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:
parent
59856e61a1
commit
f378edab5d
7 changed files with 738 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
318
backend/routes/jobs.py
Normal 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
|
||||||
|
|
@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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 };
|
||||||
|
|
||||||
|
|
|
||||||
260
backend/static/js/pages/jobs.js
Normal file
260
backend/static/js/pages/jobs.js
Normal 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 & 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', '1–2 Posts pro Woche auf Instagram & 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 };
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue