Compare commits

...

6 Commits

Author SHA1 Message Date
7acb73b7fd Fix healthcheck: use 127.0.0.1 instead of localhost for Alpine compatibility
Some checks failed
Build and Deploy Website / build-and-deploy (push) Has been cancelled
Alpine Linux resolves localhost to ::1 (IPv6), but nginx only listens on
IPv4. This caused healthchecks to fail, keeping the container in "starting"
state and preventing Traefik from creating a router.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:27:30 -07:00
eccaf8a60d Complete Phase 4: Polish, Docker, CI/CD, and deployment readiness
- Fix responsive issues: Card/detail panel use p-4 md:p-8, larger
  progress dots, tighter mobile gaps in Contact section
- Fix lint errors: remove unused vars in BentoCard, AnimatedSection;
  fix explicit-any in ExpandableServiceCard test
- Fix zod-validation-error v4 dependency for eslint-plugin-react-hooks
- Add type-check script to package.json
- Create multi-stage Dockerfile (node:20-alpine build, nginx:alpine serve)
- Create .dockerignore to exclude dev files from build context
- Create nginx.conf with SPA routing, gzip, caching, security headers
- Update docker-compose.yml: build context replaces volume mount,
  preserve Traefik labels, add healthcheck
- Rewrite CI/CD pipeline: Node.js setup, npm ci, type-check, lint,
  test, build, rsync + docker compose build on server
- Add AI-generated images for all placeholder locations
- Archive original index.html as index.html.original

All checks pass: tsc --noEmit, eslint, 222/222 tests, vite build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:27:30 -07:00
2a59beeac8 Fix WorkflowCarousel layout and add placeholder image system
Restructure WorkflowCarousel into tab headers + detail panel to eliminate
blank space in inactive cards and jarring height jumps on step change.
Detail panel uses a grid overlay crossfade for smooth transitions.

Add PlaceholderImage component and integrate into Hero, BentoCard,
WorkflowCarousel detail panel, and Contact sections. Include AI generation
prompts (PROMPTS.md) for future image creation with Nano Banana 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 01:27:12 -07:00
3f8c807dac Complete Phase 3: Feature components and micro-interactions
- Add WorkflowStep component with active/inactive states
- Add WorkflowCarousel with auto-advance, keyboard nav, progress dots
- Add ExpandableServiceCard with controlled/uncontrolled expand
- Add BentoCard with lg/md/sm responsive layouts
- Add BentoGrid section with asymmetric card layout
- Add shared animation utilities and micro-interactions
- Enhance Header logo with hover effects
- Fix Header test flakiness with waitFor

Components: WorkflowStep, WorkflowCarousel, ExpandableServiceCard,
BentoCard, BentoGrid

Data files: workflow.ts, services.ts

Total: 227 passing tests (80 new tests added)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 01:27:12 -07:00
f368ae9f8d Complete Phase 2: Core sections and animation system
- Hero section with gradient headline, CTAs, capability badges
- Stats section with animated count-up numbers
- Contact section with form validation
- Input component for form fields
- useIntersectionObserver hook for scroll detection
- useAnimateOnScroll hook for scroll-triggered animations
- AnimatedSection wrapper component
- Animation variants utility (fade-up, fade-in, slide-left/right)

147 passing tests (65 new tests added)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 01:27:12 -07:00
c915a3cd66 Complete Phase 1: React modernization foundation
- Initialize Vite + React 19 + TypeScript project
- Configure Tailwind CSS v4 with design tokens
- Add Button component (13 tests)
- Add Card component (11 tests)
- Add Container component (9 tests)
- Add Header component with glassmorphism (17 tests)
- Add Footer component (13 tests)
- Add MobileNav with slide-out menu (19 tests)
- Update App.tsx with layout preview

82 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 01:27:12 -07:00
80 changed files with 11980 additions and 798 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
dist
.git
.gitea
*.md
!README.md
.env*
.env.local
coverage
.vscode
.idea
*.log
.DS_Store
Thumbs.db
plan/
.claude/

View File

@ -1,82 +1,104 @@
name: Deploy Website name: Build and Deploy Website
run-name: Deploying website changes by ${{ github.actor }} run-name: Deploying website changes by ${{ github.actor }}
on: on:
push: push:
branches: [ main ] branches: [ main ]
paths: paths:
- '*.html' - 'src/**'
- 'assets/**' - 'public/**'
- 'css/**' - 'package*.json'
- 'js/**' - 'vite.config.ts'
- 'images/**' - 'tailwind.config.ts'
- 'tsconfig*.json'
- 'index.html'
- 'Dockerfile'
- 'nginx.conf'
- 'docker-compose.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Validate HTML - name: Setup Node.js
run: | uses: actions/setup-node@v4
apt-get update && apt-get install -y tidy with:
find . -name "*.html" -exec tidy -q -e {} \; node-version: '20'
echo "✅ HTML validation passed" cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Lint
run: npm run lint
- name: Run tests
run: npm run test -- --run
- name: Build production
run: npm run build
- name: Setup SSH - name: Setup SSH
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key
echo "Host *" > ~/.ssh/config echo "Host *" > ~/.ssh/config
echo " StrictHostKeyChecking no" >> ~/.ssh/config echo " StrictHostKeyChecking no" >> ~/.ssh/config
echo " UserKnownHostsFile /dev/null" >> ~/.ssh/config echo " UserKnownHostsFile /dev/null" >> ~/.ssh/config
chmod 600 ~/.ssh/config chmod 600 ~/.ssh/config
- name: Deploy website - name: Deploy to server
run: | run: |
# Install rsync
echo "📦 Installing rsync..."
apt-get update && apt-get install -y rsync apt-get update && apt-get install -y rsync
SERVER_IP="192.168.4.56" SERVER_IP="192.168.4.56"
DEPLOY_USER="deploy" DEPLOY_USER="deploy"
WEBSITE_DIR="/media/stephen/Storage_Linux/infrastructure/services/websites/maverickApplications" WEBSITE_DIR="/media/stephen/Storage_Linux/infrastructure/services/websites/maverickApplications"
echo "📁 Syncing website files to server..." echo "Syncing project files to server..."
rsync -avz --delete \ rsync -avz --delete \
--no-times \
--no-perms \
--exclude='.git' \ --exclude='.git' \
--exclude='node_modules' \
--exclude='.gitea' \ --exclude='.gitea' \
--exclude='README.md' \ --exclude='plan' \
--exclude='docker-compose.yml' \ --exclude='.claude' \
-e "ssh -i ~/.ssh/deploy_key" \ -e "ssh -i ~/.ssh/deploy_key" \
./ $DEPLOY_USER@$SERVER_IP:$WEBSITE_DIR/ ./ $DEPLOY_USER@$SERVER_IP:$WEBSITE_DIR/
echo "🔄 Restarting website container..." echo "Building Docker image on server..."
ssh -i ~/.ssh/deploy_key $DEPLOY_USER@$SERVER_IP \ ssh -i ~/.ssh/deploy_key $DEPLOY_USER@$SERVER_IP \
"cd $WEBSITE_DIR && docker compose restart maverick-website" "cd $WEBSITE_DIR && docker compose build --no-cache"
echo "Starting updated container..."
ssh -i ~/.ssh/deploy_key $DEPLOY_USER@$SERVER_IP \
"cd $WEBSITE_DIR && docker compose up -d"
- name: Health check - name: Health check
run: | run: |
echo "🏥 Waiting for container to restart..." echo "Waiting for container to be healthy..."
sleep 15 sleep 20
for i in {1..5}; do for i in {1..5}; do
if curl -f --connect-timeout 10 https://maverickapplications.com; then if curl -f --connect-timeout 10 https://maverickapplications.com; then
echo "✅ Website is responding correctly!" echo "Website is responding correctly!"
exit 0 exit 0
fi fi
echo "⏳ Attempt $i failed, retrying in 10 seconds..." echo "Attempt $i failed, retrying in 10 seconds..."
sleep 10 sleep 10
done done
echo "❌ Website health check failed"
echo "Website health check failed"
exit 1 exit 1
- name: Cleanup - name: Cleanup
if: always() if: always()
run: rm -f ~/.ssh/deploy_key run: rm -f ~/.ssh/deploy_key

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Original site backup
index.html.original
index.html.backup*

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first (for layer caching)
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# =============================================================================
# Stage 2: Production
# =============================================================================
FROM nginx:alpine AS production
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -1,18 +1,25 @@
services: services:
maverick-website: maverick-website:
image: nginx:alpine build:
context: .
dockerfile: Dockerfile
image: maverick-website:latest
container_name: maverick-website container_name: maverick-website
restart: unless-stopped restart: unless-stopped
volumes:
- ./:/usr/share/nginx/html:ro
networks: networks:
- traefik - traefik
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.maverick-website.rule=Host(`maverickapplications.com`)" - "traefik.http.routers.maverick-website.rule=Host(`maverickapplications.com`)"
- "traefik.http.routers.maverick-website.tls=true" - "traefik.http.routers.maverick-website.entrypoints=web"
- "traefik.http.routers.maverick-website.tls.certresolver=letsencrypt" - "traefik.http.routers.maverick-website.middlewares=cloudflare-headers@file"
- "traefik.http.services.maverick-website.loadbalancer.server.port=80" - "traefik.http.services.maverick-website.loadbalancer.server.port=80"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
networks: networks:
traefik: traefik:

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@ -1,756 +1,17 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>Maverick Applications - Custom Software Solutions</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Professional software development and custom application solutions. Built with precision, delivered with excellence."> <title>Maverick Applications - Software Development Services</title>
<meta name="description" content="Professional software development services. From concept to deployment, we build modern web applications that drive business growth." />
<style> <link rel="preconnect" href="https://fonts.googleapis.com">
* { <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
margin: 0; <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
padding: 0; </head>
box-sizing: border-box; <body>
} <div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
:root { </body>
--primary: #2563eb;
--primary-dark: #1d4ed8;
--secondary: #64748b;
--accent: #f59e0b;
--text-primary: #1e293b;
--text-secondary: #64748b;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--border: #e2e8f0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}
/* Header */
header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
transition: all 0.3s ease;
}
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo::before {
content: "◆";
font-size: 1.2rem;
color: var(--accent);
}
.nav-links {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
transition: color 0.3s ease;
position: relative;
}
.nav-links a:hover {
color: var(--primary);
}
.nav-links a::after {
content: '';
position: absolute;
bottom: -5px;
left: 0;
width: 0;
height: 2px;
background: var(--primary);
transition: width 0.3s ease;
}
.nav-links a:hover::after {
width: 100%;
}
/* Hero Section */
.hero {
background: linear-gradient(135deg, var(--bg-secondary) 0%, #f1f5f9 100%);
padding: 8rem 0 6rem;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: 0;
right: -50%;
width: 100%;
height: 100%;
background: linear-gradient(135deg, transparent 0%, rgba(37, 99, 235, 0.05) 100%);
transform: skewX(-15deg);
}
.hero-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
position: relative;
z-index: 2;
}
.hero-text h1 {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-text p {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 2rem;
max-width: 500px;
}
.cta-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.btn {
padding: 0.875rem 2rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 4px 14px 0 rgba(37, 99, 235, 0.39);
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 8px 25px 0 rgba(37, 99, 235, 0.5);
}
.btn-secondary {
background: transparent;
color: var(--primary);
border: 2px solid var(--primary);
}
.btn-secondary:hover {
background: var(--primary);
color: white;
transform: translateY(-2px);
}
.hero-visual {
display: flex;
justify-content: center;
align-items: center;
}
.tech-stack {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
max-width: 400px;
}
.tech-item {
background: white;
padding: 1.5rem;
border-radius: 1rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
border: 1px solid var(--border);
}
.tech-item:hover {
transform: translateY(-5px);
}
.tech-item::before {
content: attr(data-icon);
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
/* Services Section */
.services {
padding: 6rem 0;
background: var(--bg-primary);
}
.section-header {
text-align: center;
margin-bottom: 4rem;
}
.section-header h2 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary);
}
.section-header p {
font-size: 1.125rem;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.service-card {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--border);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.service-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(90deg, var(--primary), var(--accent));
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.service-icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
.service-card h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-primary);
}
.service-card p {
color: var(--text-secondary);
line-height: 1.6;
}
/* Stats Section */
.stats {
background: var(--primary);
padding: 4rem 0;
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
text-align: center;
}
.stat-item h3 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.stat-item p {
font-size: 1.125rem;
opacity: 0.9;
}
/* Contact Section */
.contact {
padding: 6rem 0;
background: var(--bg-secondary);
}
.contact-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: start;
}
.contact-info h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-primary);
}
.contact-info p {
color: var(--text-secondary);
margin-bottom: 2rem;
}
.contact-methods {
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact-method {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
border: 1px solid var(--border);
}
.contact-method span {
font-size: 1.5rem;
}
.contact-form {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border: 1px solid var(--border);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.form-group textarea {
height: 120px;
resize: vertical;
}
/* Footer */
footer {
background: var(--text-primary);
color: white;
padding: 2rem 0;
text-align: center;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-links {
display: flex;
gap: 2rem;
}
.footer-links a {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: color 0.3s ease;
}
.footer-links a:hover {
color: white;
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 0 1rem;
}
.hero {
padding: 6rem 0 4rem;
}
.hero-content {
grid-template-columns: 1fr;
gap: 3rem;
text-align: center;
}
.hero-text h1 {
font-size: 2.5rem;
}
.nav-links {
display: none;
}
.contact-content {
grid-template-columns: 1fr;
gap: 2rem;
}
.cta-buttons {
justify-content: center;
}
.footer-content {
flex-direction: column;
text-align: center;
}
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeInUp 0.6s ease-out;
}
/* Smooth scroll */
html {
scroll-behavior: smooth;
}
</style>
</head>
<body>
<header>
<nav class="container">
<a href="#" class="logo">Maverick Applications</a>
<ul class="nav-links">
<li><a href="#home">Home</a></li>
<li><a href="#services">Services</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
</header>
<main>
<section id="home" class="hero">
<div class="container">
<div class="hero-content">
<div class="hero-text animate-fade-in">
<h1>Custom Software Solutions</h1>
<p>Building robust, scalable applications with cutting-edge technology. From concept to deployment, we deliver excellence in every line of code.</p>
<div class="cta-buttons">
<a href="#contact" class="btn btn-primary">Get Started</a>
<a href="#services" class="btn btn-secondary">Our Services</a>
</div>
</div>
<div class="hero-visual">
<div class="tech-stack">
<div class="tech-item" data-icon="⚡">
<strong>Fast</strong>
</div>
<div class="tech-item" data-icon="🔒">
<strong>Secure</strong>
</div>
<div class="tech-item" data-icon="📱">
<strong>Modern</strong>
</div>
<div class="tech-item" data-icon="☁️">
<strong>Cloud</strong>
</div>
<div class="tech-item" data-icon="🚀">
<strong>Scalable</strong>
</div>
<div class="tech-item" data-icon="🎯">
<strong>Precise</strong>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="services" class="services">
<div class="container">
<div class="section-header">
<h2>Our Services</h2>
<p>Comprehensive software development solutions tailored to your business needs</p>
</div>
<div class="services-grid">
<div class="service-card">
<span class="service-icon">💻</span>
<h3>Web Applications</h3>
<p>Full-stack web development using modern frameworks and technologies. Responsive, fast, and user-friendly applications that scale with your business.</p>
</div>
<div class="service-card">
<span class="service-icon">📱</span>
<h3>Mobile Development</h3>
<p>Native and cross-platform mobile applications for iOS and Android. Beautiful interfaces with seamless user experiences.</p>
</div>
<div class="service-card">
<span class="service-icon">🔧</span>
<h3>DevOps & Infrastructure</h3>
<p>Server setup, containerization, CI/CD pipelines, and cloud deployment. Like our Traefik-powered infrastructure for optimal performance.</p>
</div>
<div class="service-card">
<span class="service-icon">🔍</span>
<h3>System Integration</h3>
<p>Connecting disparate systems and APIs to create unified solutions. Data synchronization and workflow automation.</p>
</div>
<div class="service-card">
<span class="service-icon">🛡️</span>
<h3>Security Solutions</h3>
<p>Implementing robust security measures, authentication systems, and compliance frameworks to protect your digital assets.</p>
</div>
<div class="service-card">
<span class="service-icon">📊</span>
<h3>Data Analytics</h3>
<p>Transform your data into actionable insights with custom dashboards, reporting tools, and business intelligence solutions.</p>
</div>
</div>
</div>
</section>
<section class="stats">
<div class="container">
<div class="stats-grid">
<div class="stat-item">
<h3>100+</h3>
<p>Projects Delivered</p>
</div>
<div class="stat-item">
<h3>99.9%</h3>
<p>Uptime</p>
</div>
<div class="stat-item">
<h3>24/7</h3>
<p>Support</p>
</div>
<div class="stat-item">
<h3>5⭐</h3>
<p>Client Rating</p>
</div>
</div>
</div>
</section>
<section id="contact" class="contact">
<div class="container">
<div class="section-header">
<h2>Get In Touch</h2>
<p>Ready to start your next project? Let's discuss how we can help bring your ideas to life.</p>
</div>
<div class="contact-content">
<div class="contact-info">
<h3>Let's Work Together</h3>
<p>We're always excited to take on new challenges and help businesses grow through innovative software solutions.</p>
<div class="contact-methods">
<div class="contact-method">
<span>📧</span>
<div>
<strong>Email</strong>
<p>hello@maverickapplications.com</p>
</div>
</div>
<div class="contact-method">
<span>💬</span>
<div>
<strong>Response Time</strong>
<p>Within 24 hours</p>
</div>
</div>
<div class="contact-method">
<span>🌐</span>
<div>
<strong>Location</strong>
<p>Worldwide Remote</p>
</div>
</div>
</div>
</div>
<form class="contact-form" onsubmit="handleSubmit(event)">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="project">Project Type</label>
<input type="text" id="project" name="project" placeholder="Web App, Mobile App, etc.">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" placeholder="Tell us about your project..." required></textarea>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%;">Send Message</button>
</form>
</div>
</div>
</section>
</main>
<footer>
<div class="container">
<div class="footer-content">
<div class="logo">Maverick Applications</div>
<div class="footer-links">
<a href="#home">Home</a>
<a href="#services">Services</a>
<a href="#contact">Contact</a>
</div>
<p>&copy; 2025 Maverick Applications. All rights reserved.</p>
</div>
</div>
</footer>
<script>
// Smooth scrolling for navigation links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// Header scroll effect
window.addEventListener('scroll', () => {
const header = document.querySelector('header');
if (window.scrollY > 100) {
header.style.background = 'rgba(255, 255, 255, 0.98)';
header.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
} else {
header.style.background = 'rgba(255, 255, 255, 0.95)';
header.style.boxShadow = 'none';
}
});
// Form submission handler
function handleSubmit(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// Show success message
alert('Thank you for your message! We\'ll get back to you within 24 hours.');
form.reset();
}
// Animate elements on scroll
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-fade-in');
}
});
}, observerOptions);
// Observe service cards and other elements
document.querySelectorAll('.service-card, .stat-item').forEach(el => {
observer.observe(el);
});
</script>
</body>
</html> </html>
<!-- Last updated: $(date) -->

60
nginx.conf Normal file
View File

@ -0,0 +1,60 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets aggressively (JS, CSS, images)
# Vite adds content hashes to filenames, so long cache is safe
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Don't cache HTML (for updates to take effect)
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# SPA fallback - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Deny access to hidden files (except .well-known)
location ~ /\.(?!well-known) {
deny all;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
internal;
}
}

6591
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "maverick-applications",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"type-check": "tsc --noEmit",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"clsx": "^2.1.0",
"framer-motion": "^11.0.0",
"lucide-react": "^0.400.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^2.2.0",
"zod-validation-error": "^4.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^9.39.1",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^24.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vitest": "^2.0.0"
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
// Note: With Tailwind v4 and @tailwindcss/vite, Tailwind is processed
// directly by Vite, not PostCSS. This config is for autoprefixer only.
autoprefixer: {},
},
}

37
public/images/PROMPTS.md Normal file
View File

@ -0,0 +1,37 @@
# AI Image Generation Prompts (Nano Banana 3)
## 1. Hero Illustration (`hero-dashboard.webp`)
Isometric 3D illustration of a modern software dashboard floating above a desk workspace. Clean minimal style with soft blue (#2563eb) and amber (#f59e0b) accent colors on a light grey background. Shows a laptop screen with abstract UI components — charts, cards, and navigation elements. Subtle floating code snippets and cloud icons surround it. Professional tech company hero image style. No text. No people. Sharp vector-like rendering with soft shadows. 16:10 aspect ratio.
## 2. Web Applications Service (`service-web-apps.webp`)
Clean flat illustration of a modern web application interface. Shows a browser window with a dashboard containing analytics charts, a sidebar navigation, and data cards. Colour palette: deep blue (#2563eb) primary with white and light grey. Minimal geometric style, no text, no people. Soft drop shadows. 4:3 aspect ratio.
## 3. Mobile Development Service (`service-mobile.webp`)
Flat illustration of two smartphones side by side (iOS and Android) displaying a modern mobile app interface. Screens show a clean onboarding flow with abstract UI elements. Blue (#2563eb) accent colour, white backgrounds, rounded corners. Minimal style, no text, no people. 3:2 aspect ratio.
## 4. DevOps & Infrastructure Service (`service-devops.webp`)
Isometric illustration of cloud infrastructure: server racks, Docker containers as stacked blue cubes, CI/CD pipeline shown as connected conveyor belts with checkmarks. Kubernetes wheel logo subtly integrated. Blue (#2563eb) and slate grey colour scheme on white background. Technical but clean, no text, no people. 4:3 aspect ratio.
## 5. Contact Illustration (`contact-collab.webp`)
Warm minimal illustration of two abstract figures collaborating over a shared digital workspace. One figure gestures toward a floating screen with wireframes. Soft blue (#2563eb) and amber (#f59e0b) highlights on a light background. Friendly, approachable, professional. No realistic faces — abstract/geometric people. 4:3 aspect ratio.
## 6. Workflow: Discovery (`workflow-discovery.webp`)
Minimal illustration of a magnifying glass examining a blueprint/roadmap document. Abstract sticky notes and a checklist float nearby. Blue (#2563eb) tones with amber (#f59e0b) accent on dark slate (#1e293b) background. Clean flat style, no text, no people. Square 1:1 aspect ratio.
## 7. Workflow: Design (`workflow-design.webp`)
Minimal illustration of a design workspace: a colour palette, wireframe sketches, and a pen tool cursor. Geometric UI component shapes float in an organised arrangement. Blue (#2563eb) tones with amber accent on dark slate (#1e293b) background. Flat vector style, no text, no people. Square 1:1 aspect ratio.
## 8. Workflow: Build (`workflow-build.webp`)
Minimal illustration of code editor windows stacked at angles, showing abstract coloured syntax lines. A gear/cog icon and test-tube icon float nearby representing testing and automation. Blue (#2563eb) on dark slate (#1e293b). Clean flat style, no text, no people. Square 1:1 aspect ratio.
## 9. Workflow: Deploy (`workflow-deploy.webp`)
Minimal illustration of a rocket launching from a laptop screen toward abstract cloud shapes. Uptime chart and checkmark shield float alongside. Blue (#2563eb) with amber (#f59e0b) rocket flame on dark slate (#1e293b). Flat style, no text, no people. Square 1:1 aspect ratio.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

32
src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Header, Footer } from './components/layout'
import { Hero, Stats, Contact, WorkflowCarousel, BentoGrid } from './components/sections'
function App() {
return (
<div className="min-h-screen flex flex-col">
<Header />
{/* Main content with top padding for fixed header */}
<main className="flex-1 pt-20">
{/* Hero Section */}
<Hero />
{/* Stats Section */}
<Stats />
{/* Services Section - Bento Grid */}
<BentoGrid />
{/* Process Section - Workflow Carousel */}
<WorkflowCarousel />
{/* Contact Section */}
<Contact />
</main>
<Footer />
</div>
)
}
export default App

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BentoCard } from './BentoCard'
import type { BentoCardService } from '../../data/services'
const mockService: BentoCardService = {
id: 'web-apps',
icon: 'Globe',
title: 'Web Applications',
summary: 'Full-stack solutions for modern web',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure.',
technologies: ['React', 'Node.js', 'AWS'],
}
describe('BentoCard', () => {
it('renders service title', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
})
it('renders service icon', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByTestId('bento-card-icon')).toBeInTheDocument()
})
it('renders summary', () => {
render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
})
})
describe('BentoCard sizes', () => {
it('applies large layout classes when size="lg"', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-2')
expect(card).toHaveClass('row-span-2')
expect(card).toHaveClass('p-8')
})
it('applies medium layout classes when size="md"', () => {
render(<BentoCard service={mockService} size="md" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-2')
expect(card).toHaveClass('row-span-1')
expect(card).toHaveClass('p-6')
})
it('applies small layout classes when size="sm"', () => {
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('col-span-1')
expect(card).toHaveClass('row-span-1')
expect(card).toHaveClass('p-4')
})
it('shows technologies only for large size', () => {
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('Node.js')).toBeInTheDocument()
expect(screen.getByText('AWS')).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="md" />)
expect(screen.queryByText('React')).not.toBeInTheDocument()
rerender(<BentoCard service={mockService} size="sm" />)
expect(screen.queryByText('React')).not.toBeInTheDocument()
})
it('shows details only for large and medium sizes', () => {
const { rerender } = render(<BentoCard service={mockService} size="lg" />)
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="md" />)
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
rerender(<BentoCard service={mockService} size="sm" />)
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
})
it('merges custom className', () => {
render(<BentoCard service={mockService} size="lg" className="custom-class" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('custom-class')
expect(card).toHaveClass('col-span-2') // Still has base classes
})
})
describe('BentoCard interactions', () => {
it('applies hover effect classes', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('hover:-translate-y-1')
expect(card).toHaveClass('hover:shadow-xl')
})
it('has transition classes for animations', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('transition-all')
expect(card).toHaveClass('duration-300')
})
it('has border and rounded corners', () => {
render(<BentoCard service={mockService} size="lg" data-testid="card" />)
const card = screen.getByTestId('card')
expect(card).toHaveClass('border')
expect(card).toHaveClass('border-border')
expect(card).toHaveClass('rounded-2xl')
})
})
describe('BentoCard medium layout', () => {
it('uses horizontal flex layout for medium size', () => {
render(<BentoCard service={mockService} size="md" data-testid="card" />)
const content = screen.getByTestId('card').firstChild
expect(content).toHaveClass('flex')
expect(content).toHaveClass('gap-4')
})
})
describe('BentoCard small layout', () => {
it('centers content for small size', () => {
render(<BentoCard service={mockService} size="sm" data-testid="card" />)
const content = screen.getByTestId('card').firstChild
expect(content).toHaveClass('text-center')
})
})

View File

@ -0,0 +1,186 @@
import { Globe, Smartphone, Cloud, Plug, Shield, Lightbulb, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { BentoCardService } from '../../data/services'
const iconMap: Record<string, LucideIcon> = {
Globe,
Smartphone,
Cloud,
Plug,
Shield,
Lightbulb,
}
interface BentoCardProps {
/** Service data */
service: BentoCardService
/** Card size determines layout */
size: 'lg' | 'md' | 'sm'
/** Additional CSS classes (for grid positioning) */
className?: string
/** Test ID */
'data-testid'?: string
}
const sizeClasses = {
lg: 'col-span-2 row-span-2 p-8',
md: 'col-span-2 row-span-1 p-6',
sm: 'col-span-1 row-span-1 p-4',
}
export function BentoCard({
service,
size,
className,
'data-testid': testId,
}: BentoCardProps) {
const Icon = iconMap[service.icon] || Globe
return (
<div
data-testid={testId}
className={cn(
'bg-white border border-border rounded-2xl shadow-md',
'transition-all duration-300 ease-in-out',
'hover:-translate-y-1 hover:shadow-xl',
'group overflow-hidden',
sizeClasses[size],
className
)}
>
{size === 'lg' && (
<LargeLayout
icon={Icon}
title={service.title}
summary={service.summary}
details={service.details}
technologies={service.technologies}
image={service.image}
/>
)}
{size === 'md' && (
<MediumLayout
icon={Icon}
title={service.title}
summary={service.summary}
details={service.details}
image={service.image}
/>
)}
{size === 'sm' && (
<SmallLayout icon={Icon} title={service.title} summary={service.summary} />
)}
</div>
)
}
interface LargeLayoutProps {
icon: LucideIcon
title: string
summary: string
details: string
technologies: string[]
image?: string
}
function LargeLayout({ icon: Icon, title, summary, details, technologies, image }: LargeLayoutProps) {
return (
<div className="flex flex-col h-full">
{image && (
<div className="mb-4 -mx-8 -mt-8">
<PlaceholderImage
src={image}
alt={`${title} illustration`}
label={title}
aspectRatio="4:3"
className="w-full rounded-t-2xl"
/>
</div>
)}
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary w-fit mb-4 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-8 h-8" />
</div>
<h3 className="text-2xl font-bold text-foreground mb-2">{title}</h3>
<p className="text-muted-foreground mb-4">{summary}</p>
<p className="text-foreground flex-1">{details}</p>
<div className="flex flex-wrap gap-2 mt-4">
{technologies.map((tech, index) => (
<span
key={index}
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
>
{tech}
</span>
))}
</div>
</div>
)
}
interface MediumLayoutProps {
icon: LucideIcon
title: string
summary: string
details: string
image?: string
}
function MediumLayout({ icon: Icon, title, summary, details, image }: MediumLayoutProps) {
return (
<div className="flex gap-4 items-start">
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary shrink-0 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-foreground mb-1">{title}</h3>
<p className="text-muted-foreground text-sm mb-2">{summary}</p>
<p className="text-foreground text-sm">{details}</p>
</div>
{image && (
<div className="hidden sm:block w-24 flex-shrink-0">
<PlaceholderImage
src={image}
alt={`${title} illustration`}
label={title}
aspectRatio="3:2"
className="w-full rounded-lg"
/>
</div>
)}
</div>
)
}
interface SmallLayoutProps {
icon: LucideIcon
title: string
summary: string
}
function SmallLayout({ icon: Icon, title, summary }: SmallLayoutProps) {
return (
<div className="text-center flex flex-col items-center justify-center h-full">
<div
data-testid="bento-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary mb-3 group-hover:scale-110 transition-transform duration-300"
>
<Icon className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-1">{title}</h3>
<p className="text-muted-foreground text-sm">{summary}</p>
</div>
)
}

View File

@ -0,0 +1,166 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Globe } from 'lucide-react'
import { ExpandableServiceCard } from './ExpandableServiceCard'
describe('ExpandableServiceCard', () => {
const defaultProps = {
icon: Globe,
title: 'Web Applications',
summary: 'Full-stack solutions for modern web',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
capabilities: ['Custom dashboards', 'E-commerce platforms', 'SaaS applications'],
technologies: ['React', 'Node.js', 'AWS'],
}
it('renders title correctly', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
})
it('renders icon correctly', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByTestId('service-card-icon')).toBeInTheDocument()
})
it('renders summary in collapsed state', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByText('Full-stack solutions for modern web')).toBeInTheDocument()
})
it('is collapsed by default', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveAttribute('aria-expanded', 'false')
})
it('expands when clicked', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
await user.click(card)
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('collapses when clicked again', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
await user.click(card) // expand
await user.click(card) // collapse
expect(card).toHaveAttribute('aria-expanded', 'false')
})
it('shows details when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
})
it('shows capabilities list when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
expect(screen.getByText('E-commerce platforms')).toBeInTheDocument()
expect(screen.getByText('SaaS applications')).toBeInTheDocument()
})
it('shows technology tags when expanded', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('Node.js')).toBeInTheDocument()
expect(screen.getByText('AWS')).toBeInTheDocument()
})
it('hides details when collapsed', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.queryByText(/Modern web applications built with React/)).not.toBeInTheDocument()
})
it('uses controlled state when isExpanded prop provided', () => {
render(<ExpandableServiceCard {...defaultProps} isExpanded={true} />)
const card = screen.getByRole('button')
expect(card).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(/Modern web applications built with React/)).toBeInTheDocument()
})
it('calls onToggle when clicked (controlled)', async () => {
const handleToggle = vi.fn()
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} isExpanded={false} onToggle={handleToggle} />)
await user.click(screen.getByRole('button'))
expect(handleToggle).toHaveBeenCalledTimes(1)
})
it('applies cursor-pointer style', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveClass('cursor-pointer')
})
it('is keyboard accessible with Enter', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
card.focus()
await user.keyboard('{Enter}')
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('is keyboard accessible with Space', async () => {
const user = userEvent.setup()
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
card.focus()
await user.keyboard(' ')
expect(card).toHaveAttribute('aria-expanded', 'true')
})
it('has gradient border styling', () => {
render(<ExpandableServiceCard {...defaultProps} />)
const card = screen.getByRole('button')
expect(card).toHaveClass('before:bg-gradient-to-r')
expect(card).toHaveClass('before:from-primary')
expect(card).toHaveClass('before:to-accent')
})
it('works without technologies prop', async () => {
const user = userEvent.setup()
const propsWithoutTech = { ...defaultProps }
delete (propsWithoutTech as Record<string, unknown>).technologies
render(<ExpandableServiceCard {...propsWithoutTech} />)
await user.click(screen.getByRole('button'))
expect(screen.getByText('Custom dashboards')).toBeInTheDocument()
expect(screen.queryByText('React')).not.toBeInTheDocument()
})
it('shows expand indicator when collapsed', () => {
render(<ExpandableServiceCard {...defaultProps} />)
expect(screen.getByTestId('expand-indicator')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,162 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronUp, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
interface ExpandableServiceCardProps {
/** Lucide icon component */
icon: LucideIcon
/** Service title */
title: string
/** Short summary for collapsed state */
summary: string
/** Full description for expanded state */
details: string
/** List of capabilities/features */
capabilities: string[]
/** Technology tags (optional) */
technologies?: string[]
/** External control: is expanded */
isExpanded?: boolean
/** External control: toggle callback */
onToggle?: () => void
}
const contentVariants = {
hidden: {
opacity: 0,
height: 0,
},
visible: {
opacity: 1,
height: 'auto',
transition: { duration: 0.3, ease: 'easeOut' },
},
exit: {
opacity: 0,
height: 0,
transition: { duration: 0.2, ease: 'easeIn' },
},
}
export function ExpandableServiceCard({
icon: Icon,
title,
summary,
details,
capabilities,
technologies,
isExpanded: controlledExpanded,
onToggle,
}: ExpandableServiceCardProps) {
const [internalExpanded, setInternalExpanded] = useState(false)
// Use controlled state if provided, otherwise use internal state
const isControlled = controlledExpanded !== undefined
const expanded = isControlled ? controlledExpanded : internalExpanded
const handleToggle = () => {
if (onToggle) {
onToggle()
}
if (!isControlled) {
setInternalExpanded(!internalExpanded)
}
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
handleToggle()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={handleKeyDown}
aria-expanded={expanded}
className={cn(
'bg-white border border-border rounded-2xl p-6 cursor-pointer',
'transition-all duration-300 ease-in-out',
'hover:-translate-y-1 hover:shadow-xl',
'relative overflow-hidden',
'before:absolute before:inset-x-0 before:top-0 before:h-1',
'before:bg-gradient-to-r before:from-primary before:to-accent'
)}
>
{/* Header - Always visible */}
<div className="flex items-start justify-between mb-4">
<div
data-testid="service-card-icon"
className="p-3 rounded-xl bg-primary/10 text-primary"
>
<Icon className="w-6 h-6" />
</div>
<div
data-testid="expand-indicator"
className="p-2 text-muted-foreground"
>
{expanded ? (
<ChevronUp className="w-5 h-5" />
) : (
<ChevronDown className="w-5 h-5" />
)}
</div>
</div>
{/* Title */}
<h3 className="text-xl font-bold text-foreground mb-2">{title}</h3>
{/* Summary - Always visible */}
<p className="text-muted-foreground">{summary}</p>
{/* Expandable content */}
<AnimatePresence>
{expanded && (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={contentVariants}
className="overflow-hidden"
>
<div className="pt-4 mt-4 border-t border-border">
{/* Details */}
<p className="text-foreground mb-4">{details}</p>
{/* Capabilities */}
<div className="mb-4">
<p className="font-semibold text-foreground mb-2">What we deliver:</p>
<ul className="space-y-1">
{capabilities.map((capability, index) => (
<li key={index} className="flex items-center gap-2 text-muted-foreground">
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
{capability}
</li>
))}
</ul>
</div>
{/* Technologies */}
{technologies && technologies.length > 0 && (
<div className="flex flex-wrap gap-2">
{technologies.map((tech, index) => (
<span
key={index}
className="px-3 py-1 bg-primary/10 text-primary text-sm rounded-full font-medium"
>
{tech}
</span>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowStep } from './WorkflowStep'
import type { WorkflowStepData } from '../../data/workflow'
const mockStep: WorkflowStepData = {
id: 1,
icon: 'Search',
title: 'Discovery',
tagline: 'Understand your goals',
description: 'Test description for the discovery phase',
deliverables: ['Item 1', 'Item 2', 'Item 3'],
}
describe('WorkflowStep', () => {
const defaultProps = {
step: mockStep,
isActive: false,
onClick: vi.fn(),
stepNumber: 1,
}
it('renders step title correctly', () => {
render(<WorkflowStep {...defaultProps} />)
expect(screen.getByText('Discovery')).toBeInTheDocument()
})
it('renders step tagline correctly', () => {
render(<WorkflowStep {...defaultProps} />)
expect(screen.getByText('Understand your goals')).toBeInTheDocument()
})
it('renders step icon', () => {
render(<WorkflowStep {...defaultProps} />)
// Icon should be present (Search icon from lucide-react)
expect(screen.getByTestId('workflow-step-icon')).toBeInTheDocument()
})
it('renders step number', () => {
render(<WorkflowStep {...defaultProps} stepNumber={2} />)
expect(screen.getByText('Step 2')).toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
await user.click(stepCard)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies active styles when isActive=true', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
const stepCard = screen.getByRole('button')
// Active state: full opacity, elevated shadow, scale 1
expect(stepCard).toHaveClass('opacity-100')
expect(stepCard).toHaveClass('shadow-lg')
expect(stepCard).toHaveClass('scale-100')
})
it('applies inactive styles when isActive=false', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
const stepCard = screen.getByRole('button')
// Inactive state: reduced opacity, scale 0.95
expect(stepCard).toHaveClass('opacity-70')
expect(stepCard).toHaveClass('scale-95')
})
it('is keyboard accessible', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
stepCard.focus()
await user.keyboard('{Enter}')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('supports keyboard navigation with Space key', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<WorkflowStep {...defaultProps} onClick={handleClick} />)
const stepCard = screen.getByRole('button')
stepCard.focus()
await user.keyboard(' ')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('applies primary border when active', () => {
render(<WorkflowStep {...defaultProps} isActive={true} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('border-primary')
})
it('applies subtle border when inactive', () => {
render(<WorkflowStep {...defaultProps} isActive={false} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('border-border')
})
it('applies cursor-pointer style', () => {
render(<WorkflowStep {...defaultProps} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('cursor-pointer')
})
it('has transition classes for smooth animations', () => {
render(<WorkflowStep {...defaultProps} />)
const stepCard = screen.getByRole('button')
expect(stepCard).toHaveClass('transition-all')
expect(stepCard).toHaveClass('duration-300')
})
})

View File

@ -0,0 +1,67 @@
import { Search, Palette, Code, Rocket, type LucideIcon } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { WorkflowStepData } from '../../data/workflow'
const iconMap: Record<string, LucideIcon> = {
Search,
Palette,
Code,
Rocket,
}
interface WorkflowStepProps {
/** Step data */
step: WorkflowStepData
/** Whether this step is currently active */
isActive: boolean
/** Click handler to select this step */
onClick: () => void
/** Step number for display */
stepNumber: number
}
export function WorkflowStep({ step, isActive, onClick, stepNumber }: WorkflowStepProps) {
const Icon = iconMap[step.icon] || Search
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={cn(
'bg-white border rounded-2xl p-6 cursor-pointer transition-all duration-300 ease-in-out',
isActive
? 'opacity-100 shadow-lg scale-100 border-primary'
: 'opacity-70 shadow-md scale-95 border-border hover:opacity-90 hover:shadow-lg'
)}
>
{/* Header */}
<div className="flex items-center gap-4 mb-2">
<div
data-testid="workflow-step-icon"
className={cn(
'p-3 rounded-xl transition-colors duration-300',
isActive ? 'bg-primary/10 text-primary' : 'bg-secondary/10 text-secondary'
)}
>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-muted-foreground font-medium">Step {stepNumber}</p>
<h3 className="text-xl font-bold text-foreground">{step.title}</h3>
</div>
</div>
{/* Tagline */}
<p className="text-muted-foreground">{step.tagline}</p>
</div>
)
}

View File

@ -0,0 +1,3 @@
export { WorkflowStep } from './WorkflowStep'
export { ExpandableServiceCard } from './ExpandableServiceCard'
export { BentoCard } from './BentoCard'

View File

@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Container } from './Container'
describe('Container', () => {
it('renders children correctly', () => {
render(<Container>Test Content</Container>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('applies max-width class', () => {
render(<Container data-testid="container">Content</Container>)
const container = screen.getByTestId('container')
expect(container).toHaveClass('max-w-7xl')
})
it('applies padding classes', () => {
render(<Container data-testid="container">Content</Container>)
const container = screen.getByTestId('container')
expect(container).toHaveClass('px-4')
expect(container).toHaveClass('md:px-8')
})
it('applies auto margin for centering', () => {
render(<Container data-testid="container">Content</Container>)
const container = screen.getByTestId('container')
expect(container).toHaveClass('mx-auto')
})
it('renders as div by default', () => {
render(<Container data-testid="container">Content</Container>)
const container = screen.getByTestId('container')
expect(container.tagName).toBe('DIV')
})
it('renders as section when as="section"', () => {
render(
<Container as="section" data-testid="container">
Content
</Container>
)
const container = screen.getByTestId('container')
expect(container.tagName).toBe('SECTION')
})
it('renders as article when as="article"', () => {
render(
<Container as="article" data-testid="container">
Content
</Container>
)
const container = screen.getByTestId('container')
expect(container.tagName).toBe('ARTICLE')
})
it('renders as main when as="main"', () => {
render(
<Container as="main" data-testid="container">
Content
</Container>
)
const container = screen.getByTestId('container')
expect(container.tagName).toBe('MAIN')
})
it('merges custom className', () => {
render(
<Container className="bg-blue-500 mt-4" data-testid="container">
Content
</Container>
)
const container = screen.getByTestId('container')
expect(container).toHaveClass('max-w-7xl')
expect(container).toHaveClass('mx-auto')
expect(container).toHaveClass('px-4')
expect(container).toHaveClass('md:px-8')
expect(container).toHaveClass('bg-blue-500')
expect(container).toHaveClass('mt-4')
})
})

View File

@ -0,0 +1,28 @@
import { cn } from '../../lib/utils'
interface ContainerProps {
/** Container content */
children: React.ReactNode
/** Additional CSS classes */
className?: string
/** HTML element to render as */
as?: 'div' | 'section' | 'article' | 'main'
/** Test ID for testing */
'data-testid'?: string
}
export function Container({
children,
className,
as: Component = 'div',
'data-testid': testId,
}: ContainerProps) {
return (
<Component
className={cn('max-w-7xl mx-auto px-4 md:px-8', className)}
data-testid={testId}
>
{children}
</Component>
)
}

View File

@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Footer } from './Footer'
describe('Footer', () => {
// Mock the current year for consistent testing
const mockYear = 2026
let originalDate: DateConstructor
beforeEach(() => {
originalDate = global.Date
vi.useFakeTimers()
vi.setSystemTime(new Date(mockYear, 0, 1))
})
afterEach(() => {
vi.useRealTimers()
global.Date = originalDate
})
describe('Logo', () => {
it('renders logo correctly', () => {
render(<Footer />)
expect(screen.getByText('Maverick')).toBeInTheDocument()
})
it('logo has correct styling', () => {
render(<Footer />)
const logo = screen.getByText('Maverick').closest('a')
expect(logo).toHaveClass('text-white')
})
})
describe('Navigation Links', () => {
it('renders default navigation links', () => {
render(<Footer />)
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Services' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Contact' })).toBeInTheDocument()
})
it('renders custom links when provided', () => {
const customLinks = [
{ label: 'About', href: '#about' },
{ label: 'Portfolio', href: '#portfolio' },
]
render(<Footer links={customLinks} />)
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Portfolio' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Home' })).not.toBeInTheDocument()
})
it('links have correct href attributes', () => {
render(<Footer />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '#home')
expect(screen.getByRole('link', { name: 'Services' })).toHaveAttribute('href', '#services')
expect(screen.getByRole('link', { name: 'Contact' })).toHaveAttribute('href', '#contact')
})
it('links are accessible with proper roles', () => {
render(<Footer />)
const nav = screen.getByRole('navigation', { name: /footer/i })
expect(nav).toBeInTheDocument()
const links = screen.getAllByRole('link')
// Logo link + 3 nav links
expect(links.length).toBeGreaterThanOrEqual(3)
})
})
describe('Copyright', () => {
it('renders copyright with current year', () => {
render(<Footer />)
expect(screen.getByText(new RegExp(`${mockYear}`))).toBeInTheDocument()
})
it('includes company name in copyright', () => {
render(<Footer />)
expect(screen.getByText(/Maverick Applications/i)).toBeInTheDocument()
})
it('includes "All rights reserved" text', () => {
render(<Footer />)
expect(screen.getByText(/All rights reserved/i)).toBeInTheDocument()
})
})
describe('Styling', () => {
it('applies dark background', () => {
render(<Footer />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('bg-text-primary')
})
it('has proper footer role', () => {
render(<Footer />)
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
})
it('applies white text color', () => {
render(<Footer />)
const footer = screen.getByRole('contentinfo')
expect(footer).toHaveClass('text-white')
})
})
describe('Responsive Layout', () => {
it('uses flex container with wrap', () => {
render(<Footer />)
// The inner container should have flex and flex-wrap classes
const footer = screen.getByRole('contentinfo')
const innerContainer = footer.querySelector('[class*="flex"]')
expect(innerContainer).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,63 @@
import { Diamond } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Container } from './Container'
interface FooterLink {
label: string
href: string
}
interface FooterProps {
links?: FooterLink[]
}
const defaultLinks: FooterLink[] = [
{ label: 'Home', href: '#home' },
{ label: 'Services', href: '#services' },
{ label: 'Contact', href: '#contact' },
]
export function Footer({ links = defaultLinks }: FooterProps) {
const currentYear = new Date().getFullYear()
return (
<footer className="bg-text-primary text-white py-8">
<Container>
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Logo */}
<a
href="#home"
className="flex items-center gap-2 text-xl font-bold text-white"
>
<Diamond className="h-5 w-5 text-accent" />
<span>Maverick</span>
</a>
{/* Navigation */}
<nav aria-label="Footer navigation">
<ul className="flex flex-wrap items-center gap-6">
{links.map((link) => (
<li key={link.href}>
<a
href={link.href}
className={cn(
'text-white/80 transition-colors duration-200',
'hover:text-white'
)}
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
{/* Copyright */}
<p className="w-full text-center text-sm text-white/60 mt-4 md:w-auto md:mt-0">
&copy; {currentYear} Maverick Applications. All rights reserved.
</p>
</div>
</Container>
</footer>
)
}

View File

@ -0,0 +1,203 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Header } from './Header'
const mockScrollTo = vi.fn()
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset scroll position
Object.defineProperty(window, 'scrollY', { value: 0, writable: true })
// Mock scrollIntoView
Element.prototype.scrollIntoView = mockScrollTo
})
afterEach(() => {
document.body.style.overflow = ''
})
describe('Logo', () => {
it('renders logo correctly', () => {
render(<Header />)
expect(screen.getByText('Maverick')).toBeInTheDocument()
})
it('logo links to home', () => {
render(<Header />)
const logo = screen.getByRole('link', { name: /maverick/i })
expect(logo).toHaveAttribute('href', '#home')
})
})
describe('Navigation Links', () => {
it('renders default navigation links', () => {
render(<Header />)
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Services' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Process' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Contact' })).toBeInTheDocument()
})
it('renders custom navigation links when provided', () => {
const customLinks = [
{ label: 'About', href: '#about' },
{ label: 'Portfolio', href: '#portfolio' },
]
render(<Header links={customLinks} />)
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Portfolio' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Home' })).not.toBeInTheDocument()
})
it('links have correct href attributes', () => {
render(<Header />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '#home')
expect(screen.getByRole('link', { name: 'Services' })).toHaveAttribute('href', '#services')
expect(screen.getByRole('link', { name: 'Process' })).toHaveAttribute('href', '#process')
expect(screen.getByRole('link', { name: 'Contact' })).toHaveAttribute('href', '#contact')
})
})
describe('Styling', () => {
it('applies fixed positioning', () => {
render(<Header />)
const header = screen.getByRole('banner')
expect(header).toHaveClass('fixed')
expect(header).toHaveClass('top-0')
expect(header).toHaveClass('left-0')
expect(header).toHaveClass('right-0')
})
it('applies glassmorphism styles', () => {
render(<Header />)
const header = screen.getByRole('banner')
expect(header).toHaveClass('backdrop-blur-md')
})
it('has appropriate z-index', () => {
render(<Header />)
const header = screen.getByRole('banner')
expect(header).toHaveClass('z-50')
})
})
describe('Scroll Behavior', () => {
it('applies scrolled styles after 100px scroll', () => {
render(<Header />)
const header = screen.getByRole('banner')
// Initially not scrolled
expect(header).not.toHaveClass('shadow-md')
// Simulate scroll past 100px
Object.defineProperty(window, 'scrollY', { value: 150, writable: true })
fireEvent.scroll(window)
expect(header).toHaveClass('shadow-md')
})
it('removes scrolled styles when scrolled back up', () => {
render(<Header />)
const header = screen.getByRole('banner')
// Scroll down
Object.defineProperty(window, 'scrollY', { value: 150, writable: true })
fireEvent.scroll(window)
expect(header).toHaveClass('shadow-md')
// Scroll back up
Object.defineProperty(window, 'scrollY', { value: 50, writable: true })
fireEvent.scroll(window)
expect(header).not.toHaveClass('shadow-md')
})
it('handles smooth scroll on link click', async () => {
const user = userEvent.setup()
// Create a target element
const targetElement = document.createElement('div')
targetElement.id = 'services'
document.body.appendChild(targetElement)
render(<Header />)
await user.click(screen.getByRole('link', { name: 'Services' }))
expect(mockScrollTo).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
})
// Cleanup
document.body.removeChild(targetElement)
})
})
describe('Mobile Navigation', () => {
it('renders mobile menu button', () => {
render(<Header />)
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('opens mobile menu when hamburger is clicked', async () => {
const user = userEvent.setup()
render(<Header />)
await user.click(screen.getByRole('button', { name: /toggle menu/i }))
// Mobile nav should be visible
expect(screen.getByRole('navigation', { name: /mobile/i })).toBeInTheDocument()
})
it('closes mobile menu when close button is clicked', async () => {
const user = userEvent.setup()
render(<Header />)
// Open menu
await user.click(screen.getByRole('button', { name: /toggle menu/i }))
expect(screen.getByRole('navigation', { name: /mobile/i })).toBeInTheDocument()
// Close menu
await user.click(screen.getByRole('button', { name: /close menu/i }))
// Wait for the exit animation to complete
await waitFor(() => {
expect(screen.queryByRole('navigation', { name: /mobile/i })).not.toBeInTheDocument()
})
})
it('mobile menu has same links as desktop nav', async () => {
const user = userEvent.setup()
render(<Header />)
await user.click(screen.getByRole('button', { name: /toggle menu/i }))
const mobileNav = screen.getByRole('navigation', { name: /mobile/i })
expect(mobileNav.querySelector('a[href="#home"]')).toBeInTheDocument()
expect(mobileNav.querySelector('a[href="#services"]')).toBeInTheDocument()
expect(mobileNav.querySelector('a[href="#process"]')).toBeInTheDocument()
expect(mobileNav.querySelector('a[href="#contact"]')).toBeInTheDocument()
})
})
describe('Accessibility', () => {
it('has a proper banner role', () => {
render(<Header />)
expect(screen.getByRole('banner')).toBeInTheDocument()
})
it('desktop navigation has navigation role', () => {
render(<Header />)
expect(screen.getByRole('navigation', { name: /main/i })).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,112 @@
import { useState, useEffect } from 'react'
import { Diamond } from 'lucide-react'
import { cn } from '../../lib/utils'
import { MobileNav } from './MobileNav'
import { Container } from './Container'
interface NavLink {
label: string
href: string
}
interface HeaderProps {
links?: NavLink[]
}
const defaultLinks: NavLink[] = [
{ label: 'Home', href: '#home' },
{ label: 'Services', href: '#services' },
{ label: 'Process', href: '#process' },
{ label: 'Contact', href: '#contact' },
]
export function Header({ links = defaultLinks }: HeaderProps) {
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
// Handle scroll detection
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Handle smooth scroll to section
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
e.preventDefault()
const targetId = href.replace('#', '')
const targetElement = document.getElementById(targetId)
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
// Close mobile menu if open
setIsMobileMenuOpen(false)
}
return (
<header
className={cn(
'fixed top-0 left-0 right-0 z-50',
'bg-white/95 backdrop-blur-md',
'border-b border-border',
'transition-all duration-300',
isScrolled && 'shadow-md bg-white'
)}
>
<Container>
<div className="flex items-center justify-between py-4">
{/* Logo */}
<a
href="#home"
onClick={(e) => handleLinkClick(e, '#home')}
className="flex items-center gap-2 text-xl font-bold text-primary transition-transform duration-300 hover:scale-105"
>
<Diamond className="h-5 w-5 text-accent transition-transform duration-300 hover:rotate-12" />
<span>Maverick</span>
</a>
{/* Desktop Navigation */}
<nav aria-label="Main navigation" className="hidden md:block">
<ul className="flex items-center gap-8">
{links.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={(e) => handleLinkClick(e, link.href)}
className={cn(
'relative font-medium text-text-secondary',
'transition-colors duration-200 hover:text-primary',
'after:absolute after:bottom-0 after:left-0 after:h-0.5',
'after:w-0 after:bg-primary after:transition-all after:duration-200',
'hover:after:w-full'
)}
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
{/* Mobile Navigation */}
<div className="md:hidden">
<MobileNav
links={links}
isOpen={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)}
onToggle={() => setIsMobileMenuOpen((prev) => !prev)}
/>
</div>
</div>
</Container>
</header>
)
}

View File

@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { MobileNav, HamburgerButton } from './MobileNav'
const mockLinks = [
{ label: 'Home', href: '#home' },
{ label: 'Services', href: '#services' },
{ label: 'Contact', href: '#contact' },
]
describe('HamburgerButton', () => {
it('renders three lines when closed', () => {
render(<HamburgerButton isOpen={false} onClick={vi.fn()} />)
const button = screen.getByRole('button', { name: /toggle menu/i })
const lines = button.querySelectorAll('span')
expect(lines).toHaveLength(3)
})
it('renders as X when open', () => {
render(<HamburgerButton isOpen={true} onClick={vi.fn()} />)
const button = screen.getByRole('button', { name: /toggle menu/i })
// When open, lines should have rotation transforms applied
const lines = button.querySelectorAll('span')
expect(lines).toHaveLength(3)
// Middle line should be hidden (opacity-0)
expect(lines[1]).toHaveClass('opacity-0')
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<HamburgerButton isOpen={false} onClick={handleClick} />)
await user.click(screen.getByRole('button', { name: /toggle menu/i }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('has correct accessible name', () => {
render(<HamburgerButton isOpen={false} onClick={vi.fn()} />)
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('indicates expanded state for accessibility', () => {
const { rerender } = render(<HamburgerButton isOpen={false} onClick={vi.fn()} />)
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
rerender(<HamburgerButton isOpen={true} onClick={vi.fn()} />)
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
})
})
describe('MobileNav', () => {
const defaultProps = {
links: mockLinks,
isOpen: false,
onClose: vi.fn(),
onToggle: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
// Ensure body scroll is restored
document.body.style.overflow = ''
})
it('renders hamburger button', () => {
render(<MobileNav {...defaultProps} />)
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('hamburger button calls onToggle when clicked', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(<MobileNav {...defaultProps} onToggle={onToggle} />)
await user.click(screen.getByRole('button', { name: /toggle menu/i }))
expect(onToggle).toHaveBeenCalledTimes(1)
})
it('menu is hidden when isOpen is false', () => {
render(<MobileNav {...defaultProps} isOpen={false} />)
// Menu should not be in the document when closed
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('menu is visible when isOpen is true', () => {
render(<MobileNav {...defaultProps} isOpen={true} />)
expect(screen.getByRole('navigation')).toBeInTheDocument()
})
it('renders all navigation links', () => {
render(<MobileNav {...defaultProps} isOpen={true} />)
mockLinks.forEach((link) => {
expect(screen.getByRole('link', { name: link.label })).toBeInTheDocument()
})
})
it('navigation links have correct href attributes', () => {
render(<MobileNav {...defaultProps} isOpen={true} />)
mockLinks.forEach((link) => {
expect(screen.getByRole('link', { name: link.label })).toHaveAttribute('href', link.href)
})
})
it('calls onClose when link is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<MobileNav {...defaultProps} isOpen={true} onClose={onClose} />)
await user.click(screen.getByRole('link', { name: 'Home' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when overlay is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<MobileNav {...defaultProps} isOpen={true} onClose={onClose} />)
// Click on overlay (background)
const overlay = screen.getByTestId('mobile-nav-overlay')
await user.click(overlay)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not call onClose when menu panel is clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<MobileNav {...defaultProps} isOpen={true} onClose={onClose} />)
// Click on menu panel (not overlay)
const panel = screen.getByRole('navigation')
await user.click(panel)
expect(onClose).not.toHaveBeenCalled()
})
it('locks body scroll when menu is open', () => {
const { rerender } = render(<MobileNav {...defaultProps} isOpen={false} />)
expect(document.body.style.overflow).toBe('')
rerender(<MobileNav {...defaultProps} isOpen={true} />)
expect(document.body.style.overflow).toBe('hidden')
})
it('restores body scroll when menu is closed', () => {
const { rerender } = render(<MobileNav {...defaultProps} isOpen={true} />)
expect(document.body.style.overflow).toBe('hidden')
rerender(<MobileNav {...defaultProps} isOpen={false} />)
expect(document.body.style.overflow).toBe('')
})
it('restores body scroll on unmount', () => {
const { unmount } = render(<MobileNav {...defaultProps} isOpen={true} />)
expect(document.body.style.overflow).toBe('hidden')
unmount()
expect(document.body.style.overflow).toBe('')
})
it('renders close button inside menu', () => {
render(<MobileNav {...defaultProps} isOpen={true} />)
// There should be a close button in the menu panel
expect(screen.getByRole('button', { name: /close menu/i })).toBeInTheDocument()
})
it('close button calls onClose when clicked', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<MobileNav {...defaultProps} isOpen={true} onClose={onClose} />)
await user.click(screen.getByRole('button', { name: /close menu/i }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,146 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from 'lucide-react'
import { cn } from '../../lib/utils'
interface NavLink {
label: string
href: string
}
interface HamburgerButtonProps {
isOpen: boolean
onClick: () => void
className?: string
}
interface MobileNavProps {
links: NavLink[]
isOpen: boolean
onClose: () => void
onToggle: () => void
}
export function HamburgerButton({ isOpen, onClick, className }: HamburgerButtonProps) {
return (
<button
type="button"
onClick={onClick}
aria-label="Toggle menu"
aria-expanded={isOpen}
className={cn(
'relative flex h-6 w-6 flex-col items-center justify-center gap-1.5',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
className
)}
>
<span
className={cn(
'block h-0.5 w-6 bg-current transition-all duration-300',
isOpen && 'translate-y-2 rotate-45'
)}
/>
<span
className={cn(
'block h-0.5 w-6 bg-current transition-all duration-300',
isOpen && 'opacity-0'
)}
/>
<span
className={cn(
'block h-0.5 w-6 bg-current transition-all duration-300',
isOpen && '-translate-y-2 -rotate-45'
)}
/>
</button>
)
}
export function MobileNav({ links, isOpen, onClose, onToggle }: MobileNavProps) {
// Lock body scroll when menu is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
return (
<>
<HamburgerButton isOpen={isOpen} onClick={onToggle} />
<AnimatePresence>
{isOpen && (
<>
{/* Overlay */}
<motion.div
data-testid="mobile-nav-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Menu Panel */}
<motion.nav
aria-label="Mobile navigation"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'tween', duration: 0.3, ease: 'easeInOut' }}
className={cn(
'fixed right-0 top-0 z-50 h-full w-[80vw] max-w-[300px]',
'bg-white shadow-[-4px_0_16px_rgba(0,0,0,0.1)]'
)}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<div className="flex justify-end p-4">
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full',
'text-text-secondary hover:bg-gray-100',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
>
<X className="h-6 w-6" />
</button>
</div>
{/* Navigation links */}
<ul className="flex flex-col px-4">
{links.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={onClose}
className={cn(
'block py-4 text-xl font-medium',
'text-text-secondary hover:text-primary',
'border-b border-gray-100 last:border-b-0',
'transition-colors duration-200'
)}
>
{link.label}
</a>
</li>
))}
</ul>
</motion.nav>
</>
)}
</AnimatePresence>
</>
)
}

View File

@ -0,0 +1,4 @@
export { Container } from './Container'
export { Footer } from './Footer'
export { Header } from './Header'
export { MobileNav, HamburgerButton } from './MobileNav'

View File

@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BentoGrid } from './BentoGrid'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('BentoGrid', () => {
it('renders section header with default title', () => {
render(<BentoGrid />)
expect(screen.getByText('What We Build')).toBeInTheDocument()
expect(screen.getByText('End-to-end solutions tailored to your business needs')).toBeInTheDocument()
})
it('renders custom title when provided', () => {
render(<BentoGrid title="Our Services" subtitle="Custom solutions" />)
expect(screen.getByText('Our Services')).toBeInTheDocument()
expect(screen.getByText('Custom solutions')).toBeInTheDocument()
})
it('renders all 6 service cards', () => {
render(<BentoGrid />)
expect(screen.getByText('Web Applications')).toBeInTheDocument()
expect(screen.getByText('Mobile Development')).toBeInTheDocument()
expect(screen.getByText('DevOps & Infrastructure')).toBeInTheDocument()
expect(screen.getByText('System Integration')).toBeInTheDocument()
expect(screen.getByText('Security Solutions')).toBeInTheDocument()
expect(screen.getByText('Technical Consulting')).toBeInTheDocument()
})
it('applies correct grid classes to container', () => {
render(<BentoGrid />)
const grid = screen.getByTestId('bento-grid-container')
expect(grid).toHaveClass('grid')
expect(grid).toHaveClass('grid-cols-1')
expect(grid).toHaveClass('lg:grid-cols-4')
expect(grid).toHaveClass('gap-6')
})
})
describe('BentoGrid card positioning', () => {
it('renders 2 large cards', () => {
render(<BentoGrid />)
const largeCards = screen.getAllByTestId(/bento-card-lg/)
expect(largeCards).toHaveLength(2)
})
it('renders 2 medium cards', () => {
render(<BentoGrid />)
const mediumCards = screen.getAllByTestId(/bento-card-md/)
expect(mediumCards).toHaveLength(2)
})
it('renders 2 small cards', () => {
render(<BentoGrid />)
const smallCards = screen.getAllByTestId(/bento-card-sm/)
expect(smallCards).toHaveLength(2)
})
it('applies correct grid position classes to large cards', () => {
render(<BentoGrid />)
const largeCards = screen.getAllByTestId(/bento-card-lg/)
largeCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-2')
expect(card).toHaveClass('lg:row-span-2')
})
})
it('applies correct grid position classes to medium cards', () => {
render(<BentoGrid />)
const mediumCards = screen.getAllByTestId(/bento-card-md/)
mediumCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-2')
expect(card).toHaveClass('lg:row-span-1')
})
})
it('applies correct grid position classes to small cards', () => {
render(<BentoGrid />)
const smallCards = screen.getAllByTestId(/bento-card-sm/)
smallCards.forEach((card) => {
expect(card).toHaveClass('lg:col-span-1')
expect(card).toHaveClass('lg:row-span-1')
})
})
})
describe('BentoGrid responsiveness', () => {
it('has responsive column classes', () => {
render(<BentoGrid />)
const grid = screen.getByTestId('bento-grid-container')
// Mobile: 1 column, tablet: 2 columns, desktop: 4 columns
expect(grid).toHaveClass('grid-cols-1')
expect(grid).toHaveClass('md:grid-cols-2')
expect(grid).toHaveClass('lg:grid-cols-4')
})
it('cards have mobile override classes', () => {
render(<BentoGrid />)
// All cards should be full width on mobile
// Match only the card containers, not the icons
const largeCards = screen.getAllByTestId(/^bento-card-lg/)
const mediumCards = screen.getAllByTestId(/^bento-card-md/)
const smallCards = screen.getAllByTestId(/^bento-card-sm/)
const allCards = [...largeCards, ...mediumCards, ...smallCards]
allCards.forEach((card) => {
expect(card).toHaveClass('col-span-1')
expect(card).toHaveClass('row-span-1')
})
})
})
describe('BentoGrid section structure', () => {
it('has correct section id for navigation', () => {
render(<BentoGrid />)
const section = screen.getByTestId('bento-grid-section')
expect(section).toHaveAttribute('id', 'services')
})
it('has section padding', () => {
render(<BentoGrid />)
const section = screen.getByTestId('bento-grid-section')
expect(section).toHaveClass('py-24')
})
})

View File

@ -0,0 +1,73 @@
import { Container } from '../layout/Container'
import { BentoCard } from '../features/BentoCard'
import { AnimatedSection } from '../ui/AnimatedSection'
import { services } from '../../data/services'
import { cn } from '../../lib/utils'
interface BentoGridProps {
/** Optional section title */
title?: string
/** Optional section subtitle */
subtitle?: string
}
const sizeToResponsiveClasses = {
lg: 'lg:col-span-2 lg:row-span-2',
md: 'lg:col-span-2 lg:row-span-1',
sm: 'lg:col-span-1 lg:row-span-1',
}
export function BentoGrid({
title = 'What We Build',
subtitle = 'End-to-end solutions tailored to your business needs',
}: BentoGridProps) {
return (
<section
id="services"
data-testid="bento-grid-section"
className="py-24 bg-white"
>
<Container>
{/* Section Header */}
<AnimatedSection animation="fade-up" className="text-center mb-16">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground mb-4">
{title}
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{subtitle}
</p>
</AnimatedSection>
{/* Bento Grid */}
<AnimatedSection animation="fade-up" delay={200} stagger staggerDelay={100}>
<div
data-testid="bento-grid-container"
className={cn(
'grid gap-6',
'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
'auto-rows-min lg:grid-rows-[repeat(3,minmax(150px,auto))]'
)}
>
{services.map((service) => {
const size = service.size || 'md'
return (
<BentoCard
key={service.id}
service={service}
size={size}
data-testid={`bento-card-${size}-${service.id}`}
className={cn(
// Mobile: all cards are 1x1
'col-span-1 row-span-1',
// Desktop: apply size-specific classes
sizeToResponsiveClasses[size]
)}
/>
)
})}
</div>
</AnimatedSection>
</Container>
</section>
)
}

View File

@ -0,0 +1,160 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Contact } from './Contact'
describe('Contact', () => {
it('renders section header', () => {
render(<Contact />)
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
})
it('renders all contact methods', () => {
render(<Contact />)
// Contact method titles and values
expect(screen.getByText('hello@maverickapplications.com')).toBeInTheDocument()
expect(screen.getByText('Response Time')).toBeInTheDocument()
expect(screen.getByText('Within 24 hours')).toBeInTheDocument()
expect(screen.getByText('Location')).toBeInTheDocument()
expect(screen.getByText('Worldwide Remote')).toBeInTheDocument()
})
it('renders contact form', () => {
render(<Contact />)
expect(screen.getByRole('form')).toBeInTheDocument()
})
it('renders all form fields', () => {
render(<Contact />)
expect(screen.getByLabelText('Name')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument()
expect(screen.getByLabelText('Project Type')).toBeInTheDocument()
expect(screen.getByLabelText('Message')).toBeInTheDocument()
})
it('renders as section element with accessible label', () => {
render(<Contact />)
expect(screen.getByRole('region', { name: /contact/i })).toBeInTheDocument()
})
})
describe('Contact Form', () => {
it('validates required fields', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument()
})
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'invalid-email')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument()
})
})
it('displays error messages for invalid fields', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument()
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Project type is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('calls onSubmit with form data when valid', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
projectType: 'Web App',
message: 'Test message',
})
})
})
it('shows success message after submission', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
})
it('disables submit button while submitting', async () => {
const handleSubmit = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByRole('button', { name: /send message/i })
await user.click(submitButton)
expect(submitButton).toBeDisabled()
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
})
it('hides form and shows success after submission', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
// Form should be hidden after success
expect(screen.queryByRole('form')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /send message/i })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,234 @@
import { useState } from 'react'
import { Mail, MessageSquare, Globe, CheckCircle } from 'lucide-react'
import { Container } from '../layout/Container'
import { Card } from '../ui/Card'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import type { LucideIcon } from 'lucide-react'
interface ContactMethod {
icon: LucideIcon
title: string
value: string
}
interface ContactFormData {
name: string
email: string
projectType: string
message: string
}
interface ContactProps {
/** Form submission handler */
onSubmit?: (data: ContactFormData) => Promise<void>
}
interface FormErrors {
name?: string
email?: string
projectType?: string
message?: string
}
const contactMethods: ContactMethod[] = [
{ icon: Mail, title: 'Email', value: 'hello@maverickapplications.com' },
{ icon: MessageSquare, title: 'Response Time', value: 'Within 24 hours' },
{ icon: Globe, title: 'Location', value: 'Worldwide Remote' },
]
const initialFormData: ContactFormData = {
name: '',
email: '',
projectType: '',
message: '',
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function validateForm(data: ContactFormData): FormErrors {
const errors: FormErrors = {}
if (!data.name.trim()) {
errors.name = 'Name is required'
}
if (!data.email.trim()) {
errors.email = 'Email is required'
} else if (!validateEmail(data.email)) {
errors.email = 'Please enter a valid email'
}
if (!data.projectType.trim()) {
errors.projectType = 'Project type is required'
}
if (!data.message.trim()) {
errors.message = 'Message is required'
}
return errors
}
export function Contact({ onSubmit }: ContactProps) {
const [formData, setFormData] = useState<ContactFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const validationErrors = validateForm(formData)
setErrors(validationErrors)
if (Object.keys(validationErrors).length > 0) {
return
}
setIsSubmitting(true)
try {
if (onSubmit) {
await onSubmit(formData)
} else {
// Default behavior: simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000))
}
setIsSuccess(true)
setFormData(initialFormData)
} finally {
setIsSubmitting(false)
}
}
const handleFieldChange = (field: keyof ContactFormData) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
return (
<section
id="contact"
aria-label="Contact section"
className="bg-slate-50 py-20 md:py-28"
>
<Container>
<div className="grid gap-8 lg:grid-cols-2 lg:gap-16">
{/* Contact Info */}
<div className="space-y-8">
<div>
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
Let's Build Something Great
</h2>
<p className="text-lg text-secondary">
Ready to bring your idea to life? Get in touch and let's discuss your project.
</p>
</div>
<div className="space-y-4">
{contactMethods.map(({ icon: Icon, title, value }) => (
<div
key={title}
className="flex items-center gap-4 p-4 bg-white rounded-lg border border-slate-200"
>
<div className="flex-shrink-0 w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<div className="text-sm font-medium text-slate-500">{title}</div>
<div className="text-slate-900">{value}</div>
</div>
</div>
))}
</div>
<div className="hidden md:block">
<PlaceholderImage
src="/images/contact-collab.webp"
alt="Collaboration illustration"
label="Collaboration"
aspectRatio="4:3"
className="w-full rounded-xl"
/>
</div>
</div>
{/* Contact Form */}
<Card className="p-8">
{isSuccess ? (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">
Thank you!
</h3>
<p className="text-secondary">
We'll get back to you within 24 hours.
</p>
</div>
) : (
<form role="form" onSubmit={handleSubmit} noValidate className="space-y-6">
<Input
label="Name"
name="name"
placeholder="Your name"
required
value={formData.name}
onChange={handleFieldChange('name')}
error={errors.name}
/>
<Input
label="Email"
name="email"
type="email"
placeholder="you@example.com"
required
value={formData.email}
onChange={handleFieldChange('email')}
error={errors.email}
/>
<Input
label="Project Type"
name="projectType"
placeholder="Web App, Mobile App, etc."
required
value={formData.projectType}
onChange={handleFieldChange('projectType')}
error={errors.projectType}
/>
<Input
label="Message"
name="message"
type="textarea"
placeholder="Tell us about your project..."
required
value={formData.message}
onChange={handleFieldChange('message')}
error={errors.message}
/>
<Button
variant="primary"
type="submit"
disabled={isSubmitting}
className="w-full"
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</form>
)}
</Card>
</div>
</Container>
</section>
)
}

View File

@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Hero } from './Hero'
describe('Hero', () => {
it('renders headline correctly', () => {
render(<Hero />)
expect(screen.getByText('Software that ships.')).toBeInTheDocument()
})
it('renders subheadline correctly', () => {
render(<Hero />)
expect(
screen.getByText(/From concept to production in weeks, not months/)
).toBeInTheDocument()
})
it('renders custom headline when provided', () => {
render(<Hero headline="Custom Headline" />)
expect(screen.getByText('Custom Headline')).toBeInTheDocument()
expect(screen.queryByText('Software that ships.')).not.toBeInTheDocument()
})
it('renders custom subheadline when provided', () => {
render(<Hero subheadline="Custom subheadline text" />)
expect(screen.getByText('Custom subheadline text')).toBeInTheDocument()
})
it('renders primary CTA button', () => {
render(<Hero />)
expect(screen.getByRole('link', { name: 'Start Your Project' })).toBeInTheDocument()
})
it('renders secondary CTA button', () => {
render(<Hero />)
expect(screen.getByRole('link', { name: 'See How We Work' })).toBeInTheDocument()
})
it('primary CTA links to #contact', () => {
render(<Hero />)
const primaryCTA = screen.getByRole('link', { name: 'Start Your Project' })
expect(primaryCTA).toHaveAttribute('href', '#contact')
})
it('secondary CTA links to #process', () => {
render(<Hero />)
const secondaryCTA = screen.getByRole('link', { name: 'See How We Work' })
expect(secondaryCTA).toHaveAttribute('href', '#process')
})
it('renders capability badges', () => {
render(<Hero />)
expect(screen.getByText('Fast Delivery')).toBeInTheDocument()
expect(screen.getByText('Secure by Default')).toBeInTheDocument()
expect(screen.getByText('Modern Stack')).toBeInTheDocument()
expect(screen.getByText('Cloud Native')).toBeInTheDocument()
expect(screen.getByText('Built to Scale')).toBeInTheDocument()
expect(screen.getByText('Pixel Perfect')).toBeInTheDocument()
})
it('applies gradient text class to headline', () => {
render(<Hero />)
const headline = screen.getByText('Software that ships.')
expect(headline).toHaveClass('bg-gradient-to-r')
expect(headline).toHaveClass('bg-clip-text')
expect(headline).toHaveClass('text-transparent')
})
it('renders as section element', () => {
render(<Hero />)
expect(screen.getByRole('region')).toBeInTheDocument()
})
it('has accessible label', () => {
render(<Hero />)
expect(screen.getByRole('region', { name: /hero/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,135 @@
import { motion } from 'framer-motion'
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Button } from '../ui/Button'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { Container } from '../layout/Container'
interface HeroProps {
/** Optional custom headline */
headline?: string
/** Optional custom subheadline */
subheadline?: string
}
const DEFAULT_HEADLINE = 'Software that ships.'
const DEFAULT_SUBHEADLINE = 'From concept to production in weeks, not months. Custom solutions built fast, built right.'
const capabilities = [
{ label: 'Fast Delivery', icon: Zap },
{ label: 'Secure by Default', icon: Shield },
{ label: 'Modern Stack', icon: Layers },
{ label: 'Cloud Native', icon: Cloud },
{ label: 'Built to Scale', icon: TrendingUp },
{ label: 'Pixel Perfect', icon: Sparkles },
]
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
}
export function Hero({ headline, subheadline }: HeroProps) {
const displayHeadline = headline ?? DEFAULT_HEADLINE
const displaySubheadline = subheadline ?? DEFAULT_SUBHEADLINE
return (
<section
aria-label="Hero section"
className="relative overflow-hidden bg-gradient-to-br from-slate-50 via-blue-50/30 to-white py-20 md:py-32"
>
{/* Decorative skewed overlay */}
<div className="absolute inset-0 -skew-y-3 bg-gradient-to-r from-primary/5 to-transparent" />
<Container className="relative">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid gap-12 lg:grid-cols-2 lg:gap-16 items-center"
>
{/* Text content */}
<div className="space-y-8">
<motion.h1
variants={itemVariants}
className={cn(
'text-4xl md:text-[3.5rem] font-extrabold leading-tight',
'bg-gradient-to-r from-slate-900 to-primary bg-clip-text text-transparent'
)}
>
{displayHeadline}
</motion.h1>
<motion.p
variants={itemVariants}
className="text-xl text-secondary max-w-[500px]"
>
{displaySubheadline}
</motion.p>
<motion.div
variants={itemVariants}
className="flex flex-wrap gap-4"
>
<Button variant="primary" size="lg" href="#contact">
Start Your Project
</Button>
<Button variant="secondary" size="lg" href="#process">
See How We Work
</Button>
</motion.div>
{/* Capability badges — below text on all sizes */}
<motion.div
variants={itemVariants}
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
>
{capabilities.map(({ label, icon: Icon }) => (
<div
key={label}
className={cn(
'flex items-center gap-3 p-4',
'bg-white rounded-xl shadow-sm',
'hover:-translate-y-1 hover:shadow-md',
'transition-all duration-300'
)}
>
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-sm font-medium text-slate-700">
{label}
</span>
</div>
))}
</motion.div>
</div>
{/* Hero illustration — visible on lg screens */}
<motion.div
variants={itemVariants}
className="hidden lg:block"
>
<PlaceholderImage
src="/images/hero-dashboard.webp"
alt="Software dashboard illustration"
label="Dashboard"
aspectRatio="16:10"
className="w-full rounded-2xl shadow-lg"
/>
</motion.div>
</motion.div>
</Container>
</section>
)
}

View File

@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Stats } from './Stats'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
describe('Stats', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders all default stats', () => {
render(<Stats />)
expect(screen.getByText('Projects Delivered')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Support')).toBeInTheDocument()
expect(screen.getByText('Client Rating')).toBeInTheDocument()
})
it('renders custom stats when provided', () => {
const customStats = [
{ id: 'custom1', value: 50, label: 'Custom Stat' },
{ id: 'custom2', value: 75, suffix: '%', label: 'Percentage Stat' },
]
render(<Stats stats={customStats} />)
expect(screen.getByText('Custom Stat')).toBeInTheDocument()
expect(screen.getByText('Percentage Stat')).toBeInTheDocument()
expect(screen.queryByText('Projects Delivered')).not.toBeInTheDocument()
})
it('displays stat labels correctly', () => {
render(<Stats />)
expect(screen.getByText('Projects Delivered')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Support')).toBeInTheDocument()
expect(screen.getByText('Client Rating')).toBeInTheDocument()
})
it('displays stat values with suffixes', () => {
render(<Stats />)
// Values show with their suffixes
expect(screen.getByText(/\+/)).toBeInTheDocument() // 100+
expect(screen.getByText(/%/)).toBeInTheDocument() // 99.9%
expect(screen.getByText(/\/7/)).toBeInTheDocument() // 24/7
expect(screen.getByText(/★/)).toBeInTheDocument() // 5★
})
it('applies primary background color', () => {
render(<Stats />)
const section = screen.getByRole('region', { name: /stats/i })
expect(section).toHaveClass('bg-primary')
})
it('renders in a grid layout', () => {
render(<Stats />)
const grid = screen.getByTestId('stats-grid')
expect(grid).toHaveClass('grid')
})
it('renders as section element with accessible label', () => {
render(<Stats />)
expect(screen.getByRole('region', { name: /stats/i })).toBeInTheDocument()
})
it('renders correct number of stat items', () => {
render(<Stats />)
const statItems = screen.getAllByTestId(/^stat-/)
expect(statItems).toHaveLength(4)
})
})

View File

@ -0,0 +1,103 @@
import { useEffect, useState, useRef } from 'react'
import { Container } from '../layout/Container'
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'
interface Stat {
id: string
value: number
suffix?: string
label: string
}
interface StatsProps {
/** Optional custom stats */
stats?: Stat[]
}
const defaultStats: Stat[] = [
{ id: 'projects', value: 100, suffix: '+', label: 'Projects Delivered' },
{ id: 'uptime', value: 99.9, suffix: '%', label: 'Uptime' },
{ id: 'support', value: 24, suffix: '/7', label: 'Support' },
{ id: 'rating', value: 5, suffix: '★', label: 'Client Rating' },
]
function useCountUp(end: number, isVisible: boolean, duration: number = 2000) {
const [count, setCount] = useState(0)
const hasAnimated = useRef(false)
useEffect(() => {
if (!isVisible || hasAnimated.current) return
hasAnimated.current = true
const startTime = performance.now()
const isDecimal = end % 1 !== 0
const decimalPlaces = isDecimal ? 1 : 0
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const easeOut = 1 - Math.pow(1 - progress, 3)
const currentValue = end * easeOut
if (isDecimal) {
setCount(parseFloat(currentValue.toFixed(decimalPlaces)))
} else {
setCount(Math.floor(currentValue))
}
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}, [end, duration, isVisible])
return count
}
function StatItem({ stat, isVisible }: { stat: Stat; isVisible: boolean }) {
const count = useCountUp(stat.value, isVisible)
return (
<div
data-testid={`stat-${stat.id}`}
className="text-center py-4"
>
<div className="text-5xl md:text-6xl font-extrabold mb-2">
{count}
{stat.suffix && <span>{stat.suffix}</span>}
</div>
<div className="text-lg opacity-90">{stat.label}</div>
</div>
)
}
export function Stats({ stats }: StatsProps) {
const displayStats = stats ?? defaultStats
const { ref, isVisible } = useIntersectionObserver<HTMLElement>({
threshold: 0.2,
triggerOnce: true,
})
return (
<section
ref={ref}
aria-label="Stats section"
className="bg-primary text-white py-16 md:py-20"
>
<Container>
<div
data-testid="stats-grid"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8"
>
{displayStats.map((stat) => (
<StatItem key={stat.id} stat={stat} isVisible={isVisible} />
))}
</div>
</Container>
</section>
)
}

View File

@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowCarousel } from './WorkflowCarousel'
describe('WorkflowCarousel', () => {
it('renders section header', () => {
render(<WorkflowCarousel autoAdvance={false} />)
expect(screen.getByText('How We Work')).toBeInTheDocument()
expect(screen.getByText('From idea to production in four focused phases')).toBeInTheDocument()
})
it('renders all 4 workflow steps', () => {
render(<WorkflowCarousel autoAdvance={false} />)
// Discovery appears in both the tab and detail panel
expect(screen.getAllByText('Discovery').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Design')).toBeInTheDocument()
expect(screen.getByText('Build')).toBeInTheDocument()
expect(screen.getByText('Deploy')).toBeInTheDocument()
})
it('renders progress indicator dots', () => {
render(<WorkflowCarousel autoAdvance={false} />)
const dots = screen.getAllByRole('button', { name: /go to step/i })
expect(dots).toHaveLength(4)
})
it('first step is active by default', () => {
render(<WorkflowCarousel autoAdvance={false} />)
// The first step should show its description (expanded state)
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('clicking a step makes it active', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
// Click the Design step
const designStep = screen.getByText('Design').closest('[role="button"]')
await user.click(designStep!)
// Design step description should now be visible
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('clicking a dot navigates to that step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
// Click the third dot (Build)
const dots = screen.getAllByRole('button', { name: /go to step/i })
await user.click(dots[2])
// Build step description should now be visible
expect(screen.getByText(/Agile development/i)).toBeInTheDocument()
})
it('has correct aria-current on active dot', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const dots = screen.getAllByRole('button', { name: /go to step/i })
// First dot should have aria-current="step" initially
expect(dots[0]).toHaveAttribute('aria-current', 'step')
expect(dots[1]).not.toHaveAttribute('aria-current')
// Click second dot
await user.click(dots[1])
// Second dot should now have aria-current
expect(dots[0]).not.toHaveAttribute('aria-current')
expect(dots[1]).toHaveAttribute('aria-current', 'step')
})
})
describe('Auto-advance', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('auto-advances after interval', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
// Initially first step is active
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
// Advance time
act(() => {
vi.advanceTimersByTime(5000)
})
// Second step should now be active (Design)
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('pauses on hover', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
const carousel = screen.getByTestId('workflow-carousel')
// Hover over carousel
fireEvent.mouseEnter(carousel)
// Advance time
act(() => {
vi.advanceTimersByTime(6000)
})
// Should still be on first step (paused)
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
})
it('resumes after mouse leaves', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
const carousel = screen.getByTestId('workflow-carousel')
// Hover then unhover
fireEvent.mouseEnter(carousel)
act(() => {
vi.advanceTimersByTime(3000)
})
fireEvent.mouseLeave(carousel)
// Should still be on first step
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
// Now advance time - should move to next step
act(() => {
vi.advanceTimersByTime(5000)
})
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('loops back to first step after last', () => {
render(<WorkflowCarousel autoAdvance={true} autoAdvanceInterval={5000} />)
// Advance through all 4 steps
act(() => {
vi.advanceTimersByTime(5000) // Step 2
})
act(() => {
vi.advanceTimersByTime(5000) // Step 3
})
act(() => {
vi.advanceTimersByTime(5000) // Step 4
})
// Should be on Deploy (step 4)
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
// Advance one more time - should loop back to step 1
act(() => {
vi.advanceTimersByTime(5000)
})
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('does not auto-advance when autoAdvance=false', () => {
render(<WorkflowCarousel autoAdvance={false} />)
act(() => {
vi.advanceTimersByTime(10000)
})
// Should still be on first step
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
expect(screen.queryByText(/Architecture planning/i)).not.toBeInTheDocument()
})
})
describe('Keyboard navigation', () => {
it('right arrow advances to next step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
await user.keyboard('{ArrowRight}')
// Second step should now be active
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
})
it('left arrow goes to previous step', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Go to second step first
await user.keyboard('{ArrowRight}')
expect(screen.getByText(/Architecture planning/i)).toBeInTheDocument()
// Go back
await user.keyboard('{ArrowLeft}')
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('wraps around at end boundary', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Go to last step
await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}')
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
// Arrow right should wrap to first step
await user.keyboard('{ArrowRight}')
expect(screen.getByText(/business requirements/i)).toBeInTheDocument()
})
it('wraps around at start boundary', async () => {
const user = userEvent.setup()
render(<WorkflowCarousel autoAdvance={false} />)
const carousel = screen.getByTestId('workflow-carousel')
carousel.focus()
// Arrow left from first step should go to last step
await user.keyboard('{ArrowLeft}')
expect(screen.getByText(/Production deployment/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Container } from '../layout/Container'
import { WorkflowStep } from '../features/WorkflowStep'
import { PlaceholderImage } from '../ui/PlaceholderImage'
import { workflowSteps } from '../../data/workflow'
import { cn } from '../../lib/utils'
interface WorkflowCarouselProps {
/** Enable auto-advance */
autoAdvance?: boolean
/** Auto-advance interval in ms */
autoAdvanceInterval?: number
}
export function WorkflowCarousel({
autoAdvance = true,
autoAdvanceInterval = 5000,
}: WorkflowCarouselProps) {
const [activeStep, setActiveStep] = useState(0)
const [isPaused, setIsPaused] = useState(false)
// Auto-advance effect
useEffect(() => {
if (!autoAdvance || isPaused) {
return
}
const interval = setInterval(() => {
setActiveStep((current) => (current + 1) % workflowSteps.length)
}, autoAdvanceInterval)
return () => clearInterval(interval)
}, [autoAdvance, autoAdvanceInterval, isPaused])
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'ArrowRight') {
event.preventDefault()
setActiveStep((current) => (current + 1) % workflowSteps.length)
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
setActiveStep((current) => (current - 1 + workflowSteps.length) % workflowSteps.length)
}
}
return (
<section
id="process"
className="py-24 bg-slate-900"
data-testid="workflow-carousel"
tabIndex={0}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<Container>
{/* Section Header */}
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
How We Work
</h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto">
From idea to production in four focused phases
</p>
</div>
{/* Step Tabs */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{workflowSteps.map((step, index) => (
<WorkflowStep
key={step.id}
step={step}
stepNumber={index + 1}
isActive={activeStep === index}
onClick={() => setActiveStep(index)}
/>
))}
</div>
{/* Detail Panel — grid overlay keeps both panels in the same cell during crossfade */}
<div className="grid mt-8 mb-12 [&>*]:col-start-1 [&>*]:row-start-1">
<AnimatePresence>
<motion.div
key={activeStep}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 1 }}
transition={{ duration: 1.0, ease: 'easeInOut' }}
className="bg-white rounded-2xl p-4 md:p-8 border border-slate-200"
>
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-1">
<h3 className="text-2xl font-bold text-foreground mb-3">
{workflowSteps[activeStep].title}
</h3>
<p className="text-foreground mb-4">
{workflowSteps[activeStep].description}
</p>
<div>
<p className="font-semibold text-foreground mb-2">Deliverables:</p>
<ul className="space-y-1">
{workflowSteps[activeStep].deliverables.map((item, index) => (
<li key={index} className="flex items-center gap-2 text-muted-foreground">
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
{item}
</li>
))}
</ul>
</div>
</div>
{workflowSteps[activeStep].image && (
<div className="md:w-64 lg:w-80 flex-shrink-0">
<PlaceholderImage
src={workflowSteps[activeStep].image}
alt={`${workflowSteps[activeStep].title} illustration`}
label={workflowSteps[activeStep].title}
className="w-full rounded-xl"
aspectRatio="1:1"
/>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
{/* Progress Dots */}
<div className="flex justify-center items-center gap-3">
{workflowSteps.map((_, index) => (
<button
key={index}
onClick={() => setActiveStep(index)}
aria-label={`Go to step ${index + 1}`}
aria-current={activeStep === index ? 'step' : undefined}
className={cn(
'rounded-full transition-all duration-300 p-2',
activeStep === index
? 'w-5 h-5 bg-primary'
: 'w-4 h-4 bg-slate-600 hover:bg-slate-500'
)}
/>
))}
</div>
{/* Progress Bar (for auto-advance) */}
{autoAdvance && !isPaused && (
<div className="mt-4 flex justify-center">
<div className="w-48 h-1 bg-slate-700 rounded-full overflow-hidden">
<motion.div
key={activeStep}
className="h-full bg-primary origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: autoAdvanceInterval / 1000, ease: 'linear' }}
/>
</div>
</div>
)}
</Container>
</section>
)
}

View File

@ -0,0 +1,5 @@
export { Hero } from './Hero'
export { Stats } from './Stats'
export { Contact } from './Contact'
export { WorkflowCarousel } from './WorkflowCarousel'
export { BentoGrid } from './BentoGrid'

View File

@ -0,0 +1,129 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { AnimatedSection } from './AnimatedSection'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
describe('AnimatedSection', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders children correctly', () => {
render(
<AnimatedSection>
<p>Test content</p>
</AnimatedSection>
)
expect(screen.getByText('Test content')).toBeInTheDocument()
})
it('applies custom className', () => {
render(
<AnimatedSection className="custom-class">
<p>Content</p>
</AnimatedSection>
)
const container = screen.getByText('Content').parentElement
expect(container).toHaveClass('custom-class')
})
it('renders with default fade-up animation', async () => {
render(
<AnimatedSection>
<p>Content</p>
</AnimatedSection>
)
// Component should render without errors
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('applies different animation variants', () => {
const { rerender } = render(
<AnimatedSection animation="slide-left">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
rerender(
<AnimatedSection animation="slide-right">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
rerender(
<AnimatedSection animation="fade-in">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('renders multiple children', () => {
render(
<AnimatedSection>
<p>Child 1</p>
<p>Child 2</p>
<p>Child 3</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
expect(screen.getByText('Child 3')).toBeInTheDocument()
})
it('renders with stagger enabled', () => {
render(
<AnimatedSection stagger>
<p>Child 1</p>
<p>Child 2</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
it('accepts custom delay', () => {
render(
<AnimatedSection delay={500}>
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('accepts custom stagger delay', () => {
render(
<AnimatedSection stagger staggerDelay={200}>
<p>Child 1</p>
<p>Child 2</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,78 @@
import { motion } from 'framer-motion'
import { Children, isValidElement } from 'react'
import { cn } from '../../lib/utils'
import { useAnimateOnScroll } from '../../hooks/useAnimateOnScroll'
import { animationVariants, type AnimationVariant } from '../../utils/animations'
interface AnimatedSectionProps {
children: React.ReactNode
className?: string
/** Animation variant */
animation?: AnimationVariant
/** Delay before animation starts (ms) */
delay?: number
/** Stagger children animations */
stagger?: boolean
/** Stagger delay between children (ms) */
staggerDelay?: number
}
export function AnimatedSection({
children,
className,
animation = 'fade-up',
delay = 0,
stagger = false,
staggerDelay = 100,
}: AnimatedSectionProps) {
const { ref, isVisible } = useAnimateOnScroll<HTMLDivElement>({
animation,
delay,
})
if (stagger) {
const customStaggerContainer = {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay / 1000,
delayChildren: delay / 1000,
},
},
}
return (
<motion.div
ref={ref}
className={cn(className)}
initial="hidden"
animate={isVisible ? 'visible' : 'hidden'}
variants={customStaggerContainer}
>
{Children.map(children, (child) => {
if (isValidElement(child)) {
return (
<motion.div variants={animationVariants[animation]}>
{child}
</motion.div>
)
}
return child
})}
</motion.div>
)
}
return (
<motion.div
ref={ref}
className={cn(className)}
initial="hidden"
animate={isVisible ? 'visible' : 'hidden'}
variants={animationVariants[animation]}
transition={delay > 0 ? { delay: delay / 1000 } : undefined}
>
{children}
</motion.div>
)
}

View File

@ -0,0 +1,148 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('renders children correctly', () => {
render(<Button variant="primary">Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('applies primary variant styles by default', () => {
render(<Button variant="primary">Primary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-primary')
expect(button).toHaveClass('text-white')
})
it('applies secondary variant styles', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('border-primary')
expect(button).toHaveClass('text-primary')
expect(button).toHaveClass('bg-transparent')
})
it('applies ghost variant styles', () => {
render(<Button variant="ghost">Ghost</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-transparent')
expect(button).toHaveClass('text-primary')
})
it('renders as anchor when href is provided', () => {
render(
<Button variant="primary" href="https://example.com">
Link
</Button>
)
const link = screen.getByRole('link')
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', 'https://example.com')
})
it('renders as button when href is not provided', () => {
render(<Button variant="primary">Button</Button>)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('calls onClick when clicked', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(
<Button variant="primary" onClick={handleClick}>
Click me
</Button>
)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(
<Button variant="primary" onClick={handleClick} disabled>
Click me
</Button>
)
await user.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
it('applies disabled styles when disabled', () => {
render(
<Button variant="primary" disabled>
Disabled
</Button>
)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button).toHaveClass('opacity-50')
expect(button).toHaveClass('cursor-not-allowed')
})
it('applies size classes correctly', () => {
const { rerender } = render(
<Button variant="primary" size="sm">
Small
</Button>
)
expect(screen.getByRole('button')).toHaveClass('py-2', 'px-4', 'text-sm')
rerender(
<Button variant="primary" size="md">
Medium
</Button>
)
expect(screen.getByRole('button')).toHaveClass('py-3', 'px-6', 'text-base')
rerender(
<Button variant="primary" size="lg">
Large
</Button>
)
expect(screen.getByRole('button')).toHaveClass('py-4', 'px-8', 'text-lg')
})
it('defaults to md size when not specified', () => {
render(<Button variant="primary">Default Size</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('py-3', 'px-6', 'text-base')
})
it('merges custom className', () => {
render(
<Button variant="primary" className="custom-class">
Custom
</Button>
)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('applies correct button type attribute', () => {
const { rerender } = render(
<Button variant="primary" type="submit">
Submit
</Button>
)
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
rerender(
<Button variant="primary" type="reset">
Reset
</Button>
)
expect(screen.getByRole('button')).toHaveAttribute('type', 'reset')
rerender(<Button variant="primary">Default</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
})
})

View File

@ -0,0 +1,74 @@
import { cn } from '../../lib/utils'
interface ButtonProps {
/** Visual style variant */
variant: 'primary' | 'secondary' | 'ghost'
/** Size variant */
size?: 'sm' | 'md' | 'lg'
/** Button content */
children: React.ReactNode
/** If provided, renders as an anchor tag */
href?: string
/** Click handler (for button behavior) */
onClick?: () => void
/** Disabled state */
disabled?: boolean
/** Additional CSS classes */
className?: string
/** Button type attribute */
type?: 'button' | 'submit' | 'reset'
}
const variantStyles = {
primary: 'bg-primary text-white shadow-[0_4px_14px_0_rgba(37,99,235,0.39)] hover:bg-primary-dark hover:-translate-y-0.5 hover:shadow-[0_6px_20px_0_rgba(37,99,235,0.5)]',
secondary: 'bg-transparent border-2 border-primary text-primary hover:bg-primary hover:text-white',
ghost: 'bg-transparent text-primary hover:underline',
}
const sizeStyles = {
sm: 'py-2 px-4 text-sm',
md: 'py-3 px-6 text-base',
lg: 'py-4 px-8 text-lg',
}
export function Button({
variant,
size = 'md',
children,
href,
onClick,
disabled = false,
className,
type = 'button',
}: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-lg transition-all duration-300'
const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : ''
const combinedClassName = cn(
baseStyles,
variantStyles[variant],
sizeStyles[size],
disabledStyles,
className
)
if (href) {
return (
<a href={href} className={combinedClassName}>
{children}
</a>
)
}
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={combinedClassName}
>
{children}
</button>
)
}

View File

@ -0,0 +1,163 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Card } from './Card';
describe('Card', () => {
it('renders children correctly', () => {
render(
<Card>
<p>Test content</p>
</Card>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('applies base styles', () => {
render(
<Card data-testid="card">
<p>Content</p>
</Card>
);
const card = screen.getByTestId('card');
// Base styles: white bg, border, rounded-2xl, responsive padding, shadow-md
expect(card).toHaveClass('bg-white');
expect(card).toHaveClass('border');
expect(card).toHaveClass('border-border');
expect(card).toHaveClass('rounded-2xl');
expect(card).toHaveClass('p-4');
expect(card).toHaveClass('md:p-8');
expect(card).toHaveClass('shadow-md');
});
it('applies hover classes when hover prop is true', () => {
render(
<Card hover data-testid="card">
<p>Hoverable card</p>
</Card>
);
const card = screen.getByTestId('card');
// Hover state should add hover transform and shadow classes
expect(card).toHaveClass('hover:-translate-y-1.5');
expect(card).toHaveClass('hover:shadow-xl');
});
it('does not apply hover classes when hover prop is false', () => {
render(
<Card hover={false} data-testid="card">
<p>Non-hoverable card</p>
</Card>
);
const card = screen.getByTestId('card');
expect(card).not.toHaveClass('hover:-translate-y-1.5');
expect(card).not.toHaveClass('hover:shadow-xl');
});
it('renders gradient border when gradientBorder is true', () => {
render(
<Card gradientBorder data-testid="card">
<p>Card with gradient border</p>
</Card>
);
const card = screen.getByTestId('card');
// Should have relative positioning for the pseudo-element
expect(card).toHaveClass('relative');
expect(card).toHaveClass('overflow-hidden');
// The gradient border is implemented via a before pseudo-element
// We check for the class that enables the gradient border styling
expect(card).toHaveClass('before:absolute');
expect(card).toHaveClass('before:inset-x-0');
expect(card).toHaveClass('before:top-0');
expect(card).toHaveClass('before:h-1');
expect(card).toHaveClass('before:bg-gradient-to-r');
expect(card).toHaveClass('before:from-primary');
expect(card).toHaveClass('before:to-accent');
});
it('does not render gradient border when gradientBorder is false', () => {
render(
<Card gradientBorder={false} data-testid="card">
<p>Card without gradient border</p>
</Card>
);
const card = screen.getByTestId('card');
expect(card).not.toHaveClass('before:absolute');
expect(card).not.toHaveClass('before:bg-gradient-to-r');
});
it('merges custom className', () => {
render(
<Card className="custom-class another-class" data-testid="card">
<p>Card with custom classes</p>
</Card>
);
const card = screen.getByTestId('card');
// Should have custom classes merged with base classes
expect(card).toHaveClass('custom-class');
expect(card).toHaveClass('another-class');
// Should still have base classes
expect(card).toHaveClass('bg-white');
expect(card).toHaveClass('rounded-2xl');
});
it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Card onClick={handleClick} data-testid="card">
<p>Clickable card</p>
</Card>
);
const card = screen.getByTestId('card');
await user.click(card);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('has correct transition styles', () => {
render(
<Card data-testid="card">
<p>Card with transitions</p>
</Card>
);
const card = screen.getByTestId('card');
// Should have transition classes for smooth animations
expect(card).toHaveClass('transition-all');
expect(card).toHaveClass('duration-300');
expect(card).toHaveClass('ease-in-out');
});
it('applies cursor-pointer when onClick is provided', () => {
const handleClick = vi.fn();
render(
<Card onClick={handleClick} data-testid="card">
<p>Clickable card</p>
</Card>
);
const card = screen.getByTestId('card');
expect(card).toHaveClass('cursor-pointer');
});
it('does not apply cursor-pointer when onClick is not provided', () => {
render(
<Card data-testid="card">
<p>Non-clickable card</p>
</Card>
);
const card = screen.getByTestId('card');
expect(card).not.toHaveClass('cursor-pointer');
});
});

View File

@ -0,0 +1,45 @@
import { cn } from '../../lib/utils'
interface CardProps {
/** Card content */
children: React.ReactNode
/** Additional CSS classes */
className?: string
/** Enable hover lift effect */
hover?: boolean
/** Show gradient top border */
gradientBorder?: boolean
/** Click handler */
onClick?: () => void
/** Test ID for testing */
'data-testid'?: string
}
export function Card({
children,
className,
hover = false,
gradientBorder = false,
onClick,
'data-testid': testId,
}: CardProps) {
const baseStyles = 'bg-white border border-border rounded-2xl p-4 md:p-8 shadow-md transition-all duration-300 ease-in-out'
const hoverStyles = hover ? 'hover:-translate-y-1.5 hover:shadow-xl' : ''
const gradientBorderStyles = gradientBorder
? 'relative overflow-hidden before:absolute before:inset-x-0 before:top-0 before:h-1 before:bg-gradient-to-r before:from-primary before:to-accent'
: ''
const clickableStyles = onClick ? 'cursor-pointer' : ''
return (
<div
className={cn(baseStyles, hoverStyles, gradientBorderStyles, clickableStyles, className)}
onClick={onClick}
data-testid={testId}
>
{children}
</div>
)
}

View File

@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Input } from './Input'
describe('Input', () => {
it('renders label correctly', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
expect(screen.getByLabelText('Name')).toBeInTheDocument()
})
it('renders input with correct type', () => {
render(<Input label="Email" name="email" type="email" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Email')
expect(input).toHaveAttribute('type', 'email')
})
it('renders text input by default', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveAttribute('type', 'text')
})
it('renders textarea when type is textarea', () => {
render(<Input label="Message" name="message" type="textarea" value="" onChange={() => {}} />)
const textarea = screen.getByLabelText('Message')
expect(textarea.tagName).toBe('TEXTAREA')
})
it('displays error message when error prop is set', () => {
render(
<Input label="Name" name="name" value="" onChange={() => {}} error="Name is required" />
)
expect(screen.getByText('Name is required')).toBeInTheDocument()
})
it('calls onChange when value changes', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<Input label="Name" name="name" value="" onChange={handleChange} />)
await user.type(screen.getByLabelText('Name'), 'John')
expect(handleChange).toHaveBeenCalled()
})
it('applies error styles when error is present', () => {
render(
<Input label="Name" name="name" value="" onChange={() => {}} error="Name is required" />
)
const input = screen.getByLabelText('Name')
expect(input).toHaveClass('border-red-500')
})
it('applies normal styles when no error', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveClass('border-slate-300')
expect(input).not.toHaveClass('border-red-500')
})
it('renders placeholder correctly', () => {
render(
<Input
label="Name"
name="name"
value=""
onChange={() => {}}
placeholder="Enter your name"
/>
)
expect(screen.getByPlaceholderText('Enter your name')).toBeInTheDocument()
})
it('marks field as required when required prop is true', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} required />)
const input = screen.getByLabelText('Name')
expect(input).toBeRequired()
})
it('displays current value', () => {
render(<Input label="Name" name="name" value="John Doe" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveValue('John Doe')
})
})

View File

@ -0,0 +1,81 @@
import { cn } from '../../lib/utils'
interface InputProps {
/** Field label */
label: string
/** Field name */
name: string
/** Input type */
type?: 'text' | 'email' | 'textarea'
/** Placeholder text */
placeholder?: string
/** Whether field is required */
required?: boolean
/** Error message */
error?: string
/** Current value */
value: string
/** Change handler */
onChange: (value: string) => void
}
export function Input({
label,
name,
type = 'text',
placeholder,
required = false,
error,
value,
onChange,
}: InputProps) {
const inputId = `input-${name}`
const baseInputStyles = cn(
'w-full px-4 py-3 rounded-lg border transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary',
error ? 'border-red-500' : 'border-slate-300'
)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(e.target.value)
}
return (
<div className="space-y-1">
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700">
{label}
</label>
{type === 'textarea' ? (
<textarea
id={inputId}
name={name}
placeholder={placeholder}
required={required}
value={value}
onChange={handleChange}
rows={4}
className={baseInputStyles}
/>
) : (
<input
id={inputId}
name={name}
type={type}
placeholder={placeholder}
required={required}
value={value}
onChange={handleChange}
className={baseInputStyles}
/>
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
)
}

View File

@ -0,0 +1,69 @@
import { cn } from '../../lib/utils'
interface PlaceholderImageProps {
/** Image source path — renders <img> if provided and the file exists, otherwise a placeholder SVG */
src?: string
/** Alt text for the image */
alt?: string
/** Label displayed on the placeholder */
label?: string
/** Additional CSS classes */
className?: string
/** Aspect ratio for the placeholder (e.g. "16:10", "4:3", "1:1") */
aspectRatio?: string
}
const aspectRatioMap: Record<string, string> = {
'16:10': 'aspect-[16/10]',
'4:3': 'aspect-[4/3]',
'3:2': 'aspect-[3/2]',
'1:1': 'aspect-square',
}
export function PlaceholderImage({
src,
alt = '',
label = 'Image',
className,
aspectRatio = '4:3',
}: PlaceholderImageProps) {
if (src) {
return (
<img
src={src}
alt={alt}
className={cn('object-cover', aspectRatioMap[aspectRatio], className)}
loading="lazy"
/>
)
}
return (
<div
role="img"
aria-label={alt || label}
className={cn(
'bg-slate-100 border border-slate-200 rounded-xl flex items-center justify-center',
aspectRatioMap[aspectRatio],
className
)}
>
<div className="text-center px-4">
<svg
className="w-12 h-12 text-slate-300 mx-auto mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"
/>
</svg>
<span className="text-sm font-medium text-slate-400">{label}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,2 @@
export { Button } from './Button'
export { Card } from './Card'

77
src/data/services.ts Normal file
View File

@ -0,0 +1,77 @@
export interface BentoCardService {
id: string
icon: string
title: string
summary: string
details: string
technologies: string[]
size?: 'lg' | 'md' | 'sm'
gridPosition?: string
image?: string
}
export const services: BentoCardService[] = [
{
id: 'web-apps',
icon: 'Globe',
title: 'Web Applications',
summary: 'Full-stack solutions that scale',
details: 'Modern web applications built with React, Node.js, and cloud infrastructure. From MVPs to enterprise platforms.',
technologies: ['React', 'Node.js', 'PostgreSQL', 'AWS'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
image: '/images/service-web-apps.webp',
},
{
id: 'mobile',
icon: 'Smartphone',
title: 'Mobile Development',
summary: 'Native and cross-platform apps',
details: 'Beautiful, performant mobile applications for iOS and Android using React Native and native technologies.',
technologies: ['React Native', 'Swift', 'Kotlin'],
size: 'md',
gridPosition: 'col-span-2 row-span-1',
image: '/images/service-mobile.webp',
},
{
id: 'devops',
icon: 'Cloud',
title: 'DevOps & Infrastructure',
summary: 'Reliable, scalable systems',
details: 'CI/CD pipelines, containerization, and cloud deployment for high-availability systems.',
technologies: ['Docker', 'AWS', 'Terraform', 'Kubernetes'],
size: 'lg',
gridPosition: 'col-span-2 row-span-2',
image: '/images/service-devops.webp',
},
{
id: 'integration',
icon: 'Plug',
title: 'System Integration',
summary: 'Connect your systems',
details: 'API integrations, data synchronization, and workflow automation.',
technologies: ['REST', 'GraphQL', 'Webhooks'],
size: 'sm',
gridPosition: 'col-span-1 row-span-1',
},
{
id: 'security',
icon: 'Shield',
title: 'Security Solutions',
summary: 'Protect your assets',
details: 'Authentication, authorization, security audits, and compliance.',
technologies: ['OAuth', 'JWT', 'OWASP'],
size: 'sm',
gridPosition: 'col-span-1 row-span-1',
},
{
id: 'consulting',
icon: 'Lightbulb',
title: 'Technical Consulting',
summary: 'Strategic technology guidance',
details: 'Architecture reviews, technology strategy, and team mentoring for your projects.',
technologies: ['Architecture', 'Strategy', 'Mentoring'],
size: 'md',
gridPosition: 'col-span-2 row-span-1',
},
]

48
src/data/workflow.ts Normal file
View File

@ -0,0 +1,48 @@
export interface WorkflowStepData {
id: number
icon: string
title: string
tagline: string
description: string
deliverables: string[]
image?: string
}
export const workflowSteps: WorkflowStepData[] = [
{
id: 1,
icon: 'Search',
title: 'Discovery',
tagline: 'Understand your goals',
description: 'We dive deep into your business requirements, user needs, and technical constraints.',
deliverables: ['Requirements document', 'Technical roadmap', 'Project timeline'],
image: '/images/workflow-discovery.webp',
},
{
id: 2,
icon: 'Palette',
title: 'Design',
tagline: 'Blueprint for success',
description: 'Architecture planning and UI/UX design that prioritizes user experience.',
deliverables: ['System architecture', 'UI mockups', 'Database schema'],
image: '/images/workflow-design.webp',
},
{
id: 3,
icon: 'Code',
title: 'Build',
tagline: 'Code with precision',
description: 'Agile development with regular demos and continuous integration.',
deliverables: ['Working software', 'Test coverage', 'Documentation'],
image: '/images/workflow-build.webp',
},
{
id: 4,
icon: 'Rocket',
title: 'Deploy',
tagline: 'Ship with confidence',
description: 'Production deployment with monitoring, CI/CD, and ongoing support.',
deliverables: ['Production environment', 'Monitoring setup', 'Support plan'],
image: '/images/workflow-deploy.webp',
},
]

7
src/hooks/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { useIntersectionObserver } from './useIntersectionObserver'
export type {
UseIntersectionObserverOptions,
UseIntersectionObserverReturn,
} from './useIntersectionObserver'
export { useAnimateOnScroll } from './useAnimateOnScroll'

View File

@ -0,0 +1,101 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useAnimateOnScroll } from './useAnimateOnScroll'
// Mock IntersectionObserver
let mockObserverInstances: MockIntersectionObserver[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
options: IntersectionObserverInit
constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) {
this.callback = callback
this.options = options
mockObserverInstances.push(this)
}
observe() {}
unobserve() {}
disconnect() {}
simulateIntersection(isIntersecting: boolean) {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
}
describe('useAnimateOnScroll', () => {
beforeEach(() => {
mockObserverInstances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns ref and isVisible', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.ref).toBeDefined()
expect(result.current.isVisible).toBeDefined()
})
it('returns isVisible as false initially', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.isVisible).toBe(false)
})
it('returns isVisible as true when element enters viewport', () => {
const element = document.createElement('div')
const { result } = renderHook(() => {
const hook = useAnimateOnScroll()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result.current.isVisible).toBe(true)
}
})
it('returns animationProps with variants and initial/animate states', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.animationProps).toBeDefined()
expect(result.current.animationProps.initial).toBe('hidden')
expect(result.current.animationProps.variants).toBeDefined()
})
it('sets animate to visible when in viewport', () => {
const element = document.createElement('div')
const { result } = renderHook(() => {
const hook = useAnimateOnScroll()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result.current.animationProps.animate).toBe('visible')
}
})
it('uses provided animation variant', () => {
const { result } = renderHook(() => useAnimateOnScroll({ animation: 'slide-left' }))
expect(result.current.animationProps.variants).toBeDefined()
})
it('applies custom delay via transition', () => {
const { result } = renderHook(() => useAnimateOnScroll({ delay: 200 }))
expect(result.current.animationProps.transition?.delay).toBe(0.2)
})
})

View File

@ -0,0 +1,45 @@
import { useIntersectionObserver } from './useIntersectionObserver'
import { animationVariants, type AnimationVariant } from '../utils/animations'
interface UseAnimateOnScrollOptions {
/** Animation variant to use */
animation?: AnimationVariant
/** Animation delay in ms */
delay?: number
/** Only animate once (default: true) */
once?: boolean
}
interface UseAnimateOnScrollReturn<T extends HTMLElement> {
ref: React.RefObject<T | null>
isVisible: boolean
animationProps: {
initial: 'hidden'
animate: 'hidden' | 'visible'
variants: typeof animationVariants[AnimationVariant]
transition?: { delay: number }
}
}
export function useAnimateOnScroll<T extends HTMLElement = HTMLDivElement>(
options: UseAnimateOnScrollOptions = {}
): UseAnimateOnScrollReturn<T> {
const { animation = 'fade-up', delay = 0, once = true } = options
const { ref, isVisible } = useIntersectionObserver<T>({
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
triggerOnce: once,
})
return {
ref,
isVisible,
animationProps: {
initial: 'hidden',
animate: isVisible ? 'visible' : 'hidden',
variants: animationVariants[animation],
...(delay > 0 && { transition: { delay: delay / 1000 } }),
},
}
}

View File

@ -0,0 +1,141 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useIntersectionObserver } from './useIntersectionObserver'
// Mock IntersectionObserver
let mockObserverInstances: MockIntersectionObserver[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
options: IntersectionObserverInit
observedElements: Element[] = []
constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) {
this.callback = callback
this.options = options
mockObserverInstances.push(this)
}
observe(element: Element) {
this.observedElements.push(element)
}
unobserve(element: Element) {
this.observedElements = this.observedElements.filter((el) => el !== element)
}
disconnect() {
this.observedElements = []
}
// Helper to simulate intersection
simulateIntersection(isIntersecting: boolean) {
this.callback(
[{ isIntersecting, target: this.observedElements[0] } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
}
describe('useIntersectionObserver', () => {
beforeEach(() => {
mockObserverInstances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns a ref object', () => {
const { result } = renderHook(() => useIntersectionObserver())
expect(result.current.ref).toBeDefined()
expect(result.current.ref.current).toBe(null)
})
it('returns isVisible as false initially', () => {
const { result } = renderHook(() => useIntersectionObserver())
expect(result.current.isVisible).toBe(false)
})
it('sets isVisible to true when element enters viewport', () => {
const { result } = renderHook(() => useIntersectionObserver())
// Simulate element being attached
const element = document.createElement('div')
Object.defineProperty(result.current.ref, 'current', {
value: element,
writable: true,
})
// Re-render to trigger effect with the element
const { result: result2 } = renderHook(() => useIntersectionObserver())
Object.defineProperty(result2.current.ref, 'current', {
value: element,
writable: true,
})
// Manually trigger the effect by checking for observer
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result2.current.isVisible).toBe(true)
}
})
it('uses custom threshold option', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver({ threshold: 0.5 })
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
// The observer should be created with threshold 0.5
const observer = mockObserverInstances.find((o) => o.options.threshold === 0.5)
expect(observer).toBeDefined()
})
it('uses custom rootMargin option', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver({ rootMargin: '100px' })
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances.find((o) => o.options.rootMargin === '100px')
expect(observer).toBeDefined()
})
it('uses default options when not specified', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances[mockObserverInstances.length - 1]
expect(observer.options.threshold).toBe(0.1)
expect(observer.options.rootMargin).toBe('0px 0px -50px 0px')
})
it('disconnects observer on unmount', () => {
const element = document.createElement('div')
const { unmount } = renderHook(() => {
const hook = useIntersectionObserver()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances[mockObserverInstances.length - 1]
const disconnectSpy = vi.spyOn(observer, 'disconnect')
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react'
interface UseIntersectionObserverOptions {
/** Percentage of element visible to trigger (0-1) */
threshold?: number
/** Margin around root */
rootMargin?: string
/** Only trigger once */
triggerOnce?: boolean
}
interface UseIntersectionObserverReturn<T extends HTMLElement> {
/** Ref to attach to the element */
ref: React.RefObject<T | null>
/** Whether the element is currently visible */
isVisible: boolean
}
export function useIntersectionObserver<T extends HTMLElement = HTMLDivElement>(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn<T> {
const {
threshold = 0.1,
rootMargin = '0px 0px -50px 0px',
triggerOnce = true,
} = options
const ref = useRef<T>(null)
const [isVisible, setIsVisible] = useState(false)
const hasTriggered = useRef(false)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
if (triggerOnce) {
hasTriggered.current = true
observer.disconnect()
}
} else if (!triggerOnce && !hasTriggered.current) {
setIsVisible(false)
}
},
{ threshold, rootMargin }
)
observer.observe(element)
return () => observer.disconnect()
}, [threshold, rootMargin, triggerOnce])
return { ref, isVisible }
}
export type { UseIntersectionObserverOptions, UseIntersectionObserverReturn }

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

97
src/styles/index.css Normal file
View File

@ -0,0 +1,97 @@
@import "tailwindcss";
/* Design Tokens - Tailwind v4 CSS-first configuration */
@theme {
/* Colors */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #3b82f6;
--color-accent: #f59e0b;
--color-secondary: #64748b;
--color-text-primary: #1e293b;
--color-text-secondary: #64748b;
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8fafc;
--color-border: #e2e8f0;
/* Font family */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
/* Custom animations */
--animate-fade-in-up: fade-in-up 0.6s ease-out;
--animate-slide-in: slide-in 0.5s ease-out;
--animate-count-up: count-up 2s ease-out;
/* Keyframes */
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(30px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes count-up {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
/* Base styles */
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply antialiased text-text-primary;
}
}
/* Component styles */
@layer components {
.gradient-text {
@apply bg-gradient-to-r from-slate-900 to-primary bg-clip-text text-transparent;
}
.glassmorphism {
@apply bg-white/95 backdrop-blur-md;
}
}
/* Utility animations */
@layer utilities {
/* Subtle pulse for stats */
.animate-stat-pulse {
animation: stat-pulse 2s ease-in-out 1;
}
@keyframes stat-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
/* Logo hover rotation */
.hover-rotate {
transition: transform 0.3s ease;
}
.hover-rotate:hover {
transform: rotate(12deg) scale(1.1);
}
}

1
src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

41
src/types/index.ts Normal file
View File

@ -0,0 +1,41 @@
// Navigation types
export interface NavLink {
label: string
href: string
}
// Service types
export interface Service {
id: string
title: string
description: string
icon: string
features: string[]
}
// Workflow types
export interface WorkflowStep {
id: number
title: string
description: string
icon: string
}
// Stats types
export interface Stat {
value: number
label: string
suffix?: string
}
// Contact types
export interface ContactMethod {
icon: string
title: string
value: string
href?: string
}
// Component prop types
export type Size = 'sm' | 'md' | 'lg'
export type Variant = 'primary' | 'secondary' | 'ghost'

100
src/utils/animations.ts Normal file
View File

@ -0,0 +1,100 @@
import type { Variants } from 'framer-motion'
export const animationVariants: Record<string, Variants> = {
'fade-up': {
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
},
'fade-in': {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
'slide-left': {
hidden: { opacity: 0, x: -40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
'slide-right': {
hidden: { opacity: 0, x: 40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
}
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
}
export type AnimationVariant = keyof typeof animationVariants
// Shared transition presets
export const springTransition = {
type: 'spring' as const,
stiffness: 300,
damping: 20,
}
export const easeTransition = {
duration: 0.3,
ease: 'easeOut' as const,
}
// Common hover effects
export const hoverLift = {
y: -5,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
}
export const hoverScale = {
scale: 1.05,
}
// Icon animation variants
export const iconVariants = {
rest: { scale: 1, rotate: 0 },
hover: {
scale: 1.1,
rotate: [0, -5, 5, 0],
transition: { duration: 0.3 },
},
}
// Badge hover animation
export const badgeVariants = {
rest: { y: 0 },
hover: {
y: -3,
transition: springTransition,
},
}
// Card hover animation
export const cardHoverVariants = {
rest: {
y: 0,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
},
hover: {
y: -5,
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
transition: easeTransition,
},
}

34
tsconfig.app.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client", "vitest/globals"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

18
vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})
)