Spaces:
Sleeping
Sleeping
saifisvibinn commited on
Commit ·
d19cc77
0
Parent(s):
Initial commit: Certificate Generator App with FastAPI and React
Browse files- .devcontainer/devcontainer.json +33 -0
- .env.example +2 -0
- .gitignore +38 -0
- README.md +94 -0
- backend/fonts/custom_font.ttf +0 -0
- backend/main.py +43 -0
- backend/requirements.txt +8 -0
- backend/routers/certificates.py +139 -0
- backend/routers/email.py +234 -0
- backend/utils/pdf_generator.py +263 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +34 -0
- frontend/postcss.config.js +6 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.jsx +138 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/DesignStep.jsx +296 -0
- frontend/src/components/ReviewSendStep.jsx +316 -0
- frontend/src/components/UploadStep.jsx +127 -0
- frontend/src/index.css +8 -0
- frontend/src/main.jsx +10 -0
- frontend/tailwind.config.js +11 -0
- frontend/vite.config.js +7 -0
- requirements.txt +5 -0
.devcontainer/devcontainer.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Python 3",
|
| 3 |
+
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
| 4 |
+
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
|
| 5 |
+
"customizations": {
|
| 6 |
+
"codespaces": {
|
| 7 |
+
"openFiles": [
|
| 8 |
+
"README.md",
|
| 9 |
+
"app.py"
|
| 10 |
+
]
|
| 11 |
+
},
|
| 12 |
+
"vscode": {
|
| 13 |
+
"settings": {},
|
| 14 |
+
"extensions": [
|
| 15 |
+
"ms-python.python",
|
| 16 |
+
"ms-python.vscode-pylance"
|
| 17 |
+
]
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
"updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
|
| 21 |
+
"postAttachCommand": {
|
| 22 |
+
"server": "streamlit run app.py --server.enableCORS false --server.enableXsrfProtection false"
|
| 23 |
+
},
|
| 24 |
+
"portsAttributes": {
|
| 25 |
+
"8501": {
|
| 26 |
+
"label": "Application",
|
| 27 |
+
"onAutoForward": "openPreview"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"forwardPorts": [
|
| 31 |
+
8501
|
| 32 |
+
]
|
| 33 |
+
}
|
.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SENDGRID_API_KEY=your_sendgrid_api_key_here
|
| 2 |
+
MAIL_FROM_ADDRESS=your_email@example.com
|
.gitignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables (IMPORTANT: Contains API keys)
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
env/
|
| 11 |
+
venv/
|
| 12 |
+
certappvenv/
|
| 13 |
+
ENV/
|
| 14 |
+
*.egg-info/
|
| 15 |
+
|
| 16 |
+
# Node
|
| 17 |
+
node_modules/
|
| 18 |
+
npm-debug.log*
|
| 19 |
+
yarn-debug.log*
|
| 20 |
+
yarn-error.log*
|
| 21 |
+
.pnpm-debug.log*
|
| 22 |
+
dist/
|
| 23 |
+
build/
|
| 24 |
+
|
| 25 |
+
# Uploads (certificate templates and data)
|
| 26 |
+
uploads/
|
| 27 |
+
output/
|
| 28 |
+
|
| 29 |
+
# IDE
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
| 32 |
+
*.swp
|
| 33 |
+
*.swo
|
| 34 |
+
.DS_Store
|
| 35 |
+
|
| 36 |
+
# Temporary files
|
| 37 |
+
temp_*.pdf
|
| 38 |
+
*.tmp
|
README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Certificate Generator App
|
| 2 |
+
|
| 3 |
+
A professional certificate generation and email distribution system built with FastAPI and React.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- 📝 **Dynamic Certificate Generation**: Create personalized certificates from PDF templates
|
| 8 |
+
- 📧 **Bulk Email Sending**: Send certificates to multiple recipients via SendGrid
|
| 9 |
+
- 🎨 **Custom Fonts**: Support for TTF font files
|
| 10 |
+
- 📊 **Real-time Progress**: Live progress tracking for email campaigns
|
| 11 |
+
- 🔒 **Secure**: Environment-based credential management
|
| 12 |
+
|
| 13 |
+
## Tech Stack
|
| 14 |
+
|
| 15 |
+
- **Backend**: FastAPI, Python, PyMuPDF, SendGrid
|
| 16 |
+
- **Frontend**: React, Vite, TailwindCSS, Framer Motion
|
| 17 |
+
- **Database**: In-memory (for progress tracking)
|
| 18 |
+
|
| 19 |
+
## Getting Started
|
| 20 |
+
|
| 21 |
+
### Prerequisites
|
| 22 |
+
|
| 23 |
+
- Python 3.8+
|
| 24 |
+
- Node.js 16+
|
| 25 |
+
- SendGrid API Key
|
| 26 |
+
|
| 27 |
+
### Installation
|
| 28 |
+
|
| 29 |
+
1. Clone the repository:
|
| 30 |
+
```bash
|
| 31 |
+
git clone https://github.com/saifisvibinn/Cert_Web_App_Volaris.git
|
| 32 |
+
cd Cert_Web_App_Volaris
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
2. Set up the backend:
|
| 36 |
+
```bash
|
| 37 |
+
# Create virtual environment
|
| 38 |
+
python -m venv certappvenv
|
| 39 |
+
certappvenv\Scripts\activate # Windows
|
| 40 |
+
# source certappvenv/bin/activate # macOS/Linux
|
| 41 |
+
|
| 42 |
+
# Install dependencies
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
3. Set up the frontend:
|
| 47 |
+
```bash
|
| 48 |
+
cd frontend
|
| 49 |
+
npm install
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
4. Configure environment variables:
|
| 53 |
+
Create a `.env` file in the root directory:
|
| 54 |
+
```
|
| 55 |
+
SENDGRID_API_KEY=your_sendgrid_api_key
|
| 56 |
+
MAIL_FROM_ADDRESS=your_verified_sender@domain.com
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### Running Locally
|
| 60 |
+
|
| 61 |
+
1. Start the backend:
|
| 62 |
+
```bash
|
| 63 |
+
uvicorn backend.main:app --reload
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
2. Start the frontend (in a new terminal):
|
| 67 |
+
```bash
|
| 68 |
+
cd frontend
|
| 69 |
+
npm run dev
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
3. Open http://localhost:5173 in your browser
|
| 73 |
+
|
| 74 |
+
## Usage
|
| 75 |
+
|
| 76 |
+
1. **Upload Files**: Upload your Excel file (with Name and Email columns) and PDF certificate template
|
| 77 |
+
2. **Design**: Position the name text on the certificate and customize styling
|
| 78 |
+
3. **Configure**: Fill in event details (name, date, company) for email templates
|
| 79 |
+
4. **Send**: Monitor real-time progress as certificates are generated and emailed
|
| 80 |
+
|
| 81 |
+
## Environment Variables
|
| 82 |
+
|
| 83 |
+
| Variable | Description |
|
| 84 |
+
|----------|-------------|
|
| 85 |
+
| `SENDGRID_API_KEY` | Your SendGrid API key |
|
| 86 |
+
| `MAIL_FROM_ADDRESS` | Verified sender email address |
|
| 87 |
+
|
| 88 |
+
## Deployment
|
| 89 |
+
|
| 90 |
+
See [deployment_guide.md](deployment_guide.md) for deployment instructions.
|
| 91 |
+
|
| 92 |
+
## License
|
| 93 |
+
|
| 94 |
+
MIT
|
backend/fonts/custom_font.ttf
ADDED
|
Binary file (20.5 kB). View file
|
|
|
backend/main.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
# Load environment variables BEFORE importing routers that need them
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# Debug: verify environment variables are loaded
|
| 10 |
+
print(f"DEBUG: SENDGRID_API_KEY loaded: {bool(os.getenv('SENDGRID_API_KEY'))}")
|
| 11 |
+
print(f"DEBUG: MAIL_FROM_ADDRESS loaded: {os.getenv('MAIL_FROM_ADDRESS')}")
|
| 12 |
+
|
| 13 |
+
# Import routers AFTER loading environment variables
|
| 14 |
+
from backend.routers import certificates, email
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title="Certificate Generator API")
|
| 17 |
+
|
| 18 |
+
# CORS Configuration
|
| 19 |
+
origins = [
|
| 20 |
+
"http://localhost:5173", # Vite default port
|
| 21 |
+
"http://localhost:5174", # Vite fallback port
|
| 22 |
+
"http://localhost:5175",
|
| 23 |
+
"http://localhost:3000",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=origins,
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Include Routers
|
| 35 |
+
app.include_router(certificates.router, prefix="/api/certificates", tags=["certificates"])
|
| 36 |
+
app.include_router(email.router, prefix="/api/email", tags=["email"])
|
| 37 |
+
|
| 38 |
+
@app.get("/")
|
| 39 |
+
def read_root():
|
| 40 |
+
return {"message": "Certificate Generator API is running"}
|
| 41 |
+
|
| 42 |
+
# Ensure output directory exists
|
| 43 |
+
os.makedirs("output", exist_ok=True)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
pandas
|
| 5 |
+
openpyxl
|
| 6 |
+
pymupdf
|
| 7 |
+
sendgrid
|
| 8 |
+
python-dotenv
|
backend/routers/certificates.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
| 2 |
+
from fastapi.responses import Response, FileResponse
|
| 3 |
+
from backend.utils.pdf_generator import generate_certificate_pdf, get_pdf_preview_image
|
| 4 |
+
import shutil
|
| 5 |
+
import os
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import zipfile
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
# In-memory storage for simplicity (in a real app, use a DB or temporary file storage with IDs)
|
| 13 |
+
# For this single-user local app, we can store the latest uploaded files in a specific location.
|
| 14 |
+
UPLOAD_DIR = "uploads"
|
| 15 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 16 |
+
|
| 17 |
+
@router.post("/upload")
|
| 18 |
+
async def upload_files(
|
| 19 |
+
excel_file: UploadFile = File(...),
|
| 20 |
+
pdf_file: UploadFile = File(...)
|
| 21 |
+
):
|
| 22 |
+
try:
|
| 23 |
+
# Save Excel
|
| 24 |
+
excel_path = os.path.join(UPLOAD_DIR, "data.xlsx")
|
| 25 |
+
with open(excel_path, "wb") as f:
|
| 26 |
+
shutil.copyfileobj(excel_file.file, f)
|
| 27 |
+
|
| 28 |
+
# Save PDF
|
| 29 |
+
pdf_path = os.path.join(UPLOAD_DIR, "template.pdf")
|
| 30 |
+
with open(pdf_path, "wb") as f:
|
| 31 |
+
shutil.copyfileobj(pdf_file.file, f)
|
| 32 |
+
|
| 33 |
+
# Parse Excel to get columns and preview data
|
| 34 |
+
df = pd.read_excel(excel_path)
|
| 35 |
+
columns = df.columns.tolist()
|
| 36 |
+
preview_data = df.head().to_dict(orient="records")
|
| 37 |
+
|
| 38 |
+
return {
|
| 39 |
+
"message": "Files uploaded successfully",
|
| 40 |
+
"columns": columns,
|
| 41 |
+
"preview_data": preview_data,
|
| 42 |
+
"total_rows": len(df)
|
| 43 |
+
}
|
| 44 |
+
except Exception as e:
|
| 45 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 46 |
+
|
| 47 |
+
@router.post("/preview")
|
| 48 |
+
async def preview_certificate(
|
| 49 |
+
name: str = Form(...),
|
| 50 |
+
name_color: str = Form("#000000"),
|
| 51 |
+
font_size: int = Form(60),
|
| 52 |
+
x: float = Form(None),
|
| 53 |
+
y: float = Form(None),
|
| 54 |
+
fontname: str = Form("helv")
|
| 55 |
+
):
|
| 56 |
+
pdf_path = os.path.join(UPLOAD_DIR, "template.pdf")
|
| 57 |
+
font_path = os.path.join("backend", "fonts", "custom_font.ttf")
|
| 58 |
+
|
| 59 |
+
if not os.path.exists(pdf_path):
|
| 60 |
+
raise HTTPException(status_code=400, detail="Template not found. Please upload files first.")
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
with open(pdf_path, "rb") as f:
|
| 64 |
+
template_bytes = f.read()
|
| 65 |
+
|
| 66 |
+
print(f"Generating preview for name: '{name}', font: {fontname}, file: {font_path}")
|
| 67 |
+
img_bytes = get_pdf_preview_image(
|
| 68 |
+
template_bytes,
|
| 69 |
+
name=name,
|
| 70 |
+
name_color_hex=name_color,
|
| 71 |
+
font_size=font_size,
|
| 72 |
+
x=x if x is not None else None,
|
| 73 |
+
y=y if y is not None else None,
|
| 74 |
+
fontname=fontname,
|
| 75 |
+
fontfile=font_path if os.path.exists(font_path) else None
|
| 76 |
+
)
|
| 77 |
+
print(f"Preview generated, size: {len(img_bytes)} bytes")
|
| 78 |
+
return Response(content=img_bytes, media_type="image/png")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 81 |
+
|
| 82 |
+
@router.post("/generate")
|
| 83 |
+
async def generate_certificates(
|
| 84 |
+
name_column: str = Form(...),
|
| 85 |
+
email_column: str = Form(...),
|
| 86 |
+
name_color: str = Form("#000000"),
|
| 87 |
+
font_size: int = Form(60),
|
| 88 |
+
x: float = Form(None),
|
| 89 |
+
y: float = Form(None),
|
| 90 |
+
fontname: str = Form("helv")
|
| 91 |
+
):
|
| 92 |
+
excel_path = os.path.join(UPLOAD_DIR, "data.xlsx")
|
| 93 |
+
pdf_path = os.path.join(UPLOAD_DIR, "template.pdf")
|
| 94 |
+
font_path = os.path.join("backend", "fonts", "custom_font.ttf")
|
| 95 |
+
|
| 96 |
+
if not os.path.exists(excel_path) or not os.path.exists(pdf_path):
|
| 97 |
+
raise HTTPException(status_code=400, detail="Files not found.")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
df = pd.read_excel(excel_path)
|
| 101 |
+
with open(pdf_path, "rb") as f:
|
| 102 |
+
template_bytes = f.read()
|
| 103 |
+
|
| 104 |
+
output_zip_buffer = BytesIO()
|
| 105 |
+
|
| 106 |
+
with zipfile.ZipFile(output_zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
|
| 107 |
+
for index, row in df.iterrows():
|
| 108 |
+
name = str(row.get(name_column, ""))
|
| 109 |
+
if not name: continue
|
| 110 |
+
|
| 111 |
+
# Generate individual PDF in memory (or temp file)
|
| 112 |
+
# For simplicity, we'll use a temp file
|
| 113 |
+
temp_filename = f"temp_{index}.pdf"
|
| 114 |
+
generate_certificate_pdf(
|
| 115 |
+
name,
|
| 116 |
+
template_bytes,
|
| 117 |
+
temp_filename,
|
| 118 |
+
name_color_hex=name_color,
|
| 119 |
+
font_size=font_size,
|
| 120 |
+
x=x if x is not None else None,
|
| 121 |
+
y=y if y is not None else None,
|
| 122 |
+
fontname=fontname,
|
| 123 |
+
fontfile=font_path if os.path.exists(font_path) else None
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Add to zip
|
| 127 |
+
zipf.write(temp_filename, arcname=f"{name}.pdf")
|
| 128 |
+
os.remove(temp_filename)
|
| 129 |
+
|
| 130 |
+
output_zip_buffer.seek(0)
|
| 131 |
+
|
| 132 |
+
return Response(
|
| 133 |
+
content=output_zip_buffer.getvalue(),
|
| 134 |
+
media_type="application/zip",
|
| 135 |
+
headers={"Content-Disposition": "attachment; filename=certificates.zip"}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/routers/email.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Form, BackgroundTasks
|
| 2 |
+
from pydantic import BaseModel, EmailStr
|
| 3 |
+
from typing import List
|
| 4 |
+
import os
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from sendgrid import SendGridAPIClient
|
| 7 |
+
from sendgrid.helpers.mail import Mail, Attachment, FileContent, FileName, FileType, Disposition
|
| 8 |
+
import base64
|
| 9 |
+
import uuid
|
| 10 |
+
from backend.utils.pdf_generator import generate_certificate_pdf
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# Get credentials from environment variables
|
| 15 |
+
SENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY', '')
|
| 16 |
+
FROM_EMAIL = os.getenv('MAIL_FROM_ADDRESS', '')
|
| 17 |
+
|
| 18 |
+
class EmailRequest(BaseModel):
|
| 19 |
+
subject: str
|
| 20 |
+
body: str
|
| 21 |
+
sendgrid_api_key: str
|
| 22 |
+
from_email: str
|
| 23 |
+
name_column: str
|
| 24 |
+
email_column: str
|
| 25 |
+
# Design params
|
| 26 |
+
name_color: str = "#000000"
|
| 27 |
+
font_size: int = 60
|
| 28 |
+
x: float = None
|
| 29 |
+
y: float = None
|
| 30 |
+
fontname: str = "helv"
|
| 31 |
+
|
| 32 |
+
UPLOAD_DIR = "uploads"
|
| 33 |
+
|
| 34 |
+
# In-memory progress tracking
|
| 35 |
+
email_progress = {}
|
| 36 |
+
|
| 37 |
+
@router.get("/config")
|
| 38 |
+
async def get_email_config():
|
| 39 |
+
"""Return the configured from email address for display in UI"""
|
| 40 |
+
return {"from_email": FROM_EMAIL}
|
| 41 |
+
|
| 42 |
+
@router.get("/progress/{job_id}")
|
| 43 |
+
async def get_email_progress(job_id: str):
|
| 44 |
+
"""Return the current progress of an email sending job"""
|
| 45 |
+
if job_id not in email_progress:
|
| 46 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 47 |
+
return email_progress[job_id]
|
| 48 |
+
|
| 49 |
+
def send_email_task(
|
| 50 |
+
job_id,
|
| 51 |
+
row,
|
| 52 |
+
template_bytes,
|
| 53 |
+
subject_template,
|
| 54 |
+
body_template,
|
| 55 |
+
from_email,
|
| 56 |
+
api_key,
|
| 57 |
+
name_col,
|
| 58 |
+
email_col,
|
| 59 |
+
design_params,
|
| 60 |
+
event_name="",
|
| 61 |
+
event_date="",
|
| 62 |
+
client_company="",
|
| 63 |
+
fontfile=None
|
| 64 |
+
):
|
| 65 |
+
name = str(row.get(name_col, "")).strip()
|
| 66 |
+
email = str(row.get(email_col, "")).strip()
|
| 67 |
+
|
| 68 |
+
# Log the extraction for debugging
|
| 69 |
+
print(f"Processing Row: {row.to_dict()}")
|
| 70 |
+
print(f"Extracted Name: '{name}' (from col '{name_col}')")
|
| 71 |
+
print(f"Extracted Email: '{email}' (from col '{email_col}')")
|
| 72 |
+
|
| 73 |
+
if not name or not email:
|
| 74 |
+
print(f"Skipping row with missing name or email")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
print(f"Starting email task for: {name} ({email})")
|
| 78 |
+
try:
|
| 79 |
+
# Generate PDF
|
| 80 |
+
temp_pdf = f"temp_send_{name}.pdf"
|
| 81 |
+
generate_certificate_pdf(
|
| 82 |
+
name,
|
| 83 |
+
template_bytes,
|
| 84 |
+
temp_pdf,
|
| 85 |
+
name_color_hex=design_params['name_color'],
|
| 86 |
+
font_size=design_params['font_size'],
|
| 87 |
+
x=design_params['x'],
|
| 88 |
+
y=design_params['y'],
|
| 89 |
+
fontname=design_params['fontname'],
|
| 90 |
+
fontfile=fontfile
|
| 91 |
+
)
|
| 92 |
+
print(f"PDF generated for {name}")
|
| 93 |
+
|
| 94 |
+
with open(temp_pdf, "rb") as f:
|
| 95 |
+
pdf_data = f.read()
|
| 96 |
+
encoded_pdf = base64.b64encode(pdf_data).decode()
|
| 97 |
+
os.remove(temp_pdf)
|
| 98 |
+
|
| 99 |
+
# Prepare Email
|
| 100 |
+
first_name = name.split()[0]
|
| 101 |
+
formatted_subject = subject_template.format(
|
| 102 |
+
first_name=first_name,
|
| 103 |
+
name=name,
|
| 104 |
+
event_name=event_name,
|
| 105 |
+
event_date=event_date,
|
| 106 |
+
client_company=client_company
|
| 107 |
+
)
|
| 108 |
+
formatted_body = body_template.format(
|
| 109 |
+
first_name=first_name,
|
| 110 |
+
name=name,
|
| 111 |
+
event_name=event_name,
|
| 112 |
+
event_date=event_date,
|
| 113 |
+
client_company=client_company
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Convert newlines to HTML breaks for proper email formatting
|
| 117 |
+
formatted_body_html = formatted_body.replace('\n', '<br>')
|
| 118 |
+
|
| 119 |
+
message = Mail(
|
| 120 |
+
from_email=from_email,
|
| 121 |
+
to_emails=email,
|
| 122 |
+
subject=formatted_subject,
|
| 123 |
+
html_content=formatted_body_html
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
attachment = Attachment(
|
| 127 |
+
FileContent(encoded_pdf),
|
| 128 |
+
FileName(f"{name}.pdf"),
|
| 129 |
+
FileType("application/pdf"),
|
| 130 |
+
Disposition("attachment")
|
| 131 |
+
)
|
| 132 |
+
message.attachment = attachment
|
| 133 |
+
|
| 134 |
+
print(f"Sending email to {email} via SendGrid...")
|
| 135 |
+
sg = SendGridAPIClient(api_key)
|
| 136 |
+
response = sg.send(message)
|
| 137 |
+
print(f"Sent to {email}: Status {response.status_code}")
|
| 138 |
+
|
| 139 |
+
# Update progress
|
| 140 |
+
if job_id in email_progress:
|
| 141 |
+
email_progress[job_id]["sent"] += 1
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f"Failed to send to {email}: {e}")
|
| 145 |
+
import traceback
|
| 146 |
+
traceback.print_exc()
|
| 147 |
+
|
| 148 |
+
# Update progress even on failure
|
| 149 |
+
if job_id in email_progress:
|
| 150 |
+
email_progress[job_id]["sent"] += 1
|
| 151 |
+
email_progress[job_id]["failed"] += 1
|
| 152 |
+
|
| 153 |
+
@router.post("/send")
|
| 154 |
+
async def send_emails(
|
| 155 |
+
background_tasks: BackgroundTasks,
|
| 156 |
+
subject: str = Form(...),
|
| 157 |
+
body: str = Form(...),
|
| 158 |
+
name_column: str = Form(...),
|
| 159 |
+
email_column: str = Form(...),
|
| 160 |
+
event_name: str = Form(""),
|
| 161 |
+
event_date: str = Form(""),
|
| 162 |
+
client_company: str = Form(""),
|
| 163 |
+
name_color: str = Form("#000000"),
|
| 164 |
+
font_size: int = Form(60),
|
| 165 |
+
x: float = Form(None),
|
| 166 |
+
y: float = Form(None),
|
| 167 |
+
fontname: str = Form("helv")
|
| 168 |
+
):
|
| 169 |
+
# Use environment variables for sensitive credentials
|
| 170 |
+
if not SENDGRID_API_KEY or not FROM_EMAIL:
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=500,
|
| 173 |
+
detail="SendGrid credentials not configured. Please check environment variables."
|
| 174 |
+
)
|
| 175 |
+
excel_path = os.path.join(UPLOAD_DIR, "data.xlsx")
|
| 176 |
+
pdf_path = os.path.join(UPLOAD_DIR, "template.pdf")
|
| 177 |
+
font_path = os.path.join("backend", "fonts", "custom_font.ttf")
|
| 178 |
+
|
| 179 |
+
if not os.path.exists(excel_path) or not os.path.exists(pdf_path):
|
| 180 |
+
raise HTTPException(status_code=400, detail="Files not found.")
|
| 181 |
+
|
| 182 |
+
df = pd.read_excel(excel_path)
|
| 183 |
+
with open(pdf_path, "rb") as f:
|
| 184 |
+
template_bytes = f.read()
|
| 185 |
+
|
| 186 |
+
design_params = {
|
| 187 |
+
"name_color": name_color,
|
| 188 |
+
"font_size": font_size,
|
| 189 |
+
"x": x if x is not None else None,
|
| 190 |
+
"y": y if y is not None else None,
|
| 191 |
+
"fontname": fontname
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# Generate unique job ID for progress tracking
|
| 195 |
+
job_id = str(uuid.uuid4())
|
| 196 |
+
email_progress[job_id] = {
|
| 197 |
+
"total": len(df),
|
| 198 |
+
"sent": 0,
|
| 199 |
+
"failed": 0,
|
| 200 |
+
"status": "processing"
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
count = 0
|
| 204 |
+
for _, row in df.iterrows():
|
| 205 |
+
background_tasks.add_task(
|
| 206 |
+
send_email_task,
|
| 207 |
+
job_id,
|
| 208 |
+
row,
|
| 209 |
+
template_bytes,
|
| 210 |
+
subject,
|
| 211 |
+
body,
|
| 212 |
+
FROM_EMAIL,
|
| 213 |
+
SENDGRID_API_KEY,
|
| 214 |
+
name_column,
|
| 215 |
+
email_column,
|
| 216 |
+
design_params,
|
| 217 |
+
event_name,
|
| 218 |
+
event_date,
|
| 219 |
+
client_company,
|
| 220 |
+
fontfile=font_path if os.path.exists(font_path) else None
|
| 221 |
+
)
|
| 222 |
+
count += 1
|
| 223 |
+
|
| 224 |
+
# Mark job as complete after all tasks are queued
|
| 225 |
+
def mark_complete():
|
| 226 |
+
if job_id in email_progress:
|
| 227 |
+
email_progress[job_id]["status"] = "completed"
|
| 228 |
+
|
| 229 |
+
background_tasks.add_task(mark_complete)
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"message": f"Successfully started sending process for {count} emails. Check your inbox!",
|
| 233 |
+
"job_id": job_id
|
| 234 |
+
}
|
backend/utils/pdf_generator.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fitz
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
def hex_to_rgb(hex_color):
|
| 5 |
+
hex_color = hex_color.lstrip('#')
|
| 6 |
+
return tuple(int(hex_color[i:i+2], 16)/255 for i in (0, 2, 4))
|
| 7 |
+
|
| 8 |
+
def generate_certificate_pdf(name, template_bytes, output_path, name_color_hex="#000000", font_size=60, x=None, y=None, fontname="helv", fontfile=None):
|
| 9 |
+
"""
|
| 10 |
+
Generates a certificate PDF by overlaying text.
|
| 11 |
+
If x and y are provided, uses those coordinates.
|
| 12 |
+
Otherwise, searches for <fullName> placeholder.
|
| 13 |
+
"""
|
| 14 |
+
doc = fitz.open(stream=template_bytes, filetype="pdf")
|
| 15 |
+
name_color = hex_to_rgb(name_color_hex)
|
| 16 |
+
|
| 17 |
+
for page in doc:
|
| 18 |
+
target_x, target_y = x, y
|
| 19 |
+
|
| 20 |
+
# If no manual coordinates, try to find placeholder
|
| 21 |
+
if target_x is None or target_y is None:
|
| 22 |
+
found = False
|
| 23 |
+
for inst in page.search_for("<fullName>"):
|
| 24 |
+
rect = fitz.Rect(inst)
|
| 25 |
+
# Calculate center of the placeholder
|
| 26 |
+
name_width = fitz.get_text_length(name, fontsize=font_size, fontname=fontname, fontfile=fontfile)
|
| 27 |
+
target_x = rect.x0 + (rect.width - name_width) / 2
|
| 28 |
+
# For Y: use baseline position (rect.y1 is bottom of box)
|
| 29 |
+
target_y = rect.y0 + rect.height / 2 + font_size / 3
|
| 30 |
+
found = True
|
| 31 |
+
break
|
| 32 |
+
|
| 33 |
+
if not found:
|
| 34 |
+
# Fallback default if nothing found and no coordinates
|
| 35 |
+
page_width = page.rect.width
|
| 36 |
+
page_height = page.rect.height
|
| 37 |
+
rect_width = int(page_width * 0.6)
|
| 38 |
+
x0_default = int((page_width - rect_width) / 2)
|
| 39 |
+
name_width = fitz.get_text_length(name, fontsize=font_size, fontname=fontname, fontfile=fontfile)
|
| 40 |
+
target_x = x0_default + (rect_width - name_width) / 2
|
| 41 |
+
# Place at 60% down the page
|
| 42 |
+
target_y = page_height * 0.6
|
| 43 |
+
|
| 44 |
+
# Insert text (ensure coordinates are within page bounds)
|
| 45 |
+
if target_x is not None and target_y is not None:
|
| 46 |
+
# Calculate text dimensions for centering
|
| 47 |
+
if fontfile and os.path.exists(fontfile):
|
| 48 |
+
font = fitz.Font(fontfile=fontfile)
|
| 49 |
+
else:
|
| 50 |
+
font = fitz.Font(fontname)
|
| 51 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 52 |
+
|
| 53 |
+
# Adjust x to center the text
|
| 54 |
+
insert_x = target_x - (name_width / 2)
|
| 55 |
+
|
| 56 |
+
# Adjust y from center to baseline (approximate)
|
| 57 |
+
# Center is target_y. Baseline is typically target_y + font_size/3
|
| 58 |
+
insert_y = target_y + (font_size / 3)
|
| 59 |
+
|
| 60 |
+
# Clamp coordinates to page bounds
|
| 61 |
+
page_rect = page.rect
|
| 62 |
+
insert_x = max(10, min(insert_x, page_rect.width - name_width - 10))
|
| 63 |
+
insert_y = max(font_size, min(insert_y, page_rect.height - 10))
|
| 64 |
+
|
| 65 |
+
page.insert_text(
|
| 66 |
+
(insert_x, insert_y),
|
| 67 |
+
name,
|
| 68 |
+
fontsize=font_size,
|
| 69 |
+
color=name_color,
|
| 70 |
+
fontname=fontname,
|
| 71 |
+
fontfile=fontfile
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
doc.save(output_path)
|
| 75 |
+
doc.close()
|
| 76 |
+
return output_path
|
| 77 |
+
|
| 78 |
+
def get_pdf_preview_image(template_bytes, name="Sample Name", name_color_hex="#000000", font_size=60, x=None, y=None, fontname="helv", fontfile=None):
|
| 79 |
+
"""
|
| 80 |
+
Generates a preview image (PNG) of the first page of the certificate.
|
| 81 |
+
Returns bytes of the PNG image.
|
| 82 |
+
"""
|
| 83 |
+
doc = fitz.open(stream=template_bytes, filetype="pdf")
|
| 84 |
+
name_color = hex_to_rgb(name_color_hex)
|
| 85 |
+
page = doc[0] # Preview only first page
|
| 86 |
+
|
| 87 |
+
target_x, target_y = x, y
|
| 88 |
+
|
| 89 |
+
# Logic similar to generation, but just for one page and return image
|
| 90 |
+
if target_x is None or target_y is None:
|
| 91 |
+
found = False
|
| 92 |
+
for inst in page.search_for("<fullName>"):
|
| 93 |
+
rect = fitz.Rect(inst)
|
| 94 |
+
name_width = fitz.get_text_length(name, fontsize=font_size, fontname=fontname, fontfile=fontfile)
|
| 95 |
+
target_x = rect.x0 + (rect.width - name_width) / 2
|
| 96 |
+
target_y = rect.y0 + rect.height / 2 + font_size / 3
|
| 97 |
+
found = True
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
if not found:
|
| 101 |
+
page_width = page.rect.width
|
| 102 |
+
page_height = page.rect.height
|
| 103 |
+
rect_width = int(page_width * 0.6)
|
| 104 |
+
x0_default = int((page_width - rect_width) / 2)
|
| 105 |
+
name_width = fitz.get_text_length(name, fontsize=font_size, fontname=fontname, fontfile=fontfile)
|
| 106 |
+
target_x = x0_default + (rect_width - name_width) / 2
|
| 107 |
+
import fitz
|
| 108 |
+
import os
|
| 109 |
+
|
| 110 |
+
def hex_to_rgb(hex_color):
|
| 111 |
+
hex_color = hex_color.lstrip('#')
|
| 112 |
+
return tuple(int(hex_color[i:i+2], 16)/255 for i in (0, 2, 4))
|
| 113 |
+
|
| 114 |
+
def generate_certificate_pdf(name, template_bytes, output_path, name_color_hex="#000000", font_size=60, x=None, y=None, fontname="helv", fontfile=None):
|
| 115 |
+
"""
|
| 116 |
+
Generates a certificate PDF by overlaying text.
|
| 117 |
+
If x and y are provided, uses those coordinates.
|
| 118 |
+
Otherwise, searches for <fullName> placeholder.
|
| 119 |
+
"""
|
| 120 |
+
doc = fitz.open(stream=template_bytes, filetype="pdf")
|
| 121 |
+
name_color = hex_to_rgb(name_color_hex)
|
| 122 |
+
|
| 123 |
+
for page in doc:
|
| 124 |
+
target_x, target_y = x, y
|
| 125 |
+
|
| 126 |
+
# If no manual coordinates, try to find placeholder
|
| 127 |
+
if target_x is None or target_y is None:
|
| 128 |
+
found = False
|
| 129 |
+
for inst in page.search_for("<fullName>"):
|
| 130 |
+
rect = fitz.Rect(inst)
|
| 131 |
+
# Calculate center of the placeholder
|
| 132 |
+
if fontfile and os.path.exists(fontfile):
|
| 133 |
+
font = fitz.Font(fontfile=fontfile)
|
| 134 |
+
else:
|
| 135 |
+
font = fitz.Font(fontname)
|
| 136 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 137 |
+
target_x = rect.x0 + (rect.width - name_width) / 2
|
| 138 |
+
# For Y: use baseline position (rect.y1 is bottom of box)
|
| 139 |
+
target_y = rect.y0 + rect.height / 2 + font_size / 3
|
| 140 |
+
found = True
|
| 141 |
+
break
|
| 142 |
+
|
| 143 |
+
if not found:
|
| 144 |
+
# Fallback default if nothing found and no coordinates
|
| 145 |
+
page_width = page.rect.width
|
| 146 |
+
page_height = page.rect.height
|
| 147 |
+
rect_width = int(page_width * 0.6)
|
| 148 |
+
x0_default = int((page_width - rect_width) / 2)
|
| 149 |
+
if fontfile and os.path.exists(fontfile):
|
| 150 |
+
font = fitz.Font(fontfile=fontfile)
|
| 151 |
+
else:
|
| 152 |
+
font = fitz.Font(fontname)
|
| 153 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 154 |
+
target_x = x0_default + (rect_width - name_width) / 2
|
| 155 |
+
# Place at 60% down the page
|
| 156 |
+
target_y = page_height * 0.6
|
| 157 |
+
|
| 158 |
+
# Insert text (ensure coordinates are within page bounds)
|
| 159 |
+
if target_x is not None and target_y is not None:
|
| 160 |
+
# Calculate text dimensions for centering
|
| 161 |
+
if fontfile and os.path.exists(fontfile):
|
| 162 |
+
font = fitz.Font(fontfile=fontfile)
|
| 163 |
+
else:
|
| 164 |
+
font = fitz.Font(fontname)
|
| 165 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 166 |
+
|
| 167 |
+
# Adjust x to center the text
|
| 168 |
+
insert_x = target_x - (name_width / 2)
|
| 169 |
+
|
| 170 |
+
# Adjust y from center to baseline (approximate)
|
| 171 |
+
# Center is target_y. Baseline is typically target_y + font_size/3
|
| 172 |
+
insert_y = target_y + (font_size / 3)
|
| 173 |
+
|
| 174 |
+
# Clamp coordinates to page bounds
|
| 175 |
+
page_rect = page.rect
|
| 176 |
+
insert_x = max(10, min(insert_x, page_rect.width - name_width - 10))
|
| 177 |
+
insert_y = max(font_size, min(insert_y, page_rect.height - 10))
|
| 178 |
+
|
| 179 |
+
page.insert_text(
|
| 180 |
+
(insert_x, insert_y),
|
| 181 |
+
name,
|
| 182 |
+
fontsize=font_size,
|
| 183 |
+
color=name_color,
|
| 184 |
+
fontname=fontname,
|
| 185 |
+
fontfile=fontfile
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
doc.save(output_path)
|
| 189 |
+
doc.close()
|
| 190 |
+
return output_path
|
| 191 |
+
|
| 192 |
+
def get_pdf_preview_image(template_bytes, name="Sample Name", name_color_hex="#000000", font_size=60, x=None, y=None, fontname="helv", fontfile=None):
|
| 193 |
+
"""
|
| 194 |
+
Generates a preview image (PNG) of the first page of the certificate.
|
| 195 |
+
Returns bytes of the PNG image.
|
| 196 |
+
"""
|
| 197 |
+
doc = fitz.open(stream=template_bytes, filetype="pdf")
|
| 198 |
+
name_color = hex_to_rgb(name_color_hex)
|
| 199 |
+
page = doc[0] # Preview only first page
|
| 200 |
+
|
| 201 |
+
target_x, target_y = x, y
|
| 202 |
+
|
| 203 |
+
# Logic similar to generation, but just for one page and return image
|
| 204 |
+
if target_x is None or target_y is None:
|
| 205 |
+
found = False
|
| 206 |
+
for inst in page.search_for("<fullName>"):
|
| 207 |
+
rect = fitz.Rect(inst)
|
| 208 |
+
if fontfile and os.path.exists(fontfile):
|
| 209 |
+
font = fitz.Font(fontfile=fontfile)
|
| 210 |
+
else:
|
| 211 |
+
font = fitz.Font(fontname)
|
| 212 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 213 |
+
target_x = rect.x0 + (rect.width - name_width) / 2
|
| 214 |
+
target_y = rect.y0 + rect.height / 2 + font_size / 3
|
| 215 |
+
found = True
|
| 216 |
+
break
|
| 217 |
+
|
| 218 |
+
if not found:
|
| 219 |
+
page_width = page.rect.width
|
| 220 |
+
page_height = page.rect.height
|
| 221 |
+
rect_width = int(page_width * 0.6)
|
| 222 |
+
x0_default = int((page_width - rect_width) / 2)
|
| 223 |
+
if fontfile and os.path.exists(fontfile):
|
| 224 |
+
font = fitz.Font(fontfile=fontfile)
|
| 225 |
+
else:
|
| 226 |
+
font = fitz.Font(fontname)
|
| 227 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 228 |
+
target_x = x0_default + (rect_width - name_width) / 2
|
| 229 |
+
target_y = page_height * 0.6
|
| 230 |
+
|
| 231 |
+
if target_x is not None and target_y is not None:
|
| 232 |
+
# Calculate text dimensions for centering
|
| 233 |
+
if fontfile and os.path.exists(fontfile):
|
| 234 |
+
font = fitz.Font(fontfile=fontfile)
|
| 235 |
+
else:
|
| 236 |
+
font = fitz.Font(fontname)
|
| 237 |
+
name_width = font.text_length(name, fontsize=font_size)
|
| 238 |
+
|
| 239 |
+
# Adjust x to center the text
|
| 240 |
+
insert_x = target_x - (name_width / 2)
|
| 241 |
+
|
| 242 |
+
# Adjust y from center to baseline (approximate)
|
| 243 |
+
# Center is target_y. Baseline is typically target_y + font_size/3
|
| 244 |
+
insert_y = target_y + (font_size / 3)
|
| 245 |
+
|
| 246 |
+
# Clamp coordinates to page bounds
|
| 247 |
+
page_rect = page.rect
|
| 248 |
+
insert_x = max(10, min(insert_x, page_rect.width - name_width - 10))
|
| 249 |
+
insert_y = max(font_size, min(insert_y, page_rect.height - 10))
|
| 250 |
+
|
| 251 |
+
page.insert_text(
|
| 252 |
+
(insert_x, insert_y),
|
| 253 |
+
name,
|
| 254 |
+
fontsize=font_size,
|
| 255 |
+
color=name_color,
|
| 256 |
+
fontname=fontname,
|
| 257 |
+
fontfile=fontfile
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
pix = page.get_pixmap()
|
| 261 |
+
img_bytes = pix.tobytes("png")
|
| 262 |
+
doc.close()
|
| 263 |
+
return img_bytes
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"axios": "^1.13.2",
|
| 14 |
+
"framer-motion": "^12.23.24",
|
| 15 |
+
"react": "^19.2.0",
|
| 16 |
+
"react-dom": "^19.2.0",
|
| 17 |
+
"react-draggable": "^4.5.0",
|
| 18 |
+
"react-icons": "^5.5.0"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@eslint/js": "^9.39.1",
|
| 22 |
+
"@types/react": "^19.2.5",
|
| 23 |
+
"@types/react-dom": "^19.2.3",
|
| 24 |
+
"@vitejs/plugin-react": "^4.3.0",
|
| 25 |
+
"autoprefixer": "^10.4.22",
|
| 26 |
+
"eslint": "^9.39.1",
|
| 27 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 28 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 29 |
+
"globals": "^16.5.0",
|
| 30 |
+
"postcss": "^8.5.6",
|
| 31 |
+
"tailwindcss": "^3.4.18",
|
| 32 |
+
"vite": "^5.4.0"
|
| 33 |
+
}
|
| 34 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import UploadStep from './components/UploadStep';
|
| 3 |
+
import DesignStep from './components/DesignStep';
|
| 4 |
+
import ReviewSendStep from './components/ReviewSendStep';
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
+
import { FaCheckCircle } from 'react-icons/fa';
|
| 7 |
+
|
| 8 |
+
function App() {
|
| 9 |
+
const [step, setStep] = useState(1);
|
| 10 |
+
const [fileData, setFileData] = useState(null);
|
| 11 |
+
const [designParams, setDesignParams] = useState({
|
| 12 |
+
name_color: "#000000",
|
| 13 |
+
font_size: 60,
|
| 14 |
+
x: null,
|
| 15 |
+
y: null,
|
| 16 |
+
fontname: "helv"
|
| 17 |
+
});
|
| 18 |
+
const [mappings, setMappings] = useState({
|
| 19 |
+
name_column: "",
|
| 20 |
+
email_column: ""
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
const nextStep = () => setStep(step + 1);
|
| 24 |
+
const prevStep = () => setStep(step - 1);
|
| 25 |
+
|
| 26 |
+
const steps = [
|
| 27 |
+
{ id: 1, title: "Upload" },
|
| 28 |
+
{ id: 2, title: "Design" },
|
| 29 |
+
{ id: 3, title: "Send" }
|
| 30 |
+
];
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
| 34 |
+
{/* Header */}
|
| 35 |
+
<div className="bg-white border-b border-gray-200 shadow-sm">
|
| 36 |
+
<div className="max-w-6xl mx-auto px-6 py-6">
|
| 37 |
+
<div className="flex items-center justify-between">
|
| 38 |
+
<div>
|
| 39 |
+
<h1 className="text-3xl font-bold text-gray-900">Certificate Manager</h1>
|
| 40 |
+
<p className="text-gray-600 mt-1">Professional certificate generation and distribution</p>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="text-right">
|
| 43 |
+
<div className="text-sm text-gray-500">Step {step} of 3</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div className="max-w-6xl mx-auto px-6 py-8">
|
| 50 |
+
{/* Progress Steps */}
|
| 51 |
+
<div className="mb-10">
|
| 52 |
+
<div className="flex items-center justify-between">
|
| 53 |
+
{steps.map((s, index) => (
|
| 54 |
+
<React.Fragment key={s.id}>
|
| 55 |
+
<div className="flex flex-col items-center flex-1">
|
| 56 |
+
<motion.div
|
| 57 |
+
initial={false}
|
| 58 |
+
animate={{
|
| 59 |
+
scale: step === s.id ? 1.1 : 1
|
| 60 |
+
}}
|
| 61 |
+
className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-lg transition-all duration-300 ${step > s.id
|
| 62 |
+
? 'bg-green-500 text-white shadow-lg'
|
| 63 |
+
: step === s.id
|
| 64 |
+
? 'bg-blue-600 text-white shadow-lg shadow-blue-200'
|
| 65 |
+
: 'bg-gray-200 text-gray-500'
|
| 66 |
+
}`}
|
| 67 |
+
>
|
| 68 |
+
{step > s.id ? <FaCheckCircle className="text-2xl" /> : s.id}
|
| 69 |
+
</motion.div>
|
| 70 |
+
<div className={`mt-2 text-sm font-medium ${step >= s.id ? 'text-gray-900' : 'text-gray-500'
|
| 71 |
+
}`}>
|
| 72 |
+
{s.title}
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
{index < steps.length - 1 && (
|
| 76 |
+
<div className="flex-1 h-1 mx-4 mt-[-20px]">
|
| 77 |
+
<div className={`h-full rounded transition-all duration-500 ${step > s.id ? 'bg-green-500' : 'bg-gray-200'
|
| 78 |
+
}`}></div>
|
| 79 |
+
</div>
|
| 80 |
+
)}
|
| 81 |
+
</React.Fragment>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{/* Main Content */}
|
| 87 |
+
<motion.div
|
| 88 |
+
layout
|
| 89 |
+
className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
| 90 |
+
>
|
| 91 |
+
<div className="p-10">
|
| 92 |
+
<AnimatePresence mode="wait">
|
| 93 |
+
<motion.div
|
| 94 |
+
key={step}
|
| 95 |
+
initial={{ opacity: 0, y: 10 }}
|
| 96 |
+
animate={{ opacity: 1, y: 0 }}
|
| 97 |
+
exit={{ opacity: 0, y: -10 }}
|
| 98 |
+
transition={{ duration: 0.2 }}
|
| 99 |
+
>
|
| 100 |
+
{step === 1 && (
|
| 101 |
+
<UploadStep
|
| 102 |
+
onUploadSuccess={(data) => {
|
| 103 |
+
setFileData(data);
|
| 104 |
+
nextStep();
|
| 105 |
+
}}
|
| 106 |
+
/>
|
| 107 |
+
)}
|
| 108 |
+
|
| 109 |
+
{step === 2 && (
|
| 110 |
+
<DesignStep
|
| 111 |
+
fileData={fileData}
|
| 112 |
+
designParams={designParams}
|
| 113 |
+
setDesignParams={setDesignParams}
|
| 114 |
+
mappings={mappings}
|
| 115 |
+
setMappings={setMappings}
|
| 116 |
+
onNext={nextStep}
|
| 117 |
+
onBack={prevStep}
|
| 118 |
+
/>
|
| 119 |
+
)}
|
| 120 |
+
|
| 121 |
+
{step === 3 && (
|
| 122 |
+
<ReviewSendStep
|
| 123 |
+
fileData={fileData}
|
| 124 |
+
designParams={designParams}
|
| 125 |
+
mappings={mappings}
|
| 126 |
+
onBack={prevStep}
|
| 127 |
+
/>
|
| 128 |
+
)}
|
| 129 |
+
</motion.div>
|
| 130 |
+
</AnimatePresence>
|
| 131 |
+
</div>
|
| 132 |
+
</motion.div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export default App;
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/DesignStep.jsx
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import axios from 'axios';
|
| 3 |
+
import { motion } from 'framer-motion';
|
| 4 |
+
import { FaPalette, FaFont, FaArrowsAlt, FaChevronLeft, FaChevronRight, FaMousePointer } from 'react-icons/fa';
|
| 5 |
+
|
| 6 |
+
const DesignStep = ({ fileData, designParams, setDesignParams, mappings, setMappings, onNext, onBack }) => {
|
| 7 |
+
const [previewImage, setPreviewImage] = useState(null);
|
| 8 |
+
const [loadingPreview, setLoadingPreview] = useState(false);
|
| 9 |
+
const [previewName, setPreviewName] = useState("Sample Name");
|
| 10 |
+
const [pdfSize, setPdfSize] = useState({ width: 595, height: 842 });
|
| 11 |
+
const imageRef = useRef(null);
|
| 12 |
+
|
| 13 |
+
// Fetch clean preview once on mount
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
fetchCleanPreview();
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
const fetchCleanPreview = async () => {
|
| 19 |
+
setLoadingPreview(true);
|
| 20 |
+
const formData = new FormData();
|
| 21 |
+
formData.append("name", " "); // Space for clean background (avoids empty string issues)
|
| 22 |
+
formData.append("name_color", "#000000");
|
| 23 |
+
formData.append("font_size", "60");
|
| 24 |
+
formData.append("fontname", "helv");
|
| 25 |
+
|
| 26 |
+
try {
|
| 27 |
+
const response = await axios.post("http://localhost:8000/api/certificates/preview", formData, {
|
| 28 |
+
responseType: 'blob'
|
| 29 |
+
});
|
| 30 |
+
const imageUrl = URL.createObjectURL(response.data);
|
| 31 |
+
setPreviewImage(imageUrl);
|
| 32 |
+
} catch (error) {
|
| 33 |
+
console.error("Error fetching preview:", error);
|
| 34 |
+
if (error.response) {
|
| 35 |
+
console.error("Response data:", error.response.data);
|
| 36 |
+
console.error("Response status:", error.response.status);
|
| 37 |
+
}
|
| 38 |
+
} finally {
|
| 39 |
+
setLoadingPreview(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const handleImageLoad = (e) => {
|
| 44 |
+
const img = e.target;
|
| 45 |
+
setPdfSize({ width: img.naturalWidth, height: img.naturalHeight });
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const handleImageClick = (e) => {
|
| 49 |
+
if (!imageRef.current) return;
|
| 50 |
+
const rect = imageRef.current.getBoundingClientRect();
|
| 51 |
+
const clickX = e.clientX - rect.left;
|
| 52 |
+
const clickY = e.clientY - rect.top;
|
| 53 |
+
const scaleX = pdfSize.width / rect.width;
|
| 54 |
+
const scaleY = pdfSize.height / rect.height;
|
| 55 |
+
const pdfX = clickX * scaleX;
|
| 56 |
+
const pdfY = clickY * scaleY;
|
| 57 |
+
setDesignParams({ ...designParams, x: Math.round(pdfX), y: Math.round(pdfY) });
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const handleKeyboardMove = (e) => {
|
| 61 |
+
if (!designParams.x && !designParams.y) return;
|
| 62 |
+
const stepX = e.shiftKey ? 60 : 30;
|
| 63 |
+
const stepY = e.shiftKey ? 40 : 20;
|
| 64 |
+
let newX = designParams.x || pdfSize.width / 2;
|
| 65 |
+
let newY = designParams.y || pdfSize.height / 2;
|
| 66 |
+
|
| 67 |
+
switch (e.key) {
|
| 68 |
+
case 'ArrowLeft': newX -= stepX; break;
|
| 69 |
+
case 'ArrowRight': newX += stepX; break;
|
| 70 |
+
case 'ArrowUp': newY -= stepY; break;
|
| 71 |
+
case 'ArrowDown': newY += stepY; break;
|
| 72 |
+
default: return;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
e.preventDefault();
|
| 76 |
+
setDesignParams({ ...designParams, x: Math.round(newX), y: Math.round(newY) });
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const InputGroup = ({ label, icon: Icon, children }) => (
|
| 80 |
+
<div className="bg-white border border-gray-200 p-5 rounded-lg shadow-sm">
|
| 81 |
+
<div className="flex items-center space-x-2 mb-4 text-blue-600">
|
| 82 |
+
<Icon className="text-lg" />
|
| 83 |
+
<span className="font-semibold text-sm uppercase tracking-wide">{label}</span>
|
| 84 |
+
</div>
|
| 85 |
+
{children}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
return (
|
| 90 |
+
<div className="flex flex-col lg:flex-row gap-8">
|
| 91 |
+
<motion.div
|
| 92 |
+
initial={{ opacity: 0, x: -20 }}
|
| 93 |
+
animate={{ opacity: 1, x: 0 }}
|
| 94 |
+
className="w-full lg:w-1/3 space-y-6"
|
| 95 |
+
>
|
| 96 |
+
<div className="mb-6">
|
| 97 |
+
<h2 className="text-2xl font-bold text-gray-900">Customize Design</h2>
|
| 98 |
+
<p className="text-gray-600 text-sm mt-1">Fine-tune the appearance of your certificates.</p>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<InputGroup label="Data Mapping" icon={FaFont}>
|
| 102 |
+
<div className="space-y-3">
|
| 103 |
+
<div>
|
| 104 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Name Column</label>
|
| 105 |
+
<select
|
| 106 |
+
className="w-full border border-gray-300 rounded-lg p-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white"
|
| 107 |
+
value={mappings.name_column}
|
| 108 |
+
onChange={(e) => setMappings({ ...mappings, name_column: e.target.value })}
|
| 109 |
+
>
|
| 110 |
+
<option value="">Select Column</option>
|
| 111 |
+
{fileData.columns.map(col => <option key={col} value={col}>{col}</option>)}
|
| 112 |
+
</select>
|
| 113 |
+
</div>
|
| 114 |
+
<div>
|
| 115 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Email Column</label>
|
| 116 |
+
<select
|
| 117 |
+
className="w-full border border-gray-300 rounded-lg p-2.5 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white"
|
| 118 |
+
value={mappings.email_column}
|
| 119 |
+
onChange={(e) => setMappings({ ...mappings, email_column: e.target.value })}
|
| 120 |
+
>
|
| 121 |
+
<option value="">Select Column</option>
|
| 122 |
+
{fileData.columns.map(col => <option key={col} value={col}>{col}</option>)}
|
| 123 |
+
</select>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</InputGroup>
|
| 127 |
+
|
| 128 |
+
<InputGroup label="Typography & Color" icon={FaPalette}>
|
| 129 |
+
<div className="space-y-4">
|
| 130 |
+
<div>
|
| 131 |
+
<div className="flex justify-between text-xs font-medium text-gray-700 mb-1">
|
| 132 |
+
<span>Font Size</span>
|
| 133 |
+
<span className="text-blue-600">{designParams.font_size}px</span>
|
| 134 |
+
</div>
|
| 135 |
+
<input
|
| 136 |
+
type="range" min="10" max="200"
|
| 137 |
+
value={designParams.font_size}
|
| 138 |
+
onChange={(e) => setDesignParams({ ...designParams, font_size: parseInt(e.target.value) })}
|
| 139 |
+
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
| 140 |
+
/>
|
| 141 |
+
</div>
|
| 142 |
+
<div>
|
| 143 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Text Color</label>
|
| 144 |
+
<div className="flex items-center space-x-3">
|
| 145 |
+
<input
|
| 146 |
+
type="color"
|
| 147 |
+
value={designParams.name_color}
|
| 148 |
+
onChange={(e) => setDesignParams({ ...designParams, name_color: e.target.value })}
|
| 149 |
+
className="w-10 h-10 rounded-lg cursor-pointer border border-gray-300"
|
| 150 |
+
/>
|
| 151 |
+
<span className="text-gray-700 font-mono text-sm">{designParams.name_color}</span>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</InputGroup>
|
| 156 |
+
|
| 157 |
+
<InputGroup label="Positioning" icon={FaArrowsAlt}>
|
| 158 |
+
<div className="space-y-3">
|
| 159 |
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
| 160 |
+
<div className="flex items-start space-x-2">
|
| 161 |
+
<FaMousePointer className="text-blue-600 mt-0.5 flex-shrink-0" />
|
| 162 |
+
<div className="text-xs text-blue-700">
|
| 163 |
+
<p className="font-semibold mb-1">Real-Time Positioning</p>
|
| 164 |
+
<ul className="space-y-1">
|
| 165 |
+
<li>• Click preview to set position</li>
|
| 166 |
+
<li>• Arrow ←→: 30px, ↑↓: 20px (Shift: 2x)</li>
|
| 167 |
+
<li>• Drag sliders for smooth control</li>
|
| 168 |
+
</ul>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div className="space-y-3">
|
| 174 |
+
<div>
|
| 175 |
+
<div className="flex justify-between text-xs font-medium text-gray-700 mb-1">
|
| 176 |
+
<label>X Position</label>
|
| 177 |
+
<span className="text-blue-600">{designParams.x || 'Auto'}</span>
|
| 178 |
+
</div>
|
| 179 |
+
<input
|
| 180 |
+
type="range"
|
| 181 |
+
min="0"
|
| 182 |
+
max={pdfSize.width}
|
| 183 |
+
value={designParams.x || pdfSize.width / 2}
|
| 184 |
+
onChange={(e) => setDesignParams({ ...designParams, x: parseInt(e.target.value) })}
|
| 185 |
+
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
| 186 |
+
/>
|
| 187 |
+
</div>
|
| 188 |
+
<div>
|
| 189 |
+
<div className="flex justify-between text-xs font-medium text-gray-700 mb-1">
|
| 190 |
+
<label>Y Position</label>
|
| 191 |
+
<span className="text-blue-600">{designParams.y || 'Auto'}</span>
|
| 192 |
+
</div>
|
| 193 |
+
<input
|
| 194 |
+
type="range"
|
| 195 |
+
min="0"
|
| 196 |
+
max={pdfSize.height}
|
| 197 |
+
value={designParams.y || pdfSize.height / 2}
|
| 198 |
+
onChange={(e) => setDesignParams({ ...designParams, y: parseInt(e.target.value) })}
|
| 199 |
+
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
| 200 |
+
/>
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<button
|
| 205 |
+
onClick={() => setDesignParams({ ...designParams, x: null, y: null })}
|
| 206 |
+
className="w-full text-xs text-blue-600 hover:text-blue-700 font-medium mt-2"
|
| 207 |
+
>
|
| 208 |
+
Reset to Auto Center
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
</InputGroup>
|
| 212 |
+
|
| 213 |
+
<div className="flex space-x-3 pt-4">
|
| 214 |
+
<button
|
| 215 |
+
onClick={onBack}
|
| 216 |
+
className="flex-1 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors flex items-center justify-center space-x-2 font-medium"
|
| 217 |
+
>
|
| 218 |
+
<FaChevronLeft className="text-xs" />
|
| 219 |
+
<span>Back</span>
|
| 220 |
+
</button>
|
| 221 |
+
<button
|
| 222 |
+
onClick={onNext}
|
| 223 |
+
disabled={!mappings.name_column || !mappings.email_column}
|
| 224 |
+
className={`flex-1 py-3 rounded-lg text-white font-semibold shadow-md flex items-center justify-center space-x-2 transition-all ${!mappings.name_column || !mappings.email_column
|
| 225 |
+
? 'bg-gray-300 cursor-not-allowed text-gray-500'
|
| 226 |
+
: 'bg-blue-600 hover:bg-blue-700 hover:shadow-lg'
|
| 227 |
+
}`}
|
| 228 |
+
>
|
| 229 |
+
<span>Next Step</span>
|
| 230 |
+
<FaChevronRight className="text-xs" />
|
| 231 |
+
</button>
|
| 232 |
+
</div>
|
| 233 |
+
</motion.div>
|
| 234 |
+
|
| 235 |
+
<motion.div
|
| 236 |
+
initial={{ opacity: 0, scale: 0.98 }}
|
| 237 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 238 |
+
className="w-full lg:w-2/3 bg-gray-50 rounded-lg border border-gray-200 p-6 flex items-center justify-center min-h-[600px]"
|
| 239 |
+
tabIndex={0}
|
| 240 |
+
onKeyDown={handleKeyboardMove}
|
| 241 |
+
>
|
| 242 |
+
{loadingPreview ? (
|
| 243 |
+
<div className="flex flex-col items-center space-y-4">
|
| 244 |
+
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
| 245 |
+
<p className="text-gray-600 font-medium">Loading Preview...</p>
|
| 246 |
+
</div>
|
| 247 |
+
) : previewImage ? (
|
| 248 |
+
<div className="relative">
|
| 249 |
+
<img
|
| 250 |
+
ref={imageRef}
|
| 251 |
+
src={previewImage}
|
| 252 |
+
alt="Certificate Preview"
|
| 253 |
+
className="max-w-full max-h-full shadow-xl rounded border border-gray-300 cursor-crosshair"
|
| 254 |
+
onClick={handleImageClick}
|
| 255 |
+
onLoad={handleImageLoad}
|
| 256 |
+
/>
|
| 257 |
+
|
| 258 |
+
{/* Real-time Frontend Text Overlay */}
|
| 259 |
+
{designParams.x !== null && designParams.y !== null && imageRef.current && (
|
| 260 |
+
<div
|
| 261 |
+
className="absolute pointer-events-none flex items-center justify-center"
|
| 262 |
+
style={{
|
| 263 |
+
left: `${(designParams.x / pdfSize.width) * 100}%`,
|
| 264 |
+
top: `${(designParams.y / pdfSize.height) * 100}%`,
|
| 265 |
+
transform: 'translate(-50%, -50%)',
|
| 266 |
+
fontSize: `${(designParams.font_size / pdfSize.width) * imageRef.current.offsetWidth}px`,
|
| 267 |
+
color: designParams.name_color,
|
| 268 |
+
fontFamily: 'Helvetica, Arial, sans-serif', // Approximate PDF font
|
| 269 |
+
fontWeight: 'bold',
|
| 270 |
+
whiteSpace: 'nowrap',
|
| 271 |
+
lineHeight: 1,
|
| 272 |
+
// No transition for instant responsiveness
|
| 273 |
+
}}
|
| 274 |
+
>
|
| 275 |
+
{previewName}
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
|
| 279 |
+
{designParams.x !== null && designParams.y !== null && (
|
| 280 |
+
<div className="absolute top-2 right-2 bg-black/70 text-white text-xs px-3 py-1 rounded">
|
| 281 |
+
Position: {Math.round(designParams.x)}, {Math.round(designParams.y)}
|
| 282 |
+
</div>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
) : (
|
| 286 |
+
<div className="text-gray-400 text-center">
|
| 287 |
+
<p className="text-lg font-medium">Preview Area</p>
|
| 288 |
+
<p className="text-sm">Upload files to see a preview</p>
|
| 289 |
+
</div>
|
| 290 |
+
)}
|
| 291 |
+
</motion.div>
|
| 292 |
+
</div>
|
| 293 |
+
);
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
export default DesignStep;
|
frontend/src/components/ReviewSendStep.jsx
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import axios from 'axios';
|
| 3 |
+
import { motion } from 'framer-motion';
|
| 4 |
+
import { FaPaperPlane, FaDownload, FaChevronLeft, FaEnvelope, FaKey } from 'react-icons/fa';
|
| 5 |
+
|
| 6 |
+
const ReviewSendStep = ({ fileData, designParams, mappings, onBack }) => {
|
| 7 |
+
const [eventName, setEventName] = useState("");
|
| 8 |
+
const [eventDate, setEventDate] = useState("");
|
| 9 |
+
const [clientCompany, setClientCompany] = useState("");
|
| 10 |
+
const [subject, setSubject] = useState("Your Attendance Certificate - {event_name}_{event_date}");
|
| 11 |
+
const [body, setBody] = useState("Dear Dr {first_name},\n\nThank you for attending {event_name}.\n\nYour certificate is attached.\n\nBest Regards,\n\nVolaris Team on behalf of {client_company}");
|
| 12 |
+
const [fromEmail, setFromEmail] = useState("");
|
| 13 |
+
const [sending, setSending] = useState(false);
|
| 14 |
+
const [status, setStatus] = useState(null);
|
| 15 |
+
const [progress, setProgress] = useState(null);
|
| 16 |
+
const [jobId, setJobId] = useState(null);
|
| 17 |
+
|
| 18 |
+
// Fetch email configuration on mount
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
const fetchEmailConfig = async () => {
|
| 21 |
+
try {
|
| 22 |
+
const response = await axios.get("http://localhost:8000/api/email/config");
|
| 23 |
+
setFromEmail(response.data.from_email);
|
| 24 |
+
} catch (error) {
|
| 25 |
+
console.error("Failed to fetch email config:", error);
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
fetchEmailConfig();
|
| 29 |
+
}, []);
|
| 30 |
+
|
| 31 |
+
const handleSend = async () => {
|
| 32 |
+
if (!eventName || !eventDate || !clientCompany) {
|
| 33 |
+
alert("Please fill in all required fields: Event Name, Event Date, and Client Company");
|
| 34 |
+
return;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
setSending(true);
|
| 38 |
+
const formData = new FormData();
|
| 39 |
+
formData.append("subject", subject);
|
| 40 |
+
formData.append("body", body);
|
| 41 |
+
formData.append("name_column", mappings.name_column);
|
| 42 |
+
formData.append("email_column", mappings.email_column);
|
| 43 |
+
formData.append("event_name", eventName);
|
| 44 |
+
formData.append("event_date", eventDate);
|
| 45 |
+
formData.append("client_company", clientCompany);
|
| 46 |
+
|
| 47 |
+
formData.append("name_color", designParams.name_color);
|
| 48 |
+
formData.append("font_size", designParams.font_size);
|
| 49 |
+
formData.append("fontname", designParams.fontname);
|
| 50 |
+
if (designParams.x !== null) formData.append("x", designParams.x);
|
| 51 |
+
if (designParams.y !== null) formData.append("y", designParams.y);
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const response = await axios.post("http://localhost:8000/api/email/send", formData);
|
| 55 |
+
const newJobId = response.data.job_id;
|
| 56 |
+
setJobId(newJobId);
|
| 57 |
+
setProgress({ sent: 0, total: fileData.total_rows, failed: 0 });
|
| 58 |
+
|
| 59 |
+
// Start polling for progress
|
| 60 |
+
const pollInterval = setInterval(async () => {
|
| 61 |
+
try {
|
| 62 |
+
const progressResponse = await axios.get(`http://localhost:8000/api/email/progress/${newJobId}`);
|
| 63 |
+
const progressData = progressResponse.data;
|
| 64 |
+
setProgress(progressData);
|
| 65 |
+
|
| 66 |
+
// Stop polling when all emails are sent
|
| 67 |
+
if (progressData.sent >= progressData.total) {
|
| 68 |
+
clearInterval(pollInterval);
|
| 69 |
+
setSending(false);
|
| 70 |
+
setStatus({ type: 'success', msg: `Successfully sent ${progressData.sent - progressData.failed} emails! ${progressData.failed > 0 ? `(${progressData.failed} failed)` : ''}` });
|
| 71 |
+
setProgress(null);
|
| 72 |
+
}
|
| 73 |
+
} catch (pollError) {
|
| 74 |
+
console.error("Progress polling error:", pollError);
|
| 75 |
+
}
|
| 76 |
+
}, 500); // Poll every 500ms
|
| 77 |
+
|
| 78 |
+
} catch (error) {
|
| 79 |
+
setStatus({ type: 'error', msg: "Failed to start sending process." });
|
| 80 |
+
console.error(error);
|
| 81 |
+
setSending(false);
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const handleDownload = async () => {
|
| 86 |
+
const formData = new FormData();
|
| 87 |
+
formData.append("name_column", mappings.name_column);
|
| 88 |
+
formData.append("email_column", mappings.email_column);
|
| 89 |
+
formData.append("name_color", designParams.name_color);
|
| 90 |
+
formData.append("font_size", designParams.font_size);
|
| 91 |
+
formData.append("fontname", designParams.fontname);
|
| 92 |
+
if (designParams.x !== null) formData.append("x", designParams.x);
|
| 93 |
+
if (designParams.y !== null) formData.append("y", designParams.y);
|
| 94 |
+
|
| 95 |
+
try {
|
| 96 |
+
const response = await axios.post("http://localhost:8000/api/certificates/generate", formData, {
|
| 97 |
+
responseType: 'blob'
|
| 98 |
+
});
|
| 99 |
+
const url = window.URL.createObjectURL(new Blob([response.data]));
|
| 100 |
+
const link = document.createElement('a');
|
| 101 |
+
link.href = url;
|
| 102 |
+
link.setAttribute('download', 'certificates.zip');
|
| 103 |
+
document.body.appendChild(link);
|
| 104 |
+
link.click();
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error("Download failed", error);
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div className="max-w-5xl mx-auto">
|
| 112 |
+
<div className="text-center mb-8">
|
| 113 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Send</h2>
|
| 114 |
+
<p className="text-gray-600">Configure your email settings and launch the campaign.</p>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 118 |
+
{/* Email Configuration */}
|
| 119 |
+
<div className="space-y-6">
|
| 120 |
+
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm">
|
| 121 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 122 |
+
Event Details
|
| 123 |
+
</h3>
|
| 124 |
+
|
| 125 |
+
<div className="space-y-4">
|
| 126 |
+
<div>
|
| 127 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Event Name *</label>
|
| 128 |
+
<input
|
| 129 |
+
type="text"
|
| 130 |
+
className="w-full border border-gray-300 rounded-lg p-3 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 131 |
+
placeholder="e.g., Annual Medical Conference"
|
| 132 |
+
value={eventName}
|
| 133 |
+
onChange={(e) => setEventName(e.target.value)}
|
| 134 |
+
/>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div>
|
| 138 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Event Date *</label>
|
| 139 |
+
<input
|
| 140 |
+
type="text"
|
| 141 |
+
className="w-full border border-gray-300 rounded-lg p-3 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 142 |
+
placeholder="e.g., November 2024"
|
| 143 |
+
value={eventDate}
|
| 144 |
+
onChange={(e) => setEventDate(e.target.value)}
|
| 145 |
+
/>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div>
|
| 149 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Client Company *</label>
|
| 150 |
+
<input
|
| 151 |
+
type="text"
|
| 152 |
+
className="w-full border border-gray-300 rounded-lg p-3 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 153 |
+
placeholder="e.g., Pharmaceutical Corp"
|
| 154 |
+
value={clientCompany}
|
| 155 |
+
onChange={(e) => setClientCompany(e.target.value)}
|
| 156 |
+
/>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm">
|
| 162 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 163 |
+
<FaEnvelope className="mr-2 text-blue-600" /> Email Configuration
|
| 164 |
+
</h3>
|
| 165 |
+
|
| 166 |
+
<div className="space-y-4">
|
| 167 |
+
<div>
|
| 168 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Sender Email</label>
|
| 169 |
+
<div className="w-full border border-gray-200 rounded-lg p-3 bg-gray-50 text-gray-700 font-medium">
|
| 170 |
+
{fromEmail || 'Loading...'}
|
| 171 |
+
</div>
|
| 172 |
+
<p className="text-xs text-gray-500 mt-1">Configured in environment settings</p>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm">
|
| 178 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
| 179 |
+
<FaEnvelope className="mr-2 text-blue-600" /> Email Content
|
| 180 |
+
</h3>
|
| 181 |
+
|
| 182 |
+
<div className="space-y-4">
|
| 183 |
+
<div>
|
| 184 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Subject Line</label>
|
| 185 |
+
<input
|
| 186 |
+
type="text"
|
| 187 |
+
className="w-full border border-gray-300 rounded-lg p-3 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
|
| 188 |
+
value={subject}
|
| 189 |
+
onChange={(e) => setSubject(e.target.value)}
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<div>
|
| 194 |
+
<label className="block text-xs font-medium text-gray-700 mb-1">Email Body</label>
|
| 195 |
+
<textarea
|
| 196 |
+
rows={6}
|
| 197 |
+
className="w-full border border-gray-300 rounded-lg p-3 text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm"
|
| 198 |
+
value={body}
|
| 199 |
+
onChange={(e) => setBody(e.target.value)}
|
| 200 |
+
/>
|
| 201 |
+
<p className="text-xs text-gray-500 mt-2">
|
| 202 |
+
Variables: <span className="text-blue-600 font-mono">{'{first_name}'}</span>, <span className="text-blue-600 font-mono">{'{name}'}</span>, <span className="text-blue-600 font-mono">{'{event_name}'}</span>, <span className="text-blue-600 font-mono">{'{event_date}'}</span>, <span className="text-blue-600 font-mono">{'{client_company}'}</span>
|
| 203 |
+
</p>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
{/* Summary & Actions */}
|
| 210 |
+
<div className="space-y-6">
|
| 211 |
+
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 p-6 rounded-lg">
|
| 212 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-4">Campaign Summary</h3>
|
| 213 |
+
<ul className="space-y-3 text-sm text-gray-700">
|
| 214 |
+
<li className="flex justify-between border-b border-blue-200 pb-2">
|
| 215 |
+
<span>Total Recipients</span>
|
| 216 |
+
<span className="font-bold text-gray-900">{fileData.total_rows}</span>
|
| 217 |
+
</li>
|
| 218 |
+
<li className="flex justify-between border-b border-blue-200 pb-2">
|
| 219 |
+
<span>Name Column</span>
|
| 220 |
+
<span className="font-mono text-blue-700">{mappings.name_column}</span>
|
| 221 |
+
</li>
|
| 222 |
+
<li className="flex justify-between border-b border-blue-200 pb-2">
|
| 223 |
+
<span>Email Column</span>
|
| 224 |
+
<span className="font-mono text-blue-700">{mappings.email_column}</span>
|
| 225 |
+
</li>
|
| 226 |
+
</ul>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<div className="space-y-4">
|
| 230 |
+
<motion.button
|
| 231 |
+
whileHover={{ scale: 1.02 }}
|
| 232 |
+
whileTap={{ scale: 0.98 }}
|
| 233 |
+
onClick={handleDownload}
|
| 234 |
+
className="w-full py-4 px-6 border border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50 font-medium flex items-center justify-center space-x-2 transition-colors"
|
| 235 |
+
>
|
| 236 |
+
<FaDownload />
|
| 237 |
+
<span>Download All Certificates (ZIP)</span>
|
| 238 |
+
</motion.button>
|
| 239 |
+
|
| 240 |
+
<motion.button
|
| 241 |
+
whileHover={{ scale: 1.02 }}
|
| 242 |
+
whileTap={{ scale: 0.98 }}
|
| 243 |
+
onClick={handleSend}
|
| 244 |
+
disabled={sending}
|
| 245 |
+
className={`w-full py-4 px-6 rounded-lg text-white font-bold shadow-md flex items-center justify-center space-x-2 transition-all ${sending
|
| 246 |
+
? 'bg-gray-400 cursor-not-allowed'
|
| 247 |
+
: 'bg-green-600 hover:bg-green-700 hover:shadow-lg'
|
| 248 |
+
}`}
|
| 249 |
+
>
|
| 250 |
+
{sending ? (
|
| 251 |
+
<span>Sending Campaign...</span>
|
| 252 |
+
) : (
|
| 253 |
+
<>
|
| 254 |
+
<FaPaperPlane />
|
| 255 |
+
<span>Send Certificates Now</span>
|
| 256 |
+
</>
|
| 257 |
+
)}
|
| 258 |
+
</motion.button>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
{progress && (
|
| 262 |
+
<motion.div
|
| 263 |
+
initial={{ opacity: 0, y: 10 }}
|
| 264 |
+
animate={{ opacity: 1, y: 0 }}
|
| 265 |
+
className="bg-white border border-blue-200 p-6 rounded-lg shadow-sm"
|
| 266 |
+
>
|
| 267 |
+
<h4 className="text-sm font-semibold text-gray-900 mb-3">Sending Progress</h4>
|
| 268 |
+
<div className="space-y-3">
|
| 269 |
+
<div className="flex justify-between text-sm text-gray-700 mb-2">
|
| 270 |
+
<span className="font-medium">{progress.sent} / {progress.total} emails sent</span>
|
| 271 |
+
<span className="text-blue-600 font-bold">{Math.round((progress.sent / progress.total) * 100)}%</span>
|
| 272 |
+
</div>
|
| 273 |
+
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
| 274 |
+
<motion.div
|
| 275 |
+
className="bg-gradient-to-r from-blue-500 to-green-500 h-full rounded-full"
|
| 276 |
+
initial={{ width: 0 }}
|
| 277 |
+
animate={{ width: `${(progress.sent / progress.total) * 100}%` }}
|
| 278 |
+
transition={{ duration: 0.3 }}
|
| 279 |
+
/>
|
| 280 |
+
</div>
|
| 281 |
+
{progress.failed > 0 && (
|
| 282 |
+
<p className="text-xs text-red-600">⚠️ {progress.failed} failed</p>
|
| 283 |
+
)}
|
| 284 |
+
</div>
|
| 285 |
+
</motion.div>
|
| 286 |
+
)}
|
| 287 |
+
|
| 288 |
+
{status && (
|
| 289 |
+
<motion.div
|
| 290 |
+
initial={{ opacity: 0, y: 10 }}
|
| 291 |
+
animate={{ opacity: 1, y: 0 }}
|
| 292 |
+
className={`p-4 rounded-lg border text-center ${status.type === 'success'
|
| 293 |
+
? 'bg-green-50 border-green-200 text-green-700'
|
| 294 |
+
: 'bg-red-50 border-red-200 text-red-700'
|
| 295 |
+
}`}
|
| 296 |
+
>
|
| 297 |
+
{status.msg}
|
| 298 |
+
</motion.div>
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<div className="mt-8">
|
| 304 |
+
<button
|
| 305 |
+
onClick={onBack}
|
| 306 |
+
className="text-gray-600 hover:text-gray-900 flex items-center space-x-2 transition-colors font-medium"
|
| 307 |
+
>
|
| 308 |
+
<FaChevronLeft />
|
| 309 |
+
<span>Back to Design</span>
|
| 310 |
+
</button>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
);
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
export default ReviewSendStep;
|
frontend/src/components/UploadStep.jsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import axios from 'axios';
|
| 3 |
+
import { FaCloudUploadAlt, FaFileExcel, FaFilePdf, FaSpinner } from 'react-icons/fa';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
|
| 6 |
+
const UploadStep = ({ onUploadSuccess }) => {
|
| 7 |
+
const [excelFile, setExcelFile] = useState(null);
|
| 8 |
+
const [pdfFile, setPdfFile] = useState(null);
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
const [error, setError] = useState(null);
|
| 11 |
+
|
| 12 |
+
const handleUpload = async () => {
|
| 13 |
+
if (!excelFile || !pdfFile) {
|
| 14 |
+
setError("Please select both files.");
|
| 15 |
+
return;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
setLoading(true);
|
| 19 |
+
setError(null);
|
| 20 |
+
|
| 21 |
+
const formData = new FormData();
|
| 22 |
+
formData.append("excel_file", excelFile);
|
| 23 |
+
formData.append("pdf_file", pdfFile);
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const response = await axios.post("http://localhost:8000/api/certificates/upload", formData, {
|
| 27 |
+
headers: { "Content-Type": "multipart/form-data" }
|
| 28 |
+
});
|
| 29 |
+
onUploadSuccess(response.data);
|
| 30 |
+
} catch (err) {
|
| 31 |
+
setError("Upload failed. Please try again.");
|
| 32 |
+
console.error(err);
|
| 33 |
+
} finally {
|
| 34 |
+
setLoading(false);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const FileInput = ({ accept, label, icon: Icon, file, setFile }) => (
|
| 39 |
+
<motion.div
|
| 40 |
+
whileHover={{ y: -4 }}
|
| 41 |
+
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer ${file ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white hover:border-blue-400 hover:bg-gray-50'
|
| 42 |
+
}`}
|
| 43 |
+
>
|
| 44 |
+
<input
|
| 45 |
+
type="file"
|
| 46 |
+
accept={accept}
|
| 47 |
+
onChange={(e) => setFile(e.target.files[0])}
|
| 48 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
| 49 |
+
/>
|
| 50 |
+
<div className="flex flex-col items-center pointer-events-none">
|
| 51 |
+
<div className={`w-16 h-16 rounded-full flex items-center justify-center mb-4 ${file ? 'bg-blue-100' : 'bg-gray-100'
|
| 52 |
+
}`}>
|
| 53 |
+
<Icon className={`text-3xl ${file ? 'text-blue-600' : 'text-gray-400'}`} />
|
| 54 |
+
</div>
|
| 55 |
+
<h3 className="text-base font-semibold text-gray-900 mb-1">{label}</h3>
|
| 56 |
+
<p className="text-sm text-gray-500">
|
| 57 |
+
{file ? (
|
| 58 |
+
<span className="text-blue-600 font-medium">{file.name}</span>
|
| 59 |
+
) : (
|
| 60 |
+
"Click to browse or drag & drop"
|
| 61 |
+
)}
|
| 62 |
+
</p>
|
| 63 |
+
</div>
|
| 64 |
+
</motion.div>
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<div className="max-w-3xl mx-auto">
|
| 69 |
+
<div className="text-center mb-8">
|
| 70 |
+
<h2 className="text-2xl font-bold text-gray-900 mb-2">Upload Files</h2>
|
| 71 |
+
<p className="text-gray-600">Upload your attendee list and certificate template to begin.</p>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
| 75 |
+
<FileInput
|
| 76 |
+
accept=".xlsx"
|
| 77 |
+
label="Attendee Data (.xlsx)"
|
| 78 |
+
icon={FaFileExcel}
|
| 79 |
+
file={excelFile}
|
| 80 |
+
setFile={setExcelFile}
|
| 81 |
+
/>
|
| 82 |
+
<FileInput
|
| 83 |
+
accept=".pdf"
|
| 84 |
+
label="Certificate Template (.pdf)"
|
| 85 |
+
icon={FaFilePdf}
|
| 86 |
+
file={pdfFile}
|
| 87 |
+
setFile={setPdfFile}
|
| 88 |
+
/>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{error && (
|
| 92 |
+
<motion.div
|
| 93 |
+
initial={{ opacity: 0, y: -10 }}
|
| 94 |
+
animate={{ opacity: 1, y: 0 }}
|
| 95 |
+
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-center text-sm"
|
| 96 |
+
>
|
| 97 |
+
{error}
|
| 98 |
+
</motion.div>
|
| 99 |
+
)}
|
| 100 |
+
|
| 101 |
+
<div className="flex justify-center">
|
| 102 |
+
<motion.button
|
| 103 |
+
whileHover={{ scale: 1.02 }}
|
| 104 |
+
whileTap={{ scale: 0.98 }}
|
| 105 |
+
onClick={handleUpload}
|
| 106 |
+
disabled={loading}
|
| 107 |
+
className={`flex items-center justify-center space-x-2 py-3 px-8 rounded-lg text-white font-semibold shadow-md transition-all ${loading ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 hover:shadow-lg'
|
| 108 |
+
}`}
|
| 109 |
+
>
|
| 110 |
+
{loading ? (
|
| 111 |
+
<>
|
| 112 |
+
<FaSpinner className="animate-spin" />
|
| 113 |
+
<span>Processing...</span>
|
| 114 |
+
</>
|
| 115 |
+
) : (
|
| 116 |
+
<>
|
| 117 |
+
<FaCloudUploadAlt className="text-xl" />
|
| 118 |
+
<span>Upload & Continue</span>
|
| 119 |
+
</>
|
| 120 |
+
)}
|
| 121 |
+
</motion.button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export default UploadStep;
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
body {
|
| 6 |
+
background-color: #f3f4f6;
|
| 7 |
+
font-family: 'Inter', sans-serif;
|
| 8 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{js,ts,jsx,tsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
sendgrid
|
| 2 |
+
streamlit
|
| 3 |
+
pandas
|
| 4 |
+
openpyxl
|
| 5 |
+
PyMuPDF
|