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>
16
.dockerignore
Normal 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/
|
||||
@ -1,82 +1,104 @@
|
||||
name: Deploy Website
|
||||
name: Build and Deploy Website
|
||||
run-name: Deploying website changes by ${{ github.actor }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '*.html'
|
||||
- 'assets/**'
|
||||
- 'css/**'
|
||||
- 'js/**'
|
||||
- 'images/**'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'public/**'
|
||||
- 'package*.json'
|
||||
- 'vite.config.ts'
|
||||
- 'tailwind.config.ts'
|
||||
- 'tsconfig*.json'
|
||||
- 'index.html'
|
||||
- 'Dockerfile'
|
||||
- 'nginx.conf'
|
||||
- 'docker-compose.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate HTML
|
||||
run: |
|
||||
apt-get update && apt-get install -y tidy
|
||||
find . -name "*.html" -exec tidy -q -e {} \;
|
||||
echo "✅ HTML validation passed"
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
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
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
|
||||
echo "Host *" > ~/.ssh/config
|
||||
echo " StrictHostKeyChecking no" >> ~/.ssh/config
|
||||
echo " UserKnownHostsFile /dev/null" >> ~/.ssh/config
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
- name: Deploy website
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
# Install rsync
|
||||
echo "📦 Installing rsync..."
|
||||
apt-get update && apt-get install -y rsync
|
||||
|
||||
|
||||
SERVER_IP="192.168.4.56"
|
||||
DEPLOY_USER="deploy"
|
||||
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 \
|
||||
--no-times \
|
||||
--no-perms \
|
||||
--exclude='.git' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.gitea' \
|
||||
--exclude='README.md' \
|
||||
--exclude='docker-compose.yml' \
|
||||
--exclude='plan' \
|
||||
--exclude='.claude' \
|
||||
-e "ssh -i ~/.ssh/deploy_key" \
|
||||
./ $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 \
|
||||
"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
|
||||
run: |
|
||||
echo "🏥 Waiting for container to restart..."
|
||||
sleep 15
|
||||
|
||||
echo "Waiting for container to be healthy..."
|
||||
sleep 20
|
||||
|
||||
for i in {1..5}; do
|
||||
if curl -f --connect-timeout 10 https://maverickapplications.com; then
|
||||
echo "✅ Website is responding correctly!"
|
||||
echo "Website is responding correctly!"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ Attempt $i failed, retrying in 10 seconds..."
|
||||
echo "Attempt $i failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ Website health check failed"
|
||||
|
||||
echo "Website health check failed"
|
||||
exit 1
|
||||
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
4
.gitignore
vendored
@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Original site backup
|
||||
index.html.original
|
||||
index.html.backup*
|
||||
|
||||
39
Dockerfile
Normal 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://localhost/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@ -1,18 +1,25 @@
|
||||
services:
|
||||
maverick-website:
|
||||
image: nginx:alpine
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: maverick-website:latest
|
||||
container_name: maverick-website
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- traefik
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.maverick-website.rule=Host(`maverickapplications.com`)"
|
||||
- "traefik.http.routers.maverick-website.tls=true"
|
||||
- "traefik.http.routers.maverick-website.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.maverick-website.entrypoints=web"
|
||||
- "traefik.http.routers.maverick-website.middlewares=cloudflare-headers@file"
|
||||
- "traefik.http.services.maverick-website.loadbalancer.server.port=80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
traefik:
|
||||
|
||||
@ -1,756 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maverick Applications - Custom Software Solutions</title>
|
||||
<meta name="description" content="Professional software development and custom application solutions. Built with precision, delivered with excellence.">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--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>© 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>
|
||||
<!-- Last updated: $(date) -->
|
||||
60
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
28
package-lock.json
generated
@ -13,7 +13,8 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod-validation-error": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@ -28,7 +29,7 @@
|
||||
"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-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
@ -3251,6 +3252,19 @@
|
||||
"eslint": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
|
||||
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
|
||||
@ -6556,23 +6570,21 @@
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
|
||||
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
|
||||
"dev": true,
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
@ -18,7 +19,8 @@
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod-validation-error": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@ -33,7 +35,7 @@
|
||||
"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-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
|
||||
BIN
public/images/contact-collab.webp
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
public/images/hero-dashboard.webp
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
BIN
public/images/service-devops.webp
Normal file
|
After Width: | Height: | Size: 6.6 MiB |
BIN
public/images/service-mobile.webp
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
public/images/service-web-apps.webp
Normal file
|
After Width: | Height: | Size: 5.7 MiB |
BIN
public/images/workflow-build.webp
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
BIN
public/images/workflow-deploy.webp
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
BIN
public/images/workflow-design.webp
Normal file
|
After Width: | Height: | Size: 6.0 MiB |
BIN
public/images/workflow-discovery.webp
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
@ -36,8 +36,6 @@ export function BentoCard({
|
||||
'data-testid': testId,
|
||||
}: BentoCardProps) {
|
||||
const Icon = iconMap[service.icon] || Globe
|
||||
const showDetails = size === 'lg' || size === 'md'
|
||||
const showTechnologies = size === 'lg'
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -149,7 +149,7 @@ describe('ExpandableServiceCard', () => {
|
||||
it('works without technologies prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const propsWithoutTech = { ...defaultProps }
|
||||
delete (propsWithoutTech as any).technologies
|
||||
delete (propsWithoutTech as Record<string, unknown>).technologies
|
||||
|
||||
render(<ExpandableServiceCard {...propsWithoutTech} />)
|
||||
|
||||
|
||||
@ -120,7 +120,7 @@ export function Contact({ onSubmit }: ContactProps) {
|
||||
className="bg-slate-50 py-20 md:py-28"
|
||||
>
|
||||
<Container>
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div className="grid gap-8 lg:grid-cols-2 lg:gap-16">
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
|
||||
@ -86,7 +86,7 @@ export function WorkflowCarousel({
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 1 }}
|
||||
transition={{ duration: 1.0, ease: 'easeInOut' }}
|
||||
className="bg-white rounded-2xl p-8 border border-slate-200"
|
||||
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">
|
||||
@ -133,10 +133,10 @@ export function WorkflowCarousel({
|
||||
aria-label={`Go to step ${index + 1}`}
|
||||
aria-current={activeStep === index ? 'step' : undefined}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
'rounded-full transition-all duration-300 p-2',
|
||||
activeStep === index
|
||||
? 'w-4 h-4 bg-primary'
|
||||
: 'w-3 h-3 bg-slate-600 hover:bg-slate-500'
|
||||
? 'w-5 h-5 bg-primary'
|
||||
: 'w-4 h-4 bg-slate-600 hover:bg-slate-500'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, act } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { AnimatedSection } from './AnimatedSection'
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Children, cloneElement, isValidElement } from 'react'
|
||||
import { Children, isValidElement } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useAnimateOnScroll } from '../../hooks/useAnimateOnScroll'
|
||||
import { animationVariants, staggerContainer, type AnimationVariant } from '../../utils/animations'
|
||||
import { animationVariants, type AnimationVariant } from '../../utils/animations'
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
children: React.ReactNode
|
||||
|
||||
@ -22,12 +22,13 @@ describe('Card', () => {
|
||||
);
|
||||
|
||||
const card = screen.getByTestId('card');
|
||||
// Base styles: white bg, border, rounded-2xl, p-8, shadow-md
|
||||
// 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-8');
|
||||
expect(card).toHaveClass('p-4');
|
||||
expect(card).toHaveClass('md:p-8');
|
||||
expect(card).toHaveClass('shadow-md');
|
||||
});
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ export function Card({
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: CardProps) {
|
||||
const baseStyles = 'bg-white border border-border rounded-2xl p-8 shadow-md transition-all duration-300 ease-in-out'
|
||||
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' : ''
|
||||
|
||||
|
||||