Jobs: Bewerbungssystem für Social-Media-Manager/in

Backend:
- job_applications + job_application_docs Tabellen in DB
- luna_trial_until Spalte in users (Migration)
- routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB),
  GET /my-application, GET /luna-trial-status
- Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id}
- Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert
- Bei Annahme: is_social_media=1 + Gründer-Status gesetzt
- Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template
- auth.py: require_social_media prüft auch luna_trial_until

Frontend:
- pages/jobs.js: Stellenausschreibung + Bewerbungsformular
  (Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload)
- Luna-Probezugang Teaser mit Countdown wenn aktiv
- Bestehende Bewerbung: Status-Screen statt Formular
- app.js: 'jobs' Seite registriert
- admin.js: neuer Tab 'Bewerbungen' (filtert nach Status,
  Statuswechsel per Dropdown, Detailansicht mit Anhang-Download,
  Admin-Notiz-Feld)
- admin.js: Tab 'Jobs' → 'Scheduler' umbenannt
This commit is contained in:
rene 2026-05-01 09:30:05 +02:00
parent 59856e61a1
commit f378edab5d
7 changed files with 738 additions and 4 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,) (user_id,)
).fetchone() ).fetchone()
@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
def require_social_media(user=Depends(get_current_user)): def require_social_media(user=Depends(get_current_user)):
"""Dependency: Social-Media-Manager oder Admin.""" """Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
if not (user.get("is_social_media") or user["rolle"] == "admin"): from datetime import datetime as _dt
trial = user.get("luna_trial_until")
trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user return user

View file

@ -1582,6 +1582,35 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol: if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
# Job-Bewerbungen + Luna-Probezugang
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
dog_name TEXT,
dog_rasse TEXT,
social_handle TEXT,
motivation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC);
CREATE TABLE IF NOT EXISTS job_application_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'luna_trial_until' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT")
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te: if 'js_exercise_id' not in existing_te:

View file

@ -188,6 +188,7 @@ from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router from routes.partner import router as partner_router
from routes.outreach import router as outreach_router from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -221,6 +222,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"]) app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"]) app.include_router(import_router, prefix="/api/import", tags=["Import"])

318
backend/routes/jobs.py Normal file
View file

@ -0,0 +1,318 @@
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional, require_admin
from mailer import send_email, email_html
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
JOBS_DIR = os.path.join(MEDIA_DIR, "jobs")
TRIAL_DAYS = 14
MAX_FILES = 3
MAX_FILE_MB = 10
os.makedirs(JOBS_DIR, exist_ok=True)
_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"}
# ------------------------------------------------------------------
# POST /api/jobs/apply
# ------------------------------------------------------------------
async def apply(
name: str = Form(...),
email: str = Form(...),
dog_name: str = Form(""),
dog_rasse: str = Form(""),
social_handle: str = Form(...),
motivation: str = Form(...),
files: list[UploadFile] = File(default=[]),
user = Depends(get_current_user_optional),
):
if len(motivation.strip()) < 80:
raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).")
if len(files) > MAX_FILES:
raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.")
user_id = user["id"] if user else None
# Doppelbewerbung verhindern
if user_id:
with db() as conn:
existing = conn.execute(
"SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')",
(user_id,)
).fetchone()
if existing:
raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.")
with db() as conn:
cur = conn.execute("""
INSERT INTO job_applications
(user_id, name, email, dog_name, dog_rasse, social_handle, motivation)
VALUES (?,?,?,?,?,?,?)
""", (user_id, name.strip(), email.strip(), dog_name.strip(),
dog_rasse.strip(), social_handle.strip(), motivation.strip()))
app_id = cur.lastrowid
# Dokumente speichern
app_dir = os.path.join(JOBS_DIR, str(app_id))
os.makedirs(app_dir, exist_ok=True)
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_EXT:
continue
size = 0
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(app_dir, safe_name)
with open(dest, "wb") as out:
while chunk := await f.read(65536):
size += len(chunk)
if size > MAX_FILE_MB * 1024 * 1024:
out.close()
os.remove(dest)
raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).")
out.write(chunk)
conn.execute("""
INSERT INTO job_application_docs (application_id, filename, file_path)
VALUES (?,?,?)
""", (app_id, f.filename, dest))
# Luna-Probezugang: 14 Tage ab sofort
if user_id:
trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat()
conn.execute(
"UPDATE users SET luna_trial_until=? WHERE id=?",
(trial_until, user_id)
)
# Bestätigungs-Mail an Bewerber
try:
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
</p>
{"<p style='margin:0 0 16px;background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0'><b>🎉 Luna-Probezugang aktiviert!</b><br>Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.</p>" if user_id else ""}
<p style="margin:0;color:#666;font-size:14px">Das Ban Yaro Team</p>"""
await send_email(
email,
"Deine Bewerbung bei Ban Yaro 🐾",
email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"),
f"Hallo {name}, deine Bewerbung ist eingegangen!",
)
except Exception:
pass
# Admin benachrichtigen
try:
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
admin_body = f"""
<p style="margin:0 0 12px"><b>Neue Job-Bewerbung eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{name}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{email}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{dog_name} ({dog_rasse})</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{social_handle}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Anhänge</td><td>{len([f for f in files if f.filename])} Datei(en)</td></tr>
</table>
<p style="margin:12px 0 0;font-size:14px;color:#444">{motivation[:300]}{"" if len(motivation)>300 else ""}</p>"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neue Bewerbung von {name} ({email})",
)
except Exception:
pass
return {
"ok": True,
"application_id": app_id,
"luna_trial": user_id is not None,
"trial_days": TRIAL_DAYS,
}
# FastAPI braucht expliziten Router-Decorator
router.add_api_route("/apply", apply, methods=["POST"], status_code=201)
# ------------------------------------------------------------------
# GET /api/jobs/my-application
# ------------------------------------------------------------------
@router.get("/my-application")
async def my_application(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, status, admin_note, created_at
FROM job_applications WHERE user_id=?
ORDER BY created_at DESC LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return {"application": None}
return {"application": dict(row)}
# ------------------------------------------------------------------
# GET /api/jobs/luna-trial-status
# ------------------------------------------------------------------
@router.get("/luna-trial-status")
async def luna_trial_status(user=Depends(get_current_user)):
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
if not trial:
return {"active": False}
remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days
return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)}
# ------------------------------------------------------------------
# Admin: Bewerbungen verwalten
# ------------------------------------------------------------------
@router.get("/admin/applications")
async def list_applications(
status: str = "pending",
admin = Depends(require_admin),
):
where = "" if status == "alle" else "WHERE a.status=?"
params = [] if status == "alle" else [status]
with db() as conn:
rows = conn.execute(f"""
SELECT a.*, u.name AS username,
COUNT(d.id) AS doc_count
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN job_application_docs d ON d.application_id = a.id
{where}
GROUP BY a.id
ORDER BY a.created_at DESC
""", params).fetchall()
return [dict(r) for r in rows]
@router.get("/admin/applications/{app_id}")
async def get_application(app_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute(
"""SELECT a.*, u.name AS username, u.email AS user_email
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.id=?""",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
docs = conn.execute(
"SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?",
(app_id,)
).fetchall()
return {**dict(row), "docs": [dict(d) for d in docs]}
@router.patch("/admin/applications/{app_id}")
async def update_application(
app_id: int,
status: Optional[str] = None,
admin_note: Optional[str] = None,
admin = Depends(require_admin),
):
valid = {"pending", "reviewing", "accepted", "rejected"}
if status and status not in valid:
raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}")
with db() as conn:
row = conn.execute(
"SELECT user_id, email, name, status FROM job_applications WHERE id=?",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
updates: dict = {"reviewed_at": datetime.utcnow().isoformat()}
if status:
updates["status"] = status
if admin_note is not None:
updates["admin_note"] = admin_note
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE job_applications SET {set_clause} WHERE id=?",
(*updates.values(), app_id)
)
# Bei Annahme: is_social_media aktivieren + Gründer-Status
if status == "accepted" and row["user_id"]:
conn.execute(
"UPDATE users SET is_social_media=1 WHERE id=?",
(row["user_id"],)
)
founder_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if founder_count < 100:
conn.execute(
"UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0",
(row["user_id"],)
)
# Status-Mail an Bewerber
try:
if status in ("accepted", "rejected", "reviewing"):
_send_status_mail(row["email"], row["name"], status, admin_note or "")
except Exception:
pass
return {"ok": True}
@router.get("/admin/applications/{app_id}/docs/{doc_id}")
async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
with db() as conn:
doc = conn.execute(
"SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?",
(doc_id, app_id)
).fetchone()
if not doc or not os.path.exists(doc["file_path"]):
raise HTTPException(404)
return FileResponse(doc["file_path"], filename=doc["filename"])
def _send_status_mail(email: str, name: str, status: str, note: str):
import asyncio
texts = {
"reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
f"<p>Hallo <b>{name}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
f"<p>Hallo <b>{name}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
"rejected": ("Deine Bewerbung bei Ban Yaro",
f"<p>Hallo <b>{name}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
}
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {name},</p>"))
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{note}</div>' if note else ""
body = body_start + note_html
async def _send():
await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_send())
else:
loop.run_until_complete(_send())
except Exception:
pass

View file

@ -70,6 +70,7 @@ const App = (() => {
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null }, 'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null }, gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -18,7 +18,8 @@ window.Page_admin = (() => {
{ id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' }, { id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
@ -93,6 +94,7 @@ window.Page_admin = (() => {
case 'partner': await _renderPartner(el); break; case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break; case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break; case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -2375,6 +2377,125 @@ window.Page_admin = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}> Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}> Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}> Abgelehnt</option>
</select>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -0,0 +1,260 @@
/* ============================================================
BAN YARO Social-Media-Job Bewerbung
============================================================ */
window.Page_jobs = (() => {
let _container = null;
let _appState = null;
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function _render() {
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
let existingApp = null;
let trialStatus = null;
if (_appState.user) {
try {
const r = await API.get('/jobs/my-application');
existingApp = r.application;
trialStatus = await API.get('/jobs/luna-trial-status');
} catch { /* ignorieren */ }
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Social-Media-Manager/in gesucht
</h1>
<p style="color:var(--c-text-secondary);margin:0">
Werde das Gesicht von Ban Yaro auf Instagram &amp; TikTok
</p>
</div>
<!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)">
${_infoRow('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
${_infoRow('📅', 'Umfang', '12 Posts pro Woche auf Instagram &amp; TikTok')}
${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')}
${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
${_infoRow('⭐', 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
</div>
</div>
</div>
<!-- Luna-Probezugang Teaser -->
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2)">
🤖 Luna 14 Tage kostenlos testen
</div>
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
bevor du dich entscheidest.
</p>
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
</div>
<!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Du hast einen Hund und liebst ihn sehr 🐕</li>
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
<li>Du schreibst gerne und authentisch auf Deutsch</li>
<li>Du hast Lust, eine junge App bekannt zu machen aus Überzeugung</li>
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
</ul>
</div>
</div>
<!-- Bewerbungsformular oder Status -->
${existingApp ? _renderStatus(existingApp) : _renderForm()}
</div>
`;
if (!existingApp) {
_bindForm();
}
}
function _infoRow(icon, label, text) {
return `
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:20px;line-height:1.4">${icon}</span>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
</div>
</div>`;
}
function _renderStatus(app) {
const statusMap = {
pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
};
const s = statusMap[app.status] || statusMap.pending;
return `
<div class="card" style="padding:var(--space-5);text-align:center">
<div style="font-size:40px;margin-bottom:var(--space-3)">${s.icon}</div>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
</div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);text-align:left">${UI.esc(app.admin_note)}</div>` : ''}
</div>`;
}
function _renderForm() {
const u = _appState.user;
return `
<div class="card">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
Jetzt bewerben
</h2>
<form id="jobs-form" novalidate>
<div class="form-group">
<label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name"
value="${u ? UI.esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email"
value="${u ? UI.esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
</div>
<div class="form-group" style="margin:0">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
</div>
</div>
<div class="form-group">
<label class="form-label">Instagram oder TikTok Handle *</label>
<div style="position:relative">
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted)">@</span>
<input class="form-control" type="text" name="social_handle"
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
</div>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Dein öffentliches Profil auf Instagram oder TikTok
</p>
</div>
<div class="form-group">
<label class="form-label">Warum du? *</label>
<textarea class="form-control" name="motivation" rows="5"
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
style="resize:vertical"></textarea>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Mindestens 80 Zeichen
</p>
</div>
<div class="form-group">
<label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
style="padding:var(--space-2)">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video.
</p>
</div>
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''}
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Bewerbung absenden + Luna freischalten 🚀
</button>
</form>
</div>
</div>`;
}
function _bindForm() {
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
e.preventDefault();
if (window.App) App.navigate('settings');
});
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = new FormData(e.target);
// Dateien aus file-input übernehmen
const fileInput = document.getElementById('jobs-files');
if (fileInput?.files?.length) {
fd.delete('files');
for (const f of fileInput.files) fd.append('files', f);
}
await UI.asyncButton(btn, async () => {
const resp = await fetch('/api/jobs/apply', {
method: 'POST',
body: fd,
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
credentials: 'include',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler beim Absenden.');
}
const result = await resp.json();
if (result.luna_trial) {
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
// User-State aktualisieren damit Luna sofort zugänglich ist
if (_appState.user && window.API) {
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
}
} else {
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
}
await _render();
});
});
}
return { init };
})();