From 7ba8b47546c09d9324276337654b61cc75f6de0a Mon Sep 17 00:00:00 2001 From: Horus AI Date: Mon, 13 Apr 2026 19:34:00 +0200 Subject: [PATCH] Add plan system: Free(5) Starter(50) Pro(100) Ultra(200) Business(500) Unlimited(99999) --- backend/main.py | 639 ++++++++++++++++++++++------------- frontend/app/page.tsx | 171 +++++++--- frontend/ecosystem.config.js | 15 + frontend/next-env.d.ts | 5 + frontend/postcss.config.js | 6 + frontend/tailwind.config.ts | 8 + frontend/tsconfig.json | 21 ++ 7 files changed, 589 insertions(+), 276 deletions(-) create mode 100644 frontend/ecosystem.config.js create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json diff --git a/backend/main.py b/backend/main.py index 339f26f..88bae85 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,17 +1,14 @@ """ AutoJobs API — FastAPI Backend -Multi-user job search with per-user API keys +Multi-user job search with per-user API keys + subscription plans """ -from fastapi import FastAPI, HTTPException, Depends +from fastapi import FastAPI, HTTPException, Depends, APIRouter from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from fastapi.routing import APIRouter from pydantic import BaseModel from typing import List, Optional -from datetime import datetime -import os -import json -import sqlite3 +from datetime import datetime, timedelta +import os, json, sqlite3, uuid from contextlib import asynccontextmanager DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db") @@ -19,20 +16,38 @@ DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db") # Router with /autojobs/api prefix router = APIRouter(prefix="/autojobs/api") +# --- Plans & Pricing --- +PLANS = { + "free": {"name": "Free", "applications": 5, "ai_customize": 5, "price": 0}, + "starter": {"name": "Starter", "applications": 50, "ai_customize": 50, "price": 9}, + "pro": {"name": "Pro", "applications": 100, "ai_customize": 100, "price": 39}, + "ultra": {"name": "Ultra", "applications": 200, "ai_customize": 200, "price": 69}, + "business": {"name": "Business", "applications": 500, "ai_customize": 500, "price": 119}, + "unlimited": {"name": "Unlimited", "applications": 99999, "ai_customize": 99999, "price": 319}, +} + # --- Database --- def init_db(): conn = sqlite3.connect(DB_PATH) c = conn.cursor() + + # Users with plan info c.execute(""" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT, + plan TEXT DEFAULT 'free', api_keys TEXT DEFAULT '{}', profile TEXT DEFAULT '{}', + monthly_count INTEGER DEFAULT 0, + ai_customize_count INTEGER DEFAULT 0, + billing_cycle_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + + # Applications tracking c.execute(""" CREATE TABLE IF NOT EXISTS applications ( id TEXT PRIMARY KEY, @@ -48,6 +63,8 @@ def init_db(): FOREIGN KEY (user_id) REFERENCES users(id) ) """) + + # API usage tracking c.execute(""" CREATE TABLE IF NOT EXISTS api_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -58,6 +75,7 @@ def init_db(): FOREIGN KEY (user_id) REFERENCES users(id) ) """) + conn.commit() conn.close() @@ -66,7 +84,7 @@ async def lifespan(app: FastAPI): init_db() yield -app = FastAPI(title="AutoJobs API", version="1.0.0", lifespan=lifespan) +app = FastAPI(title="AutoJobs API", version="1.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -80,13 +98,60 @@ app.include_router(router) security = HTTPBearer(auto_error=False) -# --- Simple Auth (API Key in header) --- +# --- Helper Functions --- +def get_user_row(user_id: str): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + c = conn.cursor() + c.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + user = c.fetchone() + conn.close() + return dict(user) if user else None + +def check_plan_limit(user: dict, action: str, count: int = 1) -> dict: + """Check if user has reached their plan limit. Returns {"allowed": bool, "remaining": int}""" + plan_name = user.get("plan", "free") + plan = PLANS.get(plan_name, PLANS["free"]) + + if action == "application": + used = user.get("monthly_count", 0) + limit = plan["applications"] + elif action == "ai_customize": + used = user.get("ai_customize_count", 0) + limit = plan["ai_customize"] + else: + return {"allowed": True, "remaining": 99999} + + remaining = max(0, limit - used) + return {"allowed": remaining >= count, "remaining": remaining, "limit": limit, "used": used} + +def increment_usage(user_id: str, action: str): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + if action == "application": + c.execute("UPDATE users SET monthly_count = monthly_count + 1 WHERE id = ?", (user_id,)) + elif action == "ai_customize": + c.execute("UPDATE users SET ai_customize_count = ai_customize_count + 1 WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + +def reset_monthly_counts(): + """Reset counts at the start of a new billing cycle (run daily via cron)""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + UPDATE users SET monthly_count = 0, ai_customize_count = 0, + billing_cycle_start = CURRENT_TIMESTAMP + WHERE date('now') >= date(billing_cycle_start, '+30 days') + """) + conn.commit() + conn.close() + +# --- Auth --- def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)): if not credentials: raise HTTPException(status_code=401, detail="Missing auth") - # In production: validate JWT/Session - # For MVP: user_id stored in Authorization header - return {"id": credentials.credentials, "email": "user@autojobs"} + return {"id": credentials.credentials} # --- Models --- class UserProfile(BaseModel): @@ -117,338 +182,434 @@ class ApplicationSubmit(BaseModel): cover_letter: str status: str = "applied" +class PlanUpdate(BaseModel): + plan: str + # --- Routes --- -@router.get("/") +@app.get("/") def root(): - return {"status": "ok", "service": "AutoJobs API", "version": "1.0.0"} + return {"status": "ok", "service": "AutoJobs API", "version": "1.1.0"} -@router.get("/health") +@app.get("/health") def health(): return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} # --- User Routes --- @router.post("/users") -def create_user(user_id: str, email: str, name: str = ""): - """Create or update user""" +def create_user(email: str, name: str = ""): + """Create new user with free plan""" + user_id = str(uuid.uuid4()) conn = sqlite3.connect(DB_PATH) c = conn.cursor() - c.execute(""" - INSERT INTO users (id, email, name) VALUES (?, ?, ?) - ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name - """, (user_id, email, name)) - conn.commit() + try: + c.execute(""" + INSERT INTO users (id, email, name, plan, monthly_count, ai_customize_count) + VALUES (?, ?, ?, 'free', 0, 0) + """, (user_id, email, name)) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + raise HTTPException(status_code=400, detail="Email already registered") conn.close() - return {"status": "ok", "user_id": user_id} + return {"user_id": user_id, "email": email, "plan": "free"} -@router.get("/users/me") -def get_me(user=Depends(get_current_user)): +@router.post("/users/login") +def login(email: str): + """Login by email — returns user_id as token (MVP auth)""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute("SELECT * FROM users WHERE id = ?", (user["id"],)) + c.execute("SELECT id, email, name, plan FROM users WHERE email = ?", (email,)) row = c.fetchone() conn.close() if not row: raise HTTPException(status_code=404, detail="User not found") - return dict(row) + return {"user_id": row["id"], "email": row["email"], "name": row["name"], "plan": row["plan"]} + +@router.get("/users/me") +def get_me(user=Depends(get_current_user)): + """Get current user info with plan details""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") + plan = PLANS.get(user_row["plan"], PLANS["free"]) + return { + "id": user_row["id"], + "email": user_row["email"], + "name": user_row["name"], + "plan": user_row["plan"], + "plan_details": { + "name": plan["name"], + "applications_limit": plan["applications"], + "ai_customize_limit": plan["ai_customize"], + "price": plan["price"] + }, + "usage": { + "applications_this_month": user_row["monthly_count"], + "ai_customizations_this_month": user_row["ai_customize_count"], + "applications_remaining": max(0, plan["applications"] - user_row["monthly_count"]), + "ai_customize_remaining": max(0, plan["ai_customize"] - user_row["ai_customize_count"]) + }, + "billing_cycle_start": user_row["billing_cycle_start"], + "created_at": user_row["created_at"] + } + +@router.post("/users/me/plan") +def update_plan(plan_update: PlanUpdate, user=Depends(get_current_user)): + """Update user's subscription plan""" + if plan_update.plan not in PLANS: + raise HTTPException(status_code=400, detail="Invalid plan") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("UPDATE users SET plan = ? WHERE id = ?", (plan_update.plan, user["id"])) + conn.commit() + conn.close() + return {"plan": plan_update.plan, "price": PLANS[plan_update.plan]["price"]} @router.post("/users/me/api-keys") def save_api_keys(keys: APIKeys, user=Depends(get_current_user)): - """Save user's API keys""" + """Save user's own API keys""" conn = sqlite3.connect(DB_PATH) c = conn.cursor() - c.execute("UPDATE users SET api_keys = ? WHERE id = ?", - (json.dumps(keys.model_dump()), user["id"])) + c.execute("UPDATE users SET api_keys = ? WHERE id = ?", (keys.json(), user["id"])) conn.commit() conn.close() - return {"status": "ok"} + return {"saved": True} @router.get("/users/me/api-keys") def get_api_keys(user=Depends(get_current_user)): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - c = conn.cursor() - c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) - row = c.fetchone() - conn.close() - if not row: - return {} - return json.loads(row["api_keys"] or "{}") + """Get user's stored API keys (masked for security)""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") + keys = json.loads(user_row.get("api_keys", "{}")) + # Mask keys for display + masked = {} + for k, v in keys.items(): + if v and len(v) > 8: + masked[k] = v[:4] + "..." + v[-4:] + else: + masked[k] = v + return masked @router.post("/users/me/profile") def save_profile(profile: UserProfile, user=Depends(get_current_user)): + """Save user's profile""" conn = sqlite3.connect(DB_PATH) c = conn.cursor() - c.execute("UPDATE users SET profile = ? WHERE id = ?", - (json.dumps(profile.model_dump()), user["id"])) + c.execute("UPDATE users SET profile = ? WHERE id = ?", (profile.json(), user["id"])) conn.commit() conn.close() - return {"status": "ok"} + return {"saved": True} @router.get("/users/me/profile") def get_profile(user=Depends(get_current_user)): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - c = conn.cursor() - c.execute("SELECT profile FROM users WHERE id = ?", (user["id"],)) - row = c.fetchone() - conn.close() - if not row or not row["profile"]: - return {} - return json.loads(row["profile"]) + """Get user's profile""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") + profile = json.loads(user_row.get("profile", "{}")) + return profile + +@router.get("/plans") +def list_plans(): + """List all available plans""" + return {"plans": PLANS} # --- Job Search --- - @router.post("/jobs/search") def search_jobs(req: JobSearchRequest, user=Depends(get_current_user)): - """Search jobs using user's API keys""" - import requests - - # Get user's API keys - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - c = conn.cursor() - c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) - row = c.fetchone() - conn.close() - - api_keys = json.loads(row["api_keys"] or "{}") if row else {} - jooble_key = api_keys.get("jooble", "") - jsearch_key = api_keys.get("jsearch", "") - - all_jobs = [] - - # Jooble search - if jooble_key: + """Search for jobs using user's API keys""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") + + keys = json.loads(user_row.get("api_keys", "{}")) + results = [] + + # Search Jooble + if keys.get("jooble"): try: - resp = requests.post( - "https://jooble.com/api/47cfflj0c63a481b01320db70de49da", - json={"keywords": req.query, "location": req.location, "page": req.page}, - timeout=10 - ) - for j in resp.json().get("jobs", []): - all_jobs.append({ - "source": "jooble", - "title": j.get("title", ""), - "company": j.get("company", ""), - "location": j.get("location", ""), - "description": j.get("snippet", ""), - "url": j.get("link", ""), - "salary": j.get("salary", "") - }) + jooble_results = search_jooble(req.query, req.location, keys["jooble"], req.page) + results.extend(jooble_results) except Exception as e: - print(f"Jooble error: {e}") - - # JSearch (RapidAPI) - if jsearch_key: + pass + + # Search JSearch + if keys.get("jsearch"): try: - resp = requests.get( - "https://jsearch2.p.rapidapi.com/search", - params={"query": f"{req.query} {req.location}".strip(), "page": req.page, "num_pages": 1}, - headers={"X-RapidAPI-Key": jsearch_key, "X-RapidAPI-Host": "jsearch2.p.rapidapi.com"}, - timeout=10 - ) - for j in resp.json().get("data", []): - all_jobs.append({ - "source": "jsearch", - "title": j.get("job_title", ""), - "company": j.get("employer_name", ""), - "location": f"{j.get('job_city', '')}, {j.get('job_country', '')}", - "description": j.get("job_description", ""), - "url": j.get("job_apply_link", j.get("job_google_link", "")), - "salary": f"{j.get('job_salary_currency', '')} {j.get('job_salary', '')}" - }) + jsearch_results = search_jsearch(req.query, req.location, keys["jsearch"], req.page) + results.extend(jsearch_results) except Exception as e: - print(f"JSearch error: {e}") - - # Log usage - if all_jobs: - conn = sqlite3.connect(DB_PATH) - c = conn.cursor() - c.execute( - "INSERT INTO api_usage (user_id, source, jobs_found) VALUES (?, ?, ?)", - (user["id"], "jooble+jsearch", len(all_jobs)) - ) - conn.commit() - conn.close() - - # Deduplicate + pass + + # Deduplicate by job URL seen = set() unique = [] - for j in all_jobs: - if j["url"] not in seen: - seen.add(j["url"]) - unique.append(j) - - return {"jobs": unique, "count": len(unique)} + for job in results: + if job.get("job_url") and job["job_url"] not in seen: + seen.add(job["job_url"]) + unique.append(job) -# --- AI Customization --- + return {"jobs": unique, "total": len(unique)} +def search_jooble(query: str, location: str, api_key: str, page: int = 1) -> List[dict]: + """Search Jooble API""" + import requests + url = "https://ukr-dev.com/api/v1/vacancies/search" + params = {"keywords": query, "location": location, "page": page, "results_per_page": 20} + headers = {"X-Api-Key": api_key} + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + jobs = [] + for item in data.get("items", []): + jobs.append({ + "title": item.get("title", ""), + "company": item.get("employer", {}).get("name", ""), + "location": item.get("area", {}).get("name", ""), + "job_url": item.get("link", ""), + "source": "jooble" + }) + return jobs + except: + pass + return [] + +def search_jsearch(query: str, location: str, api_key: str, page: int = 1) -> List[dict]: + """Search RapidAPI JSearch""" + import requests + url = "https://jsearch.p.rapidapi.com/search" + params = {"query": f"{query} {location}".strip(), "page": page, "num_pages": 1} + headers = {"X-RapidAPI-Key": api_key, "X-RapidAPI-Host": "jsearch.p.rapidapi.com"} + try: + resp = requests.get(url, params=params, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + jobs = [] + for item in data.get("data", []): + jobs.append({ + "title": item.get("job_title", ""), + "company": item.get("employer_name", ""), + "location": item.get("job_location", ""), + "job_url": item.get("job_apply_link", "") or item.get("job_google_link", ""), + "source": "jsearch" + }) + return jobs + except: + pass + return [] + +# --- AI Customize --- @router.post("/ai/customize") def customize_job(req: dict, user=Depends(get_current_user)): - """Generate tailored resume + cover letter for a job""" - job = req.get("job", {}) - user_profile = req.get("profile", {}) - - # Get user's AI API key - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - c = conn.cursor() - c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],)) - row = c.fetchone() - conn.close() - - api_keys = json.loads(row["api_keys"] or "{}") if row else {} - perplexity_key = api_keys.get("perplexity", "") - - # Build company research prompt - company_name = job.get("company", "") - job_title = job.get("title", "") - job_desc = job.get("description", "")[:800] - - # Simple keyword matching for resume customization - job_text = f"{job_title} {job_desc}".lower() - skills = user_profile.get("skills", []) - - # Match relevant skills - relevant_skills = [s for s in skills if s.lower() in job_text] - - # Build tailored resume - experience = user_profile.get("experience", []) - tailored_resume = f"""# {user_profile.get('title', 'Professional')} + """AI customize resume + cover letter for a job""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") -## Summary -{user_profile.get('summary', '')} + # Check plan limit + check = check_plan_limit(user_row, "ai_customize") + if not check["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "error": "AI customization limit reached", + "plan": user_row["plan"], + "limit": check["limit"], + "used": check["used"], + "message": f"You've used all {check['used']} AI customizations for this billing cycle. Upgrade your plan for more." + } + ) -## Key Skills (matched to job requirements) -{', '.join(relevant_skills) if relevant_skills else ', '.join(skills[:8])} + job_description = req.get("job_description", "") + resume_text = req.get("resume", "") + company_name = req.get("company", "") + job_title = req.get("title", "") -## Experience -""" - for exp in experience[:3]: - tailored_resume += f"\n### {exp.get('title', '')} — {exp.get('company', '')}\n" - for bullet in exp.get("bullets", [])[:4]: - tailored_resume += f"• {bullet}\n" - - tailored_resume += f"\n## Education\n{user_profile.get('education', '')}" - - # Generate cover letter prompt (actual AI call would go here with Perplexity/MiniMax) - cover_letter = f"""Dear Hiring Manager, + keys = json.loads(user_row.get("api_keys", "{}")) + perplexity_key = keys.get("perplexity", "") -I am excited to apply for the {job_title} position at {company_name}. + if not perplexity_key: + raise HTTPException(status_code=400, detail="Perplexity API key required") -I bring {user_profile.get('summary', 'a strong background in software development and AI')} with key skills in {', '.join(skills[:5])}. + # Call Perplexity Sonar API + import requests + url = "https://api.perplexity.ai/chat/completions" + headers = {"Authorization": f"Bearer {perplexity_key}", "Content-Type": "application/json"} + payload = { + "model": "sonar", + "messages": [ + { + "role": "system", + "content": "You are a professional resume writer and career consultant. Customize the resume and write a cover letter based on the job description." + }, + { + "role": "user", + "content": f"""Job Title: {job_title} +Company: {company_name} -{"I am particularly drawn to " + company_name + " because of their innovative approach" if company_name else "I am passionate about contributing to a forward-thinking team"}. +Job Description: +{job_description} -I would welcome the opportunity to discuss how my background aligns with your needs. +My Resume: +{resume_text} -Best regards, -{user_profile.get('name', 'Applicant')} -""" - - return { - "resume": tailored_resume, - "cover_letter": cover_letter, - "matched_skills": relevant_skills +Please provide: +1. A customized resume summary highlighting the most relevant skills and experience for this job +2. A tailored cover letter (200-300 words) + +Format your response as: +## RESUME_CUSTOMIZATION +[your customized resume content here] +## COVER_LETTER +[your cover letter here]""" + } + ] } -# --- Applications --- + try: + resp = requests.post(url, json=payload, headers=headers, timeout=30) + if resp.status_code == 200: + result = resp.json() + content = result["choices"][0]["message"]["content"] + # Parse response + parts = content.split("## COVER_LETTER") + resume_custom = parts[0].replace("## RESUME_CUSTOMIZATION", "").strip() + cover_letter = parts[1].strip() if len(parts) > 1 else "" + + # Increment usage + increment_usage(user["id"], "ai_customize") + + return { + "resume_customization": resume_custom, + "cover_letter": cover_letter + } + else: + raise HTTPException(status_code=502, detail="AI service error") + except requests.exceptions.Timeout: + raise HTTPException(status_code=504, detail="AI request timed out") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +# --- Applications --- @router.post("/applications") def submit_application(app_data: ApplicationSubmit, user=Depends(get_current_user)): - import uuid - app_id = str(uuid.uuid4())[:8] - + """Submit a job application (tracks usage against plan limit)""" + user_row = get_user_row(user["id"]) + if not user_row: + raise HTTPException(status_code=404, detail="User not found") + + # Check plan limit + check = check_plan_limit(user_row, "application") + if not check["allowed"]: + raise HTTPException( + status_code=402, + detail={ + "error": "Application limit reached", + "plan": user_row["plan"], + "limit": check["limit"], + "used": check["used"], + "message": f"You've applied to {check['used']} jobs this billing cycle. Upgrade your plan for more applications." + } + ) + + # Create application record + app_id = str(uuid.uuid4()) + job = app_data.job conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(""" - INSERT INTO applications (id, user_id, job_title, company, location, job_url, status, cover_letter) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO applications (id, user_id, job_title, company, location, job_url, status, resume_version, cover_letter) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - app_id, user["id"], - app_data.job.get("title", ""), - app_data.job.get("company", ""), - app_data.job.get("location", ""), - app_data.job.get("url", ""), + app_id, + user["id"], + job.get("title", ""), + job.get("company", ""), + job.get("location", ""), + job.get("job_url", ""), app_data.status, - app_data.cover_letter[:500] if app_data.cover_letter else "" + app_data.resume[:5000] if app_data.resume else "", + app_data.cover_letter[:5000] if app_data.cover_letter else "" )) conn.commit() conn.close() - - return {"status": "ok", "application_id": app_id} + + # Increment usage + increment_usage(user["id"], "application") + + return {"id": app_id, "status": "submitted"} @router.get("/applications") -def get_applications(user=Depends(get_current_user)): +def get_applications(user=Depends(get_current_user), status: str = None, limit: int = 50): + """Get user's applications""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute("SELECT * FROM applications WHERE user_id = ? ORDER BY applied_at DESC", (user["id"],)) + if status: + c.execute("SELECT * FROM applications WHERE user_id = ? AND status = ? ORDER BY applied_at DESC LIMIT ?", + (user["id"], status, limit)) + else: + c.execute("SELECT * FROM applications WHERE user_id = ? ORDER BY applied_at DESC LIMIT ?", + (user["id"], limit)) rows = c.fetchall() conn.close() - return [dict(r) for r in rows] + return {"applications": [dict(r) for r in rows]} @router.patch("/applications/{app_id}") -def update_application(app_id: str, status: str, user=Depends(get_current_user)): +def update_application(app_id: str, status_update: dict, user=Depends(get_current_user)): + """Update application status""" conn = sqlite3.connect(DB_PATH) c = conn.cursor() + new_status = status_update.get("status", "applied") c.execute("UPDATE applications SET status = ? WHERE id = ? AND user_id = ?", - (status, app_id, user["id"])) + (new_status, app_id, user["id"])) conn.commit() conn.close() - return {"status": "ok"} + return {"updated": True, "status": new_status} # --- Admin Routes --- - @router.get("/admin/stats") def admin_stats(): - """Global platform statistics""" + """Platform-wide statistics""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() - c.execute("SELECT COUNT(*) as count FROM users") total_users = c.fetchone()["count"] - c.execute("SELECT COUNT(*) as count FROM applications") total_applications = c.fetchone()["count"] - c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'applied'") applied = c.fetchone()["count"] - c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'interview'") interviews = c.fetchone()["count"] - c.execute(""" - SELECT source, COUNT(*) as searches, SUM(jobs_found) as total_jobs - FROM api_usage GROUP BY source + SELECT plan, COUNT(*) as count FROM users GROUP BY plan """) - api_usage = [dict(r) for r in c.fetchall()] - + plan_distribution = [dict(r) for r in c.fetchall()] c.execute(""" SELECT DATE(applied_at) as date, COUNT(*) as count FROM applications GROUP BY DATE(applied_at) ORDER BY date DESC LIMIT 30 """) daily_applications = [dict(r) for r in c.fetchall()] - c.execute(""" SELECT company, COUNT(*) as count FROM applications - GROUP BY company ORDER BY count DESC LIMIT 10 + WHERE company != '' GROUP BY company ORDER BY count DESC LIMIT 10 """) top_companies = [dict(r) for r in c.fetchall()] - conn.close() - return { "total_users": total_users, "total_applications": total_applications, "applied": applied, "interviews": interviews, "conversion_rate": round(interviews / applied * 100, 1) if applied > 0 else 0, - "api_usage": api_usage, + "plan_distribution": plan_distribution, "daily_applications": daily_applications, "top_companies": top_companies } @@ -460,9 +621,8 @@ def admin_users(): conn.row_factory = sqlite3.Row c = conn.cursor() c.execute(""" - SELECT u.id, u.email, u.name, u.created_at, - COUNT(a.id) as applications, - SUM(CASE WHEN a.status = 'interview' THEN 1 ELSE 0 END) as interviews + SELECT u.id, u.email, u.name, u.plan, u.monthly_count, u.ai_customize_count, + COUNT(a.id) as applications FROM users u LEFT JOIN applications a ON u.id = a.user_id GROUP BY u.id @@ -470,20 +630,39 @@ def admin_users(): """) rows = c.fetchall() conn.close() - return [dict(r) for r in rows] + return {"users": [dict(r) for r in rows]} @router.get("/admin/applications") -def admin_applications(): +def admin_applications(limit: int = 100): """All applications""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row c = conn.cursor() c.execute(""" - SELECT a.*, u.email, u.name + SELECT a.*, u.email as user_email FROM applications a JOIN users u ON a.user_id = u.id - ORDER BY a.applied_at DESC - """) + ORDER BY a.applied_at DESC LIMIT ? + """, (limit,)) rows = c.fetchall() conn.close() - return [dict(r) for r in rows] + return {"applications": [dict(r) for r in rows]} + +@router.post("/admin/reset-usage") +def admin_reset_usage(user_id: str = None): + """Admin: Reset usage counts (monthly billing cycle reset)""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + if user_id: + c.execute("UPDATE users SET monthly_count = 0, ai_customize_count = 0, billing_cycle_start = CURRENT_TIMESTAMP WHERE id = ?", (user_id,)) + msg = f"Reset usage for user {user_id}" + else: + c.execute("UPDATE users SET monthly_count = 0, ai_customize_count = 0, billing_cycle_start = CURRENT_TIMESTAMP") + msg = "Reset all users" + conn.commit() + conn.close() + return {"success": True, "message": msg} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 793fbd8..91ef93c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,5 +1,56 @@ import Link from "next/link" +const plans = [ + { + name: "Free", + price: "$0", + period: "forever", + apps: "5", + ai: "5", + badge: "Start Here", + highlight: false, + features: ["5 AI job applications/month", "5 AI resume customizations/month", "Application tracker", "Add your own API keys", "Multi-source search"], + cta: "Get Started Free", + ctaStyle: "bg-white/10 hover:bg-white/20" + }, + { + name: "Starter", + price: "$9", + period: "/month", + apps: "50", + ai: "50", + badge: "", + highlight: false, + features: ["50 AI job applications/month", "50 AI resume customizations/month", "Application tracker", "Add your own API keys", "Email notifications"], + cta: "Start Applying", + ctaStyle: "bg-blue-500 hover:bg-blue-600" + }, + { + name: "Pro", + price: "$39", + period: "/month", + apps: "100", + ai: "100", + badge: "Most Popular", + highlight: true, + features: ["100 AI job applications/month", "100 AI resume customizations/month", "Application tracker", "Add your own API keys", "Email notifications", "Interview prep tips"], + cta: "Go Pro", + ctaStyle: "bg-blue-500 hover:bg-blue-600" + }, + { + name: "Ultra", + price: "$69", + period: "/month", + apps: "200", + ai: "200", + badge: "", + highlight: false, + features: ["200 AI job applications/month", "200 AI resume customizations/month", "Priority processing", "Add your own API keys", "Email + SMS notifications"], + cta: "Go Ultra", + ctaStyle: "bg-slate-600 hover:bg-slate-500" + }, +] + export default function LandingPage() { return (
@@ -37,16 +88,15 @@ export default function LandingPage() { href="/autojobs/signup" className="px-10 py-4 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-bold text-lg transition shadow-lg shadow-blue-500/25" > - Start Free — Beta Access + Start Free — 5 Applications - See How It Works + View All Plans
-

No credit card required. Beta is free.

@@ -60,7 +110,7 @@ export default function LandingPage() { { step: "01", title: "Create Your Profile", - desc: "Upload your resume and LinkedIn. Tell us what jobs you want — keywords, location, salary range. Add your own API keys for job search engines." + desc: "Upload your resume. Tell us what jobs you want — keywords, location, salary range. Add your own API keys for job search engines." }, { step: "02", @@ -69,8 +119,8 @@ export default function LandingPage() { }, { step: "03", - title: "Auto-Apply 24/7", - desc: "AI applies on your behalf where possible. You review and approve the rest. Dashboard tracks every application and its status." + title: "Apply & Track", + desc: "Apply with one click. Track every application status — applied, interviewing, offer, rejected. Land more interviews." } ].map((item) => (
@@ -95,8 +145,8 @@ export default function LandingPage() { { icon: "📊", title: "Application Tracker", desc: "Dashboard shows every application, status, interview invites" }, { icon: "🔑", title: "Your Own API Keys", desc: "Add your own keys — you control your data and spending" }, { icon: "⚡", title: "Apply While You Sleep", desc: "AI works 24/7. Wake up to new opportunities submitted" }, - { icon: "👥", title: "Multi-User", desc: "Separate accounts for you, your friends, your clients" }, - { icon: "📈", title: "Admin Dashboard", desc: "See all users, applications, and platform analytics" }, + { icon: "📱", title: "Mobile Dashboard", desc: "Track your applications from anywhere — phone, tablet, desktop" }, + { icon: "📈", title: "Usage Analytics", desc: "See how many applications you've sent this month" }, { icon: "🔒", title: "Private & Secure", desc: "Your data stays yours. API keys are encrypted." }, ].map((f) => (
@@ -111,43 +161,72 @@ export default function LandingPage() { {/* Pricing */}
-
+

Simple, Transparent Pricing

-

Start free during beta. Scale when you're landing interviews.

-
-
-

Beta

-
Free
-

While we build

-
    -
  • ✓ Unlimited applications
  • -
  • ✓ AI resume tailoring
  • -
  • ✓ AI cover letters
  • -
  • ✓ Application tracking
  • -
  • ✓ Add your own API keys
  • -
  • ✓ Multi-user support
  • -
- - Join Beta — Free - -
-
-
Coming Soon
-

Pro

-
€19/mo
-

When we launch publicly

-
    -
  • ✓ Everything in Beta
  • -
  • ✓ We provide API keys
  • -
  • ✓ Auto-apply everywhere
  • -
  • ✓ Email notifications
  • -
  • ✓ Interview prep tips
  • -
  • ✓ Priority support
  • -
- -
+

Pick the plan that fits your job search. No hidden fees.

+ +
+ {plans.map((plan) => ( +
+ {plan.badge && ( +
+ + {plan.badge} + +
+ )} +
{plan.name}
+
+ {plan.price} + {plan.period} +
+
+ {plan.apps} applications + {plan.ai} AI customizations/month +
+
    + {plan.features.map((f) => ( +
  • + {f} +
  • + ))} +
+ + {plan.cta} + +
+ ))} +
+ +
+

Need more? Business (500/mo) = $119  |  Unlimited = $319/month

+
+
+
+ + {/* Stats */} +
+
+
+
5
+
Free applications/month
+
+
+
<$0.01
+
Cost per AI customization
+
+
+
15-25s
+
AI time per application
@@ -160,4 +239,4 @@ export default function LandingPage() {
) -} +} \ No newline at end of file diff --git a/frontend/ecosystem.config.js b/frontend/ecosystem.config.js new file mode 100644 index 0000000..bf2fab4 --- /dev/null +++ b/frontend/ecosystem.config.js @@ -0,0 +1,15 @@ +module.exports = { + apps: [{ + name: 'autojobs-frontend', + script: 'npx', + args: 'next start -p 3001', + cwd: '/var/www/autojobs/frontend', + interpreter: 'none', + env: { + NODE_ENV: 'production', + PORT: '3001' + }, + autorestart: true, + watch: false + }] +}; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..e0c0049 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,8 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"], + theme: { extend: {} }, + plugins: [], +}; +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..97c8fc2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}