[33e88f42] Keine Zusammenfassung angegeben.

Keine Zusammenfassung angegeben.
This commit is contained in:
2026-04-10 21:51:12 +00:00
parent c2f614d7ad
commit 5e0186c534
8 changed files with 460 additions and 22 deletions

View File

@@ -1 +1 @@
{"task_id": "32788f42-8544-80e1-a13a-c26114cf9b34", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-08T16:39:28.516327"}
{"task_id": "33e88f42-8544-80a2-8cee-e0b11287c523", "token": "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", "readme_path": "readme.md", "session_start_time": "2026-04-10T21:51:09.122501"}

View File

@@ -1,6 +1,6 @@
# Fotograf.de Scraper & Management UI
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards & Shooting Schedule)
**Status:** Production-Ready Microservice (Core Feature: PDF List Generation, QR Cards, Shooting Schedule & **Gmail API Integration**)
Dieser Service modernisiert die alten `Fotograf.de` Skripte, indem er eine robuste, web-basierte UI zur Verwaltung und Automatisierung von Foto-Aufträgen bereitstellt. Er ist als eigenständiger Microservice konzipiert, der unabhängig vom Haupt-Stack läuft.
@@ -10,13 +10,13 @@ Der Service besteht aus zwei Hauptkomponenten:
1. **Backend (Python / FastAPI / Selenium / SQLAlchemy):**
* **Automatisierung:** Nutzt Selenium für das Scraping von `fotograf.de`.
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind.
* **Persistenz:** Eine SQLite-Datenbank (`fotograf_jobs.db`) speichert die Auftragsliste, sodass langsame Scraping-Vorgänge nur bei Bedarf (Refresh) nötig sind. Speichert außerdem OAuth-Tokens (`GmailToken`) für persistente E-Mail-Sitzungen.
* **PDF-Engine:** Nutzt WeasyPrint für Teilnehmerlisten und ReportLab/PyPDF2 für präzise PDF-Overlays (QR-Karten).
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten.
* **API-Integration:** Direkte Anbindung an die **Calendly API (v2)** zum Abruf von Live-Buchungsdaten sowie an die **Gmail API** für direkten E-Mail-Versand.
2. **Frontend (TypeScript / React / Vite / TailwindCSS):**
* **Modernes UI:** Ein vollständig responsives Dashboard mit Tailwind CSS (Kachel-Layout, Tabs für Kiga/Schule).
* **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung).
* **Arbeitsfluss:** Tools sind direkt in der Detailansicht des jeweiligen Auftrags integriert (Shooting-Planung, E-Mail-Kampagnen).
## ✨ Core Features
@@ -36,16 +36,40 @@ Spezielles Modul für Familien-Mini-Shootings, direkt integriert in die Auftrags
* Generiert eine A4-Tabelle für den Shooting-Tag im 6-Minuten-Takt.
* Füllt Lücken für nicht gebuchte Slots automatisch leer auf.
### Feature 3: Nachfass-E-Mails (Vollständig)
* Identifizierung von Käufern/Nicht-Käufern zur Generierung von CSV-Listen für den Supermailer.
### Feature 3: Nachfass-E-Mails & Gmail Direkt-Versand (Vollständig)
Identifizierung von potenziellen Käufern und automatisierter Kontakt.
* **Analyse-Logik:** Sucht nach Personen mit 0-1 Logins, die noch keine Bilder gekauft haben.
* **Supermailer Export:** Generierung einer fertigen CSV-Liste.
* **Direkter Gmail-Versand (Neu):**
* Volle OAuth 2.0 Integration. Einmaliger Login, das Refresh-Token hält die Sitzung aktiv.
* Inline-Editor für Betreff und HTML-Nachricht mit Live-Platzhaltern (`{Name Käufer}`, `{Kindernamen}`, `{LinksHTML}`).
* Massensendungs-API (Bulk Send) schickt Mails direkt über das verbundene Postfach.
### Feature 4: Verkaufs-Statistiken (Vollständig)
* Detaillierte Analyse des Kaufverhaltens pro Album mit Echtzeit-Fortschrittsanzeige im Browser.
---
## 🎯 Nächste Session: "Freigabeanfragen" (Feature 5)
Das nächste große Ziel ist der automatische Versand von Freigabeanfragen via Gmail.
**Der geplante Workflow (Bestätigt):**
Anstatt Bild für Bild auf `fotograf.de` zu prüfen, nutzen wir die bereits erteilte **Einwilligung aus Calendly**.
* **Logik:** Das System prüft die Calendly-Buchungen des Shootings auf die Frage *"Dürfen wir einige schöne Aufnahmen veröffentlichen?"*.
* Alle Kunden, die hier "Ja" (bzw. eine positive Antwort) angegeben haben, werden extrahiert und erhalten automatisiert über die Gmail-API die Freigabeanfrage.
* *Text & Template werden in der nächsten Sitzung definiert.*
---
## 🛠️ Technische Details & Fixes (April 2026)
* **E-Mail Signatur:** Die offizielle HTML-Signatur von "Kinderfotos Erding" (inkl. Logo via Google Drive Link) ist hartcodiert hinterlegt und wird bei jedem Massen- oder Testversand unsichtbar im Hintergrund an den HTML-Body angehängt.
* **Gmail OAuth Architektur:** Strikt getrennt. App-Credentials (`client_id`, `client_secret`) liegen in der `.env`. User-Credentials (Access & Refresh Tokens) werden dynamisch in der SQLite-Datenbank (`GmailToken`) gespeichert.
* **Routing / Reverse Proxy:**
* Das Frontend (React/Vite) nutzt den `base: '/fotograf-de/'` Pfad, um Assets korrekt hinter dem Nginx-Proxy zu laden.
* API-Aufrufe nutzen relative Pfade (`/fotograf-de-api/`), welche von Nginx sauber zum internen Backend-Port `8000` umgeschrieben (`rewrite`) werden. Dies verhindert Mixed-Content und CORS-Fehler im Produktionsbetrieb.
* **Zeitzonen:** Automatische Konversion von Calendly-UTC-Zeiten in die lokale Zeit (`Europe/Berlin`).
* **Pagination Fix:** Das Backend blättert durch alle Calendly-Seiten für lückenlose Daten.
* **Logo-Integration:** Dynamisches Einbetten des Firmenlogos in alle generierten Dokumente.
## 🚀 Deployment & Konfiguration
@@ -55,8 +79,9 @@ Der Service wird über eine eigene `docker-compose.yml` im Unterverzeichnis gest
Folgende Variablen müssen in der `.env` im Verzeichnis `/fotograf-de-scraper/` definiert sein:
* `KIGA_USER` / `KIGA_PW` / `SCHULE_USER` / `SCHULE_PW`: Logins für Fotograf.de.
* `CALENDLY_TOKEN`: Personal Access Token (JWT) von Calendly.
* `google_fotograf_client_id` / `google_fotograf_secret`: Die OAuth-App-Credentials aus der Google Cloud Console.
* `GOOGLE_REDIRECT_URI`: (Optional) Standard ist `https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback`.
### URLs & Ports
* **Scraper Frontend:** `http://192.168.178.6:3009`
* **Zentrales Dashboard:** `http://192.168.178.6:8090`
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.
* **Produktion / Nginx:** `https://floke-ai.duckdns.org/fotograf-de/`
* **Persistenz:** Datenbank unter `./backend/data/fotograf_jobs.db`.

View File

@@ -22,6 +22,12 @@ class Job(Base):
account_type = Column(String, index=True) # 'kiga' or 'schule'
last_updated = Column(DateTime, default=datetime.datetime.utcnow)
class GmailToken(Base):
__tablename__ = "gmail_tokens"
id = Column(Integer, primary_key=True)
token_json = Column(String) # Stores the full credentials JSON
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
Base.metadata.create_all(bind=engine)
def get_db():

View File

@@ -0,0 +1,129 @@
import os
import json
import logging
import datetime
from typing import Optional, List, Dict, Any
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow
from googleapiclient.discovery import build
from google.auth.transport.requests import Request
from sqlalchemy.orm import Session
from database import GmailToken
import base64
from email.mime.text import MIMEText
logger = logging.getLogger("gmail-service")
# Scopes required for sending emails
SCOPES = ['https://www.googleapis.com/auth/gmail.send']
class GmailService:
def __init__(self, db: Session):
self.db = db
self.client_id = os.getenv("google_fotograf_client_id")
self.client_secret = os.getenv("google_fotograf_secret")
# Redirect URI - must match what was configured in Google Console
# We try to detect the public URL, fallback to duckdns
self.redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "https://floke-ai.duckdns.org/fotograf-de-api/api/auth/callback")
def _get_client_config(self) -> Dict[str, Any]:
return {
"web": {
"client_id": self.client_id,
"project_id": "fotograf-tool",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": self.client_secret,
"redirect_uris": [self.redirect_uri]
}
}
def get_auth_url(self) -> str:
flow = Flow.from_client_config(
self._get_client_config(),
scopes=SCOPES,
redirect_uri=self.redirect_uri
)
auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline')
return auth_url
def handle_callback(self, code: str):
flow = Flow.from_client_config(
self._get_client_config(),
scopes=SCOPES,
redirect_uri=self.redirect_uri
)
flow.fetch_token(code=code)
credentials = flow.credentials
self._save_token(credentials)
return credentials
def _save_token(self, credentials):
token_data = {
'token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_uri': credentials.token_uri,
'client_id': credentials.client_id,
'client_secret': credentials.client_secret,
'scopes': credentials.scopes
}
db_token = self.db.query(GmailToken).first()
if not db_token:
db_token = GmailToken(token_json=json.dumps(token_data))
self.db.add(db_token)
else:
db_token.token_json = json.dumps(token_data)
self.db.commit()
logger.info("Gmail OAuth token saved to database.")
def get_credentials(self) -> Optional[Credentials]:
db_token = self.db.query(GmailToken).first()
if not db_token:
return None
token_data = json.loads(db_token.token_json)
creds = Credentials.from_authorized_user_info(token_data, SCOPES)
if creds and creds.expired and creds.refresh_token:
logger.info("Gmail token expired, refreshing...")
creds.refresh(Request())
self._save_token(creds)
return creds
def is_authenticated(self) -> bool:
try:
creds = self.get_credentials()
return creds is not None and creds.valid
except Exception as e:
logger.error(f"Auth check failed: {e}")
return False
def send_email(self, to: str, subject: str, body_html: str) -> bool:
creds = self.get_credentials()
if not creds:
logger.error("Cannot send email: Not authenticated.")
return False
try:
service = build('gmail', 'v1', credentials=creds)
message = MIMEText(body_html, 'html')
message['to'] = to
message['subject'] = subject
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
send_result = service.users().messages().send(
userId='me',
body={'raw': raw_message}
).execute()
logger.info(f"Email sent to {to}. Message ID: {send_result['id']}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return False

View File

@@ -746,14 +746,16 @@ def process_reminder_analysis(task_id: str, job_id: str, account_type: str):
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from typing import List, Dict, Any, Optional
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db, Job as DBJob, engine, Base
import math
import uuid
from qr_generator import get_calendly_events, overlay_text_on_pdf, get_calendly_event_types
from gmail_service import GmailService
# --- API Endpoints ---
@@ -914,6 +916,31 @@ async def download_latest_file():
async def health_check():
return {"status": "ok"}
# --- Gmail API Endpoints ---
@app.get("/api/auth/google")
async def get_google_auth_url(db: Session = Depends(get_db)):
service = GmailService(db)
return {"url": service.get_auth_url()}
@app.get("/api/auth/callback")
async def google_auth_callback(code: str, db: Session = Depends(get_db)):
service = GmailService(db)
try:
service.handle_callback(code)
# Redirect back to frontend
# The frontend lives at /fotograf-de/ through NGINX
frontend_url = os.getenv("FRONTEND_URL", "https://floke-ai.duckdns.org/fotograf-de/")
return RedirectResponse(url=frontend_url)
except Exception as e:
logger.error(f"Auth callback failed: {e}")
return JSONResponse(status_code=500, content={"message": f"Authentifizierung fehlgeschlagen: {str(e)}"})
@app.get("/api/gmail/status")
async def get_gmail_status(db: Session = Depends(get_db)):
service = GmailService(db)
return {"authenticated": service.is_authenticated()}
@app.get("/api/jobs", response_model=List[Dict[str, Any]])
async def get_jobs(account_type: str, force_refresh: bool = False, db: Session = Depends(get_db)):
logger.info(f"API Request: GET /api/jobs for {account_type} (force_refresh={force_refresh})")
@@ -1034,6 +1061,34 @@ async def download_task_csv(task_id: str):
logger.error(f"Export error: {e}")
raise HTTPException(status_code=500, detail="CSV Export fehlgeschlagen.")
class BulkEmailRequest(BaseModel):
emails: List[Dict[str, str]]
@app.post("/api/gmail/send-bulk")
async def send_bulk_emails(request: BulkEmailRequest, db: Session = Depends(get_db)):
service = GmailService(db)
if not service.is_authenticated():
raise HTTPException(status_code=401, detail="Gmail nicht authentifiziert.")
success_count = 0
failed_emails = []
for email_data in request.emails:
to = email_data.get("to")
subject = email_data.get("subject")
body = email_data.get("body")
if service.send_email(to, subject, body):
success_count += 1
else:
failed_emails.append(to)
return {
"total": len(request.emails),
"success": success_count,
"failed": failed_emails
}
@app.get("/api/jobs/{job_id}/generate-pdf")
async def generate_pdf(job_id: str, account_type: str, db: Session = Depends(get_db)):
logger.info(f"API Request: Generate PDF for job {job_id} ({account_type})")

View File

@@ -12,3 +12,6 @@ requests==2.31.0
reportlab==4.0.9
PyPDF2==3.0.1
tzdata
google-api-python-client==2.122.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0

View File

@@ -42,8 +42,64 @@ function App() {
const [reminderProgress, setReminderProgress] = useState<string>('');
const [isReminderRunning, setIsReminderRunning] = useState(false);
const [latestFile, setLatestFile] = useState<any>(null);
const [isGmailAuthenticated, setIsGmailAuthenticated] = useState(false);
// Email States
const [reminderResult, setReminderResult] = useState<any[] | null>(null);
const [emailSubject, setEmailSubject] = useState("Fotos von {Kindernamen}");
const [emailBody, setEmailBody] = useState("Hallo {Name Käufer},<br><br>deine Fotos sind fertig und warten auf dich! Klicke einfach auf die Links unten, um direkt zu den Galerien zu gelangen:<br><br>{LinksHTML}<br><br>Viel Spaß beim Anschauen!");
const [isSendingEmails, setIsSendingEmails] = useState(false);
const [emailSendStatus, setEmailSendStatus] = useState<string | null>(null);
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002';
// Email Signature (Cleaned up from user input)
const SIGNATURE_HTML = `
<br><br>
<span style="color: #888;">--</span><br>
<div dir="ltr">
<table border="0" cellspacing="0" cellpadding="0" style="border-collapse:collapse; margin-top: 5px;">
<tbody>
<tr>
<td width="220" valign="top" style="padding-right: 15px;">
<img width="200" src="https://lh3.googleusercontent.com/d/1K7RODOqKE2e1nRJ3D4dEWdjthoTMyXUq" alt="Kinderfotos Erding Logo" style="display: block;">
</td>
<td valign="bottom" style="padding-left: 15px; border-left: 1px solid #ddd; font-family: sans-serif; font-size: 13px; color: #333; line-height: 1.5;">
<p style="margin: 0;"><b>Kinderfotos Erding</b> | <a href="http://www.kinderfotos-erding.de/" target="_blank" style="color: #1155cc; text-decoration: none;">www.kinderfotos-erding.de</a></p>
<p style="margin: 0; color: #666;">Gartenstr. 10 | 85445 Oberding | 08122-8470867</p>
</td>
</tr>
</tbody>
</table>
</div>
`;
// If we are on duckdns, use the relative path, otherwise use local IP
const API_BASE_URL = window.location.hostname.includes('duckdns.org')
? '/fotograf-de-api'
: (import.meta.env.VITE_API_BASE_URL || 'http://192.168.178.6:8002');
const checkGmailAuth = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/gmail/status`);
if (response.ok) {
const data = await response.json();
setIsGmailAuthenticated(data.authenticated);
}
} catch (err) {
console.error("Failed to check Gmail auth status");
}
};
const handleGmailLogin = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/auth/google`);
if (response.ok) {
const data = await response.json();
window.location.href = data.url;
}
} catch (err) {
setError("Konnte Gmail-Anmeldung nicht starten.");
}
};
const fetchLatestFile = async () => {
try {
@@ -83,6 +139,7 @@ function App() {
fetchJobs(activeTab, false);
}
fetchLatestFile();
checkGmailAuth();
}, [activeTab]);
const handleRefresh = () => fetchJobs(activeTab, true);
@@ -157,6 +214,18 @@ function App() {
};
}, [statsTaskId, isStatsRunning]);
const handleFetchReminderResult = async (taskId: string) => {
try {
const res = await fetch(`${API_BASE_URL}/api/tasks/${taskId}`);
if (res.ok) {
const data = await res.json();
setReminderResult(data.result);
}
} catch (err) {
console.error("Failed to fetch reminder results");
}
};
useEffect(() => {
let interval: any;
if (reminderTaskId && isReminderRunning) {
@@ -168,8 +237,7 @@ function App() {
setReminderProgress(data.progress || 'Verarbeite...');
if (data.status === 'completed') {
setIsReminderRunning(false);
// Auto-trigger download or show button? The user wants a CSV.
// Let's keep the task ID so we can show a download button.
handleFetchReminderResult(reminderTaskId);
} else if (data.status === 'error') {
setError(data.progress || 'Ein Fehler ist aufgetreten.');
setIsReminderRunning(false);
@@ -296,6 +364,78 @@ function App() {
setError("Download fehlgeschlagen.");
}
};
const handleSendEmails = async () => {
if (!reminderResult || !isGmailAuthenticated) return;
setIsSendingEmails(true);
setEmailSendStatus("Sende...");
const emailsToSend = reminderResult.map(row => {
let subject = emailSubject.replace(/{Kindernamen}/g, row["Kindernamen"]);
let body = emailBody
.replace(/{Name Käufer}/g, row["Name Käufer"])
.replace(/{Kindernamen}/g, row["Kindernamen"])
.replace(/{LinksHTML}/g, row["LinksHTML"])
.replace(/\n/g, "<br>");
return {
to: row["E-Mail-Adresse Käufer"],
subject: subject,
body: body + SIGNATURE_HTML
};
});
try {
const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails: emailsToSend })
});
if (response.ok) {
const data = await response.json();
setEmailSendStatus(`✅ Fertig! ${data.success} gesendet.`);
if (data.failed.length > 0) {
setEmailSendStatus(prev => `${prev} (${data.failed.length} Fehler)`);
}
} else {
throw new Error("Sende-Fehler");
}
} catch (err) {
setEmailSendStatus("❌ Fehler beim Senden");
} finally {
setIsSendingEmails(false);
}
};
const handleSendTestEmail = async () => {
if (!isGmailAuthenticated) return;
setIsSendingEmails(true);
try {
const response = await fetch(`${API_BASE_URL}/api/gmail/send-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
emails: [{
to: "floke.com@gmail.com",
subject: "Test-E-Mail vom Fotograf Tool (inkl. Signatur)",
body: "Hallo! Das ist eine Test-E-Mail, um die Gmail-API-Integration zu verifizieren. Wenn du das liest, funktioniert alles perfekt! 🚀" + SIGNATURE_HTML
}]
})
});
if (response.ok) {
alert("Test-E-Mail erfolgreich an floke.com@gmail.com gesendet!");
} else {
alert("Fehler beim Senden der Test-E-Mail.");
}
} catch (err) {
alert("Netzwerkfehler beim Senden der Test-E-Mail.");
} finally {
setIsSendingEmails(false);
}
};
const currentJobs = jobsCache[activeTab];
return (
@@ -317,6 +457,28 @@ function App() {
Zum Dashboard
</a>
<button
onClick={handleGmailLogin}
className={`flex text-xs font-medium transition-colors items-center gap-1 px-2 py-1 rounded border ${
isGmailAuthenticated
? 'bg-emerald-50 text-emerald-600 border-emerald-200'
: 'bg-amber-50 text-amber-600 border-amber-200 hover:bg-amber-100'
}`}
>
<span className="text-lg">{isGmailAuthenticated ? '✅' : '✉️'}</span>
{isGmailAuthenticated ? 'Gmail verbunden' : 'Gmail verbinden'}
</button>
{isGmailAuthenticated && (
<button
onClick={handleSendTestEmail}
disabled={isSendingEmails}
className="text-[10px] bg-white border border-gray-200 text-gray-400 hover:text-indigo-600 hover:border-indigo-200 px-2 py-1 rounded transition-all"
>
{isSendingEmails ? 'Sende...' : 'Test-Mail senden'}
</button>
)}
{latestFile && (
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs text-gray-400">Letzte Datei:</span>
@@ -623,13 +785,70 @@ function App() {
<p className="text-xs break-words">{reminderProgress}</p>
</div>
) : reminderTaskId ? (
<button
onClick={() => handleDownloadReminderCsv(reminderTaskId)}
className="w-full px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-colors shadow-sm flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
CSV herunterladen
</button>
<div className="space-y-3">
<button
onClick={() => handleDownloadReminderCsv(reminderTaskId)}
className="w-full px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700 transition-colors shadow-sm flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
CSV für Supermailer
</button>
{reminderResult && isGmailAuthenticated && (
<div className="mt-4 border-t border-gray-100 pt-4 bg-gray-50 p-4 rounded-xl space-y-4">
<h6 className="font-bold text-gray-900 flex items-center gap-2">
<span>🚀</span> Gmail Direkt-Versand
</h6>
<p className="text-xs text-gray-500">
{reminderResult.length} Empfänger identifiziert.
</p>
<div className="space-y-2">
<label className="text-[10px] font-bold text-gray-400 uppercase">Betreff</label>
<input
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-gray-400 uppercase">Nachricht (HTML erlaubt)</label>
<textarea
value={emailBody}
onChange={(e) => setEmailBody(e.target.value)}
rows={4}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 font-mono"
/>
<div className="flex justify-between items-center text-[10px] text-gray-400">
<span>Platzhalter: {"{Name Käufer}"}, {"{Kindernamen}"}, {"{LinksHTML}"}</span>
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Signatur "Kinderfotos Erding" wird automatisch angehängt
</span>
</div>
</div>
<button
onClick={handleSendEmails}
disabled={isSendingEmails}
className="w-full py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-md flex items-center justify-center gap-2"
>
{isSendingEmails ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
Sende Mails...
</>
) : (
<>Mails jetzt versenden</>
)}
</button>
{emailSendStatus && (
<p className="text-center text-xs font-bold text-indigo-600">{emailSendStatus}</p>
)}
</div>
)}
</div>
) : (
<button
onClick={() => handleStartReminderAnalysis(selectedJob)}

View File

@@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/fotograf-de/', // Ensures assets are loaded with the correct prefix behind NGINX
})