Phase 4–6: Next.js frontend, production deploy, CI/CD
Some checks are pending
CI / Backend — lint + test (push) Waiting to run
CI / Frontend — lint + typecheck (push) Waiting to run
CI / Build + push Docker images (push) Blocked by required conditions

Frontend (Next.js 15 + shadcn/ui + Tailwind, Supabase Auth):
- Landing page: hero, feature grid, social proof (EU Accessibility Act), CTA
- Pricing page: Free / Pro $29 / Business $149 with highlighted Pro tier
- Auth: magic link login + signup (Supabase OTP, no password)
- App layout: sidebar nav (Dashboard, History, Billing, Team)
- Dashboard: drag-and-drop PDF upload with quota error handling
- Jobs history: table with score badges and status indicators
- Billing: Stripe Checkout + Customer Portal integration
- Supabase SSR client/server helpers

Deploy:
- docker-compose.prod.yml: postgres, redis, minio, api, celery, nextjs, caddy
- Caddyfile: auto-SSL for pdfaccess.ai-impress.com
- Watchtower excluded (locally-built images)

CI/CD (Forgejo Actions):
- backend-lint-test: pytest with real postgres + redis
- frontend-lint: tsc typecheck
- build-and-push: docker build → push to registry.ai-impress.com
- SSH deploy to homelab on push to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-19 14:51:14 +01:00
parent fc6f4a12e6
commit 5cbbcc6e5e
19 changed files with 981 additions and 18 deletions

110
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,110 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
backend-lint-test:
name: Backend — lint + test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: pdf_accessibility_test
POSTGRES_USER: pdf_accessibility
POSTGRES_PASSWORD: testpassword
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Install uv
run: pip install uv
- name: Install dependencies
working-directory: backend
run: uv sync
- name: Run tests
working-directory: backend
env:
DB_HOST: localhost
DB_NAME: pdf_accessibility_test
DB_USER: pdf_accessibility
DB_PASSWORD: testpassword
REDIS_URL: redis://localhost:6379/0
STORAGE_ENDPOINT: http://localhost:9000
ANTHROPIC_API_KEY: test-key
SUPABASE_JWT_SECRET: test-secret
ENVIRONMENT: development
run: uv run pytest tests/ -v --tb=short
frontend-lint:
name: Frontend — lint + typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install deps
working-directory: frontend
run: npm ci
- name: Typecheck
working-directory: frontend
run: npx tsc --noEmit
build-and-push:
name: Build + push Docker images
runs-on: ubuntu-latest
needs: [backend-lint-test, frontend-lint]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with:
registry: registry.ai-impress.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build + push API
uses: docker/build-push-action@v6
with:
context: .
file: backend/Dockerfile
push: true
tags: registry.ai-impress.com/pdf-accessibility/api:latest
- name: Build + push Frontend
uses: docker/build-push-action@v6
with:
context: frontend
file: frontend/Dockerfile
push: true
tags: registry.ai-impress.com/pdf-accessibility/frontend:latest
- name: Deploy to homelab
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/pdf-accessibility
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker system prune -f

14
Caddyfile Normal file
View file

@ -0,0 +1,14 @@
pdfaccess.ai-impress.com {
# Next.js app
handle /api/* {
reverse_proxy api:8000
}
handle {
reverse_proxy nextjs:3000
}
encode gzip
log {
output stderr
format json
}
}

View file

@ -1,26 +1,99 @@
# Production Docker Compose — PostgreSQL only
# Apache/Nginx on host serves PHP + frontend files natively
# PDF processing handled by Cloud Run (no local worker)
# PostgreSQL on 1221 to avoid host conflicts
services:
postgres:
image: postgres:16-alpine
ports:
- "127.0.0.1:1221:5432"
volumes:
- pg-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
restart: always
environment:
POSTGRES_DB: ${DB_NAME:-pdf_checker}
POSTGRES_USER: ${DB_USER:-pdf_checker}
POSTGRES_PASSWORD: ${DB_PASSWORD:-dev_password}
POSTGRES_DB: ${DB_NAME:-pdf_accessibility}
POSTGRES_USER: ${DB_USER:-pdf_accessibility}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-pdf_checker}"]
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-pdf_accessibility}"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
timeout: 5s
retries: 5
labels:
- "com.centurylinklabs.watchtower.enable=false"
redis:
image: redis:7-alpine
restart: always
command: redis-server --appendonly yes
volumes:
- redis_data:/data
labels:
- "com.centurylinklabs.watchtower.enable=false"
minio:
image: minio/minio:latest
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY}
volumes:
- minio_data:/data
labels:
- "com.centurylinklabs.watchtower.enable=false"
api:
image: registry.ai-impress.com/pdf-accessibility/api:latest
restart: always
env_file: .env
environment:
- DB_HOST=postgres
- REDIS_URL=redis://redis:6379/0
- STORAGE_ENDPOINT=http://minio:9000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
labels:
- "com.centurylinklabs.watchtower.enable=false"
celery:
image: registry.ai-impress.com/pdf-accessibility/api:latest
command: uv run celery -A app.services.queue.celery_app worker --loglevel=info -c 2
restart: always
env_file: .env
environment:
- DB_HOST=postgres
- REDIS_URL=redis://redis:6379/0
- STORAGE_ENDPOINT=http://minio:9000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
labels:
- "com.centurylinklabs.watchtower.enable=false"
nextjs:
image: registry.ai-impress.com/pdf-accessibility/frontend:latest
restart: always
env_file: frontend/.env.local
labels:
- "com.centurylinklabs.watchtower.enable=false"
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- api
- nextjs
volumes:
pg-data:
postgres_data:
redis_data:
minio_data:
caddy_data:
caddy_config:

View file

@ -0,0 +1,5 @@
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_STRIPE_PRICE_PRO=price_YOUR_PRO_PRICE_ID
NEXT_PUBLIC_STRIPE_PRICE_BUSINESS=price_YOUR_BUSINESS_PRICE_ID

15
frontend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View file

@ -0,0 +1,106 @@
"use client";
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { useRouter } from "next/navigation";
export default function DashboardPage() {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
if (file.type !== "application/pdf") {
setError("Only PDF files are accepted");
return;
}
setUploading(true);
setError(null);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/v1/jobs", {
method: "POST",
body: formData,
credentials: "include",
});
if (res.status === 402) {
const data = await res.json();
setError(data.detail + " — upgrade your plan to continue.");
return;
}
if (!res.ok) {
const data = await res.json();
setError(data.detail || "Upload failed");
return;
}
const { id } = await res.json();
router.push(`/jobs/${id}`);
} catch {
setError("Network error — please try again");
} finally {
setUploading(false);
}
}, [router]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "application/pdf": [".pdf"] },
multiple: false,
disabled: uploading,
});
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check PDF Accessibility</h1>
<p className="text-gray-500 text-sm mb-8">Upload a PDF to run 30+ WCAG 2.1 AA checks</p>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-2xl p-16 text-center cursor-pointer transition-colors ${
isDragActive ? "border-brand-500 bg-brand-50" : "border-gray-300 hover:border-brand-400 bg-white"
} ${uploading ? "opacity-50 cursor-not-allowed" : ""}`}
>
<input {...getInputProps()} />
{uploading ? (
<div>
<div className="text-4xl mb-3 animate-pulse"></div>
<p className="text-gray-500 font-medium">Uploading and queuing check...</p>
</div>
) : isDragActive ? (
<div>
<div className="text-4xl mb-3">📄</div>
<p className="text-brand-600 font-medium">Drop your PDF here</p>
</div>
) : (
<div>
<div className="text-4xl mb-3">📤</div>
<p className="text-gray-700 font-medium text-lg">Drop your PDF here, or click to browse</p>
<p className="text-gray-400 text-sm mt-2">PDF files only · Max 50 MB</p>
</div>
)}
</div>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-sm text-red-700">
{error}
{error.includes("upgrade") && (
<span className="ml-2">
<a href="/settings/billing" className="font-medium underline">Upgrade now </a>
</span>
)}
</div>
)}
<div className="mt-8 bg-brand-50 rounded-xl p-4 text-sm text-brand-700">
<strong>What gets checked:</strong> Alt text, color contrast, reading order, headings, tables, forms, language, bookmarks, PDF/UA-1 (Matterhorn Protocol), and more.
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface Job {
id: string;
filename: string;
status: string;
accessibility_score: number | null;
created_at: string;
}
function ScoreBadge({ score }: { score: number | null }) {
if (score === null) return <span className="text-gray-400 text-sm"></span>;
const color = score >= 80 ? "bg-green-100 text-green-800" : score >= 60 ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-800";
return <span className={`px-2 py-0.5 rounded-full text-xs font-bold ${color}`}>{score}</span>;
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
completed: "bg-green-100 text-green-700",
processing: "bg-blue-100 text-blue-700",
pending: "bg-gray-100 text-gray-700",
failed: "bg-red-100 text-red-700",
};
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status] || "bg-gray-100 text-gray-700"}`}>
{status}
</span>
);
}
export default function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/v1/jobs", { credentials: "include" })
.then((r) => r.json())
.then((d) => { setJobs(d.jobs || []); setLoading(false); })
.catch(() => setLoading(false));
}, []);
if (loading) return <div className="text-gray-400 text-sm">Loading history...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">History</h1>
<Link href="/dashboard" className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors">
+ New check
</Link>
</div>
{jobs.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<div className="text-4xl mb-3">📄</div>
<p>No PDFs checked yet upload your first one!</p>
<Link href="/dashboard" className="mt-4 inline-block text-brand-500 hover:underline text-sm">Upload PDF </Link>
</div>
) : (
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead>
<tr className="text-xs text-gray-500 font-medium border-b border-gray-100 bg-gray-50">
<th className="text-left px-4 py-3">Filename</th>
<th className="text-left px-4 py-3">Status</th>
<th className="text-left px-4 py-3">Score</th>
<th className="text-left px-4 py-3">Date</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 text-sm text-gray-900 font-medium max-w-xs truncate">{job.filename}</td>
<td className="px-4 py-3"><StatusBadge status={job.status} /></td>
<td className="px-4 py-3"><ScoreBadge score={job.accessibility_score} /></td>
<td className="px-4 py-3 text-xs text-gray-400">{new Date(job.created_at).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right">
<Link href={`/jobs/${job.id}`} className="text-brand-500 text-sm hover:underline">View report </Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col py-6 px-4 fixed h-full">
<Link href="/dashboard" className="text-lg font-bold text-brand-600 mb-8 px-2">Aimpress PDF</Link>
<nav className="flex-1 space-y-1">
{[
{ href: "/dashboard", label: "Dashboard", icon: "⬆️" },
{ href: "/jobs", label: "History", icon: "📋" },
{ href: "/settings/billing", label: "Billing", icon: "💳" },
{ href: "/settings/team", label: "Team", icon: "👥" },
].map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 px-3 py-2 text-sm text-gray-600 rounded-lg hover:bg-gray-100 hover:text-gray-900 transition-colors"
>
<span>{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="border-t border-gray-100 pt-4 mt-4">
<p className="text-xs text-gray-400 px-3 truncate">{user.email}</p>
</div>
</aside>
{/* Main content */}
<main className="ml-56 flex-1 p-8">{children}</main>
</div>
);
}

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
interface Subscription {
plan_tier: string;
monthly_quota: number;
}
const PLAN_LABELS: Record<string, string> = {
free: "Free",
pro: "Pro",
business: "Business",
};
export default function BillingPage() {
const [sub, setSub] = useState<Subscription | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/v1/billing/subscription", { credentials: "include" })
.then((r) => r.json())
.then((d) => { setSub(d); setLoading(false); })
.catch(() => setLoading(false));
}, []);
async function handleUpgrade(plan: "pro" | "business") {
const priceEnv = plan === "pro"
? process.env.NEXT_PUBLIC_STRIPE_PRICE_PRO
: process.env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS;
const res = await fetch(`/api/v1/billing/checkout?price_id=${priceEnv}`, {
method: "POST",
credentials: "include",
});
const { checkout_url } = await res.json();
window.location.href = checkout_url;
}
async function handleManage() {
const res = await fetch("/api/v1/billing/portal", { method: "POST", credentials: "include" });
const { portal_url } = await res.json();
window.location.href = portal_url;
}
if (loading) return <div className="text-gray-400 text-sm">Loading billing...</div>;
return (
<div className="max-w-lg">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Billing</h1>
<div className="bg-white rounded-2xl border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-gray-500">Current plan</p>
<p className="text-xl font-bold text-gray-900">{PLAN_LABELS[sub?.plan_tier || "free"]}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
sub?.plan_tier === "free" ? "bg-gray-100 text-gray-600" : "bg-brand-100 text-brand-700"
}`}>
{sub?.monthly_quota === 999999 ? "Unlimited" : `${sub?.monthly_quota} PDFs/month`}
</span>
</div>
{sub?.plan_tier !== "free" && (
<button onClick={handleManage} className="text-sm text-brand-600 hover:underline">
Manage subscription
</button>
)}
</div>
{sub?.plan_tier === "free" && (
<div className="grid grid-cols-2 gap-4">
{(["pro", "business"] as const).map((plan) => (
<button
key={plan}
onClick={() => handleUpgrade(plan)}
className="bg-brand-500 text-white rounded-xl py-3 px-4 font-semibold hover:bg-brand-600 transition-colors"
>
Upgrade to {PLAN_LABELS[plan]}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleMagicLink(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const supabase = createClient();
await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${window.location.origin}/dashboard` },
});
setSent(true);
setLoading(false);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-brand-600">Aimpress PDF</Link>
<h1 className="text-xl font-semibold text-gray-900 mt-4">Sign in to your account</h1>
<p className="text-gray-500 text-sm mt-1">We'll send you a magic link no password needed</p>
</div>
{sent ? (
<div className="bg-green-50 border border-green-200 rounded-xl p-4 text-center">
<div className="text-2xl mb-2"></div>
<p className="text-green-800 font-medium">Check your email</p>
<p className="text-green-700 text-sm mt-1">We sent a magic link to <strong>{email}</strong></p>
</div>
) : (
<form onSubmit={handleMagicLink} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-brand-500 text-white py-3 rounded-xl font-semibold hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{loading ? "Sending..." : "Send magic link"}
</button>
</form>
)}
<p className="text-center text-sm text-gray-500 mt-6">
No account?{" "}
<Link href="/signup" className="text-brand-600 font-medium hover:underline">Sign up free</Link>
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import Link from "next/link";
export default function SignupPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSignup(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
const supabase = createClient();
await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${window.location.origin}/dashboard`, shouldCreateUser: true },
});
setSent(true);
setLoading(false);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="text-2xl font-bold text-brand-600">Aimpress PDF</Link>
<h1 className="text-xl font-semibold text-gray-900 mt-4">Create your free account</h1>
<p className="text-gray-500 text-sm mt-1">5 PDFs free · No credit card required</p>
</div>
{sent ? (
<div className="bg-green-50 border border-green-200 rounded-xl p-4 text-center">
<div className="text-2xl mb-2"></div>
<p className="text-green-800 font-medium">Almost there!</p>
<p className="text-green-700 text-sm mt-1">We sent a confirmation link to <strong>{email}</strong></p>
</div>
) : (
<form onSubmit={handleSignup} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Work email</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-brand-500 text-white py-3 rounded-xl font-semibold hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{loading ? "Creating account..." : "Create free account"}
</button>
</form>
)}
<p className="text-center text-sm text-gray-500 mt-6">
Already have an account?{" "}
<Link href="/login" className="text-brand-600 font-medium hover:underline">Sign in</Link>
</p>
<p className="text-center text-xs text-gray-400 mt-3">
By signing up you agree to our Terms of Service and Privacy Policy.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import Link from "next/link";
export default function LandingPage() {
return (
<main className="min-h-screen bg-white">
{/* Nav */}
<nav className="border-b border-gray-100 px-6 py-4 flex items-center justify-between max-w-6xl mx-auto">
<span className="font-bold text-xl text-brand-600">Aimpress PDF</span>
<div className="flex items-center gap-4">
<Link href="/pricing" className="text-sm text-gray-600 hover:text-gray-900">Pricing</Link>
<Link href="/login" className="text-sm text-gray-600 hover:text-gray-900">Log in</Link>
<Link href="/signup" className="text-sm bg-brand-500 text-white px-4 py-2 rounded-lg hover:bg-brand-600 transition-colors">
Get started free
</Link>
</div>
</nav>
{/* Hero */}
<section className="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
<div className="inline-flex items-center gap-2 bg-brand-50 text-brand-700 text-sm font-medium px-3 py-1 rounded-full mb-6">
EU Accessibility Act June 2025
</div>
<h1 className="text-5xl font-extrabold text-gray-900 leading-tight mb-6">
WCAG-compliant PDFs<br />
<span className="text-brand-500">in 60 seconds</span>
</h1>
<p className="text-xl text-gray-500 mb-10 max-w-2xl mx-auto">
AI-powered PDF accessibility checker. 30+ WCAG 2.1 AA checks, auto-remediation, and detailed reports.
No desktop software upload and get results instantly.
</p>
<div className="flex items-center justify-center gap-4">
<Link href="/signup" className="bg-brand-500 text-white px-8 py-3 rounded-xl font-semibold hover:bg-brand-600 transition-colors text-lg">
Check your PDF for free
</Link>
<Link href="#features" className="text-gray-500 hover:text-gray-700 px-4 py-3 text-lg">
See how it works
</Link>
</div>
<p className="text-sm text-gray-400 mt-4">5 PDFs free · No credit card required</p>
</section>
{/* Features */}
<section id="features" className="bg-gray-50 py-20">
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">Everything you need for PDF accessibility</h2>
<div className="grid md:grid-cols-3 gap-8">
{[
{ icon: "🔍", title: "30+ WCAG 2.1 checks", desc: "Alt text, color contrast, reading order, headings, tables, forms, language, bookmarks — all automated." },
{ icon: "🤖", title: "AI-powered analysis", desc: "Claude AI validates image descriptions, detects text-in-images, and classifies decorative vs informational graphics." },
{ icon: "⚡", title: "Auto-remediation", desc: "Fix title, language, tags and bookmarks automatically. Download the corrected PDF in one click." },
{ icon: "📊", title: "Visual Page Inspector", desc: "See exactly where issues are with SVG overlays on your PDF pages. Click any issue to jump to it." },
{ icon: "🌍", title: "50+ languages", desc: "Multi-language audit reports and auto-translation of accessibility metadata." },
{ icon: "👥", title: "Team workspaces", desc: "Share results with your team, track history, and collaborate on remediation tasks." },
].map((f) => (
<div key={f.title} className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<div className="text-3xl mb-3">{f.icon}</div>
<h3 className="font-semibold text-gray-900 mb-2">{f.title}</h3>
<p className="text-gray-500 text-sm leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Social proof */}
<section className="py-16 max-w-4xl mx-auto px-6 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Who needs this?</h2>
<div className="flex flex-wrap justify-center gap-3 mt-6">
{["Banks & Financial Services", "E-commerce", "E-learning Platforms", "Government & Public Sector", "Healthcare", "Legal Firms"].map((tag) => (
<span key={tag} className="bg-gray-100 text-gray-700 px-4 py-2 rounded-full text-sm font-medium">{tag}</span>
))}
</div>
<p className="text-gray-500 mt-6 text-sm">
The <strong>EU Accessibility Act</strong> (effective June 2025) requires these sectors to provide accessible digital documents.
Non-compliance can result in fines up to 100,000.
</p>
</section>
{/* CTA */}
<section className="bg-brand-500 py-16 text-center text-white">
<h2 className="text-3xl font-bold mb-4">Ready to make your PDFs accessible?</h2>
<p className="text-brand-100 mb-8">Start free 5 PDFs per month, no credit card required.</p>
<Link href="/signup" className="bg-white text-brand-600 px-8 py-3 rounded-xl font-semibold hover:bg-brand-50 transition-colors text-lg">
Get started for free
</Link>
</section>
{/* Footer */}
<footer className="border-t border-gray-100 py-8 text-center text-sm text-gray-400">
<p>© {new Date().getFullYear()} Aimpress Ltd · Company No. 16417799 · London, UK</p>
</footer>
</main>
);
}

View file

@ -0,0 +1,95 @@
import Link from "next/link";
const plans = [
{
name: "Free",
price: "$0",
period: "forever",
quota: "5 PDFs / month",
features: ["30+ WCAG 2.1 AA checks", "HTML + JSON report", "Visual Page Inspector", "Basic issue list"],
cta: "Get started",
href: "/signup",
highlighted: false,
},
{
name: "Pro",
price: "$29",
period: "/ month",
quota: "100 PDFs / month",
features: ["Everything in Free", "Auto-remediation (fix PDF)", "PDF report export", "API access (coming soon)", "Email support"],
cta: "Start Pro",
href: "/signup?plan=pro",
highlighted: true,
},
{
name: "Business",
price: "$149",
period: "/ month",
quota: "Unlimited PDFs",
features: ["Everything in Pro", "Team workspace (up to 10 seats)", "Priority support", "Custom branding on reports", "Audit log", "SLA 99.9%"],
cta: "Start Business",
href: "/signup?plan=business",
highlighted: false,
},
];
export default function PricingPage() {
return (
<main className="min-h-screen bg-gray-50 py-16 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">Simple, transparent pricing</h1>
<p className="text-gray-500 text-lg">Start free. Upgrade when you need more.</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{plans.map((plan) => (
<div
key={plan.name}
className={`rounded-2xl p-8 border ${
plan.highlighted
? "bg-brand-500 text-white border-brand-500 shadow-xl scale-105"
: "bg-white border-gray-200 shadow-sm"
}`}
>
<div className="mb-6">
<div className={`text-sm font-semibold mb-1 ${plan.highlighted ? "text-brand-100" : "text-brand-500"}`}>
{plan.name}
</div>
<div className="flex items-end gap-1">
<span className="text-4xl font-extrabold">{plan.price}</span>
<span className={`text-sm mb-1 ${plan.highlighted ? "text-brand-100" : "text-gray-400"}`}>{plan.period}</span>
</div>
<div className={`text-sm mt-1 ${plan.highlighted ? "text-brand-100" : "text-gray-500"}`}>{plan.quota}</div>
</div>
<ul className="space-y-2 mb-8">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm">
<span className={plan.highlighted ? "text-brand-100" : "text-brand-500"}></span>
<span className={plan.highlighted ? "text-white" : "text-gray-600"}>{f}</span>
</li>
))}
</ul>
<Link
href={plan.href}
className={`block text-center py-3 rounded-xl font-semibold transition-colors ${
plan.highlighted
? "bg-white text-brand-600 hover:bg-brand-50"
: "bg-brand-500 text-white hover:bg-brand-600"
}`}
>
{plan.cta}
</Link>
</div>
))}
</div>
<p className="text-center text-sm text-gray-400 mt-8">
All plans include WCAG 2.1 AA + PDF/UA-1 checking · VAT may apply · Cancel anytime
</p>
</div>
</main>
);
}

9
frontend/app/globals.css Normal file
View file

@ -0,0 +1,9 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--brand: #6366f1;
--brand-dark: #4f46e5;
}

19
frontend/app/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Aimpress PDF Accessibility — WCAG 2.1 AA Compliance Checker",
description: "AI-powered PDF accessibility checker. WCAG 2.1 AA / PDF/UA-1 compliance in seconds. EU Accessibility Act ready.",
keywords: ["PDF accessibility", "WCAG 2.1", "PDF/UA", "accessibility checker", "EU Accessibility Act"],
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

View file

@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

View file

@ -0,0 +1,20 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}

14
frontend/next.config.ts Normal file
View file

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
};
export default nextConfig;

View file

@ -0,0 +1,22 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#eef2ff",
100: "#e0e7ff",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [require("@tailwindcss/typography")],
} satisfies Config;