Phase 4–6: Next.js frontend, production deploy, CI/CD
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:
parent
fc6f4a12e6
commit
5cbbcc6e5e
19 changed files with 981 additions and 18 deletions
110
.forgejo/workflows/ci.yml
Normal file
110
.forgejo/workflows/ci.yml
Normal 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
14
Caddyfile
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
5
frontend/.env.local.example
Normal file
5
frontend/.env.local.example
Normal 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
15
frontend/Dockerfile
Normal 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"]
|
||||
106
frontend/app/(app)/dashboard/page.tsx
Normal file
106
frontend/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/app/(app)/jobs/page.tsx
Normal file
91
frontend/app/(app)/jobs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/app/(app)/layout.tsx
Normal file
41
frontend/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/app/(app)/settings/billing/page.tsx
Normal file
86
frontend/app/(app)/settings/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/app/(auth)/login/page.tsx
Normal file
69
frontend/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/app/(auth)/signup/page.tsx
Normal file
72
frontend/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/app/(marketing)/page.tsx
Normal file
94
frontend/app/(marketing)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/app/(marketing)/pricing/page.tsx
Normal file
95
frontend/app/(marketing)/pricing/page.tsx
Normal 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
9
frontend/app/globals.css
Normal 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
19
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/lib/supabase/client.ts
Normal file
8
frontend/lib/supabase/client.ts
Normal 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!
|
||||
);
|
||||
}
|
||||
20
frontend/lib/supabase/server.ts
Normal file
20
frontend/lib/supabase/server.ts
Normal 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
14
frontend/next.config.ts
Normal 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;
|
||||
22
frontend/tailwind.config.ts
Normal file
22
frontend/tailwind.config.ts
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue