saifisvibinn commited on
Commit
d19cc77
·
0 Parent(s):

Initial commit: Certificate Generator App with FastAPI and React

Browse files
.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