Add plan system: Free(5) Starter(50) Pro(100) Ultra(200) Business(500) Unlimited(99999)
This commit is contained in:
+401
-222
@@ -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
|
||||
"""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")
|
||||
|
||||
# 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()
|
||||
keys = json.loads(user_row.get("api_keys", "{}"))
|
||||
results = []
|
||||
|
||||
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 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}")
|
||||
pass
|
||||
|
||||
# JSearch (RapidAPI)
|
||||
if jsearch_key:
|
||||
# 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}")
|
||||
pass
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
for job in results:
|
||||
if job.get("job_url") and job["job_url"] not in seen:
|
||||
seen.add(job["job_url"])
|
||||
unique.append(job)
|
||||
|
||||
return {"jobs": unique, "count": len(unique)}
|
||||
return {"jobs": unique, "total": len(unique)}
|
||||
|
||||
# --- AI Customization ---
|
||||
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", {})
|
||||
"""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")
|
||||
|
||||
# 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()
|
||||
# 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."
|
||||
}
|
||||
)
|
||||
|
||||
api_keys = json.loads(row["api_keys"] or "{}") if row else {}
|
||||
perplexity_key = api_keys.get("perplexity", "")
|
||||
job_description = req.get("job_description", "")
|
||||
resume_text = req.get("resume", "")
|
||||
company_name = req.get("company", "")
|
||||
job_title = req.get("title", "")
|
||||
|
||||
# Build company research prompt
|
||||
company_name = job.get("company", "")
|
||||
job_title = job.get("title", "")
|
||||
job_desc = job.get("description", "")[:800]
|
||||
keys = json.loads(user_row.get("api_keys", "{}"))
|
||||
perplexity_key = keys.get("perplexity", "")
|
||||
|
||||
# Simple keyword matching for resume customization
|
||||
job_text = f"{job_title} {job_desc}".lower()
|
||||
skills = user_profile.get("skills", [])
|
||||
if not perplexity_key:
|
||||
raise HTTPException(status_code=400, detail="Perplexity API key required")
|
||||
|
||||
# Match relevant skills
|
||||
relevant_skills = [s for s in skills if s.lower() in job_text]
|
||||
# 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}
|
||||
|
||||
# Build tailored resume
|
||||
experience = user_profile.get("experience", [])
|
||||
tailored_resume = f"""# {user_profile.get('title', 'Professional')}
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
## Summary
|
||||
{user_profile.get('summary', '')}
|
||||
My Resume:
|
||||
{resume_text}
|
||||
|
||||
## Key Skills (matched to job requirements)
|
||||
{', '.join(relevant_skills) if relevant_skills else ', '.join(skills[:8])}
|
||||
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)
|
||||
|
||||
## 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,
|
||||
|
||||
I am excited to apply for the {job_title} position at {company_name}.
|
||||
|
||||
I bring {user_profile.get('summary', 'a strong background in software development and AI')} with key skills in {', '.join(skills[:5])}.
|
||||
|
||||
{"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"}.
|
||||
|
||||
I would welcome the opportunity to discuss how my background aligns with your needs.
|
||||
|
||||
Best regards,
|
||||
{user_profile.get('name', 'Applicant')}
|
||||
"""
|
||||
|
||||
return {
|
||||
"resume": tailored_resume,
|
||||
"cover_letter": cover_letter,
|
||||
"matched_skills": relevant_skills
|
||||
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)
|
||||
+124
-45
@@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900">
|
||||
@@ -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
|
||||
</Link>
|
||||
<Link
|
||||
href="/autojobs/demo"
|
||||
href="/autojobs/pricing"
|
||||
className="px-10 py-4 bg-white/10 hover:bg-white/20 text-white rounded-xl font-semibold text-lg border border-white/20 transition"
|
||||
>
|
||||
See How It Works
|
||||
View All Plans
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-6 text-slate-500 text-sm">No credit card required. Beta is free.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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) => (
|
||||
<div key={item.step} className="relative bg-slate-700/50 rounded-2xl p-8 border border-slate-600 hover:border-blue-500/50 transition">
|
||||
@@ -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) => (
|
||||
<div key={f.title} className="bg-slate-800/50 rounded-xl p-5 border border-slate-700 hover:border-slate-500 transition">
|
||||
@@ -111,43 +161,72 @@ export default function LandingPage() {
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="py-20 px-6 bg-slate-800/40">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Simple, Transparent Pricing</h2>
|
||||
<p className="text-slate-400 mb-10">Start free during beta. Scale when you're landing interviews.</p>
|
||||
<div className="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
|
||||
<div className="bg-slate-700/60 rounded-2xl p-8 border border-slate-600">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Beta</h3>
|
||||
<div className="text-4xl font-bold text-white mb-1">Free</div>
|
||||
<p className="text-slate-400 mb-6 text-sm">While we build</p>
|
||||
<ul className="text-slate-300 text-left space-y-2 mb-8">
|
||||
<li>✓ Unlimited applications</li>
|
||||
<li>✓ AI resume tailoring</li>
|
||||
<li>✓ AI cover letters</li>
|
||||
<li>✓ Application tracking</li>
|
||||
<li>✓ Add your own API keys</li>
|
||||
<li>✓ Multi-user support</li>
|
||||
</ul>
|
||||
<Link href="/autojobs/signup" className="block text-center px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-lg font-medium transition">
|
||||
Join Beta — Free
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-600/20 to-purple-600/20 rounded-2xl p-8 border border-blue-500/30">
|
||||
<div className="text-sm text-blue-400 font-medium mb-1">Coming Soon</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Pro</h3>
|
||||
<div className="text-4xl font-bold text-white mb-1">€19<span className="text-lg font-normal text-slate-400">/mo</span></div>
|
||||
<p className="text-slate-400 mb-6 text-sm">When we launch publicly</p>
|
||||
<ul className="text-slate-300 text-left space-y-2 mb-8">
|
||||
<li>✓ Everything in Beta</li>
|
||||
<li>✓ We provide API keys</li>
|
||||
<li>✓ Auto-apply everywhere</li>
|
||||
<li>✓ Email notifications</li>
|
||||
<li>✓ Interview prep tips</li>
|
||||
<li>✓ Priority support</li>
|
||||
</ul>
|
||||
<button className="w-full px-6 py-3 bg-blue-500/50 text-white rounded-lg font-medium cursor-not-allowed" disabled>
|
||||
Join Waitlist
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-slate-400 mb-12">Pick the plan that fits your job search. No hidden fees.</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto" style={{gridTemplateColumns: 'repeat(4, 1fr)'}}>
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={`rounded-2xl p-6 border text-left relative ${
|
||||
plan.highlight
|
||||
? 'bg-gradient-to-br from-blue-600/30 to-purple-600/30 border-blue-500/50 shadow-lg shadow-blue-500/10'
|
||||
: 'bg-slate-700/50 border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="px-3 py-1 bg-blue-500 text-white text-xs font-bold rounded-full">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-slate-400 font-medium mb-1">{plan.name}</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className="text-3xl font-bold text-white">{plan.price}</span>
|
||||
<span className="text-slate-400 text-sm">{plan.period}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-4">
|
||||
{plan.apps} applications + {plan.ai} AI customizations/month
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="text-slate-300 text-sm flex items-start gap-2">
|
||||
<span className="text-green-400">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/autojobs/signup"
|
||||
className={`block text-center px-4 py-2.5 rounded-lg font-medium text-white transition ${plan.ctaStyle}`}
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-slate-400 text-sm">
|
||||
<p>Need more? <span className="text-blue-400">Business</span> (500/mo) = $119 | <span className="text-blue-400">Unlimited</span> = $319/month</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="py-16 px-6">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white mb-1">5</div>
|
||||
<div className="text-slate-400 text-sm">Free applications/month</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white mb-1"><$0.01</div>
|
||||
<div className="text-slate-400 text-sm">Cost per AI customization</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-white mb-1">15-25s</div>
|
||||
<div className="text-slate-400 text-sm">AI time per application</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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
|
||||
}]
|
||||
};
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user