Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Secret key for JWT
# Secret key for JWT (generate with: openssl rand -base64 32)
SECRET_KEY=

# Base URL
BASE_URL=http://localhost:8000
# Dev server port (8001 avoids conflict with other projects on 8000)
APP_PORT=8001

# Base URL (must match APP_PORT for local dev)
BASE_URL=http://127.0.0.1:8001

# Database connection mode (0 = direct, 1 = pooled)
USE_POOL=0
Expand All @@ -14,8 +17,9 @@ DB_SSLMODE=prefer
# Direct connection settings (when USE_POOL=0)
DB_USER=postgres
DB_PASSWORD=postgres
DB_PORT=5432
DB_NAME=
# Use 5433 if another project already uses 5432
DB_PORT=5433
DB_NAME=calliflowr

# Pooled connection settings (when USE_POOL=1)
# DB_APPUSER=
Expand All @@ -37,4 +41,7 @@ DOMAIN=

# Resend
RESEND_API_KEY=
EMAIL_FROM=
EMAIL_FROM=

# Website lead notifications (defaults to hello@calliflowr.com)
# LEADS_NOTIFY_EMAIL=
14 changes: 14 additions & 0 deletions dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Start the local dev server (reads APP_PORT from .env)."""

import os

from dotenv import load_dotenv

load_dotenv()

if __name__ == "__main__":
import uvicorn

port = int(os.getenv("APP_PORT", "8001"))
host = os.getenv("APP_HOST", "127.0.0.1")
uvicorn.run("main:app", host=host, port=port, reload=True)
21 changes: 8 additions & 13 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import logging
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from fastapi import FastAPI, Request, Depends, status
from fastapi import FastAPI, Request, status
from fastapi.responses import RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
Expand All @@ -16,9 +17,9 @@
static_pages,
invitation,
)
from routers.website import home, demo, leads
from utils.core.dependencies import (
get_user_from_request,
require_unauthenticated_client,
)
from utils.core.auth import refresh_token_is_persistent, set_auth_cookies
from utils.core.rate_limit import get_trusted_proxy_hosts
Expand Down Expand Up @@ -109,6 +110,9 @@ async def csrf_middleware(request: Request, call_next):
# --- Include Routers ---


app.include_router(home.router)
app.include_router(demo.router)
app.include_router(leads.router)
app.include_router(account.router)
app.include_router(dashboard.router)
app.include_router(invitation.router)
Expand Down Expand Up @@ -389,17 +393,8 @@ async def general_exception_handler(request: Request, exc: Exception):
)


# --- Home Page ---


@app.get("/")
async def read_home(
request: Request, _: None = Depends(require_unauthenticated_client)
):
return templates.TemplateResponse(request, "index.html", {"user": None})


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
port = int(os.getenv("APP_PORT", "8001"))
uvicorn.run(app, host="0.0.0.0", port=port)
20 changes: 18 additions & 2 deletions routers/core/static_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@
from fastapi.templating import Jinja2Templates
from utils.core.dependencies import get_optional_user
from utils.core.models import User
from utils.website.content import website_home_context

router = APIRouter(tags=["static_pages"])
templates = Jinja2Templates(directory="templates")

# Define valid static pages to prevent arbitrary template access
VALID_PAGES = {
"about": "static_pages/about.html",
"blog": "static_pages/blog.html",
"customers": "static_pages/customers.html",
"privacy-policy": "static_pages/privacy_policy.html",
"terms-of-service": "static_pages/terms_of_service.html",
"terms-and-conditions": "static_pages/terms_and_conditions.html",
"website-fulfillment-policy": "static_pages/website_fulfillment_policy.html",
}

WEBSITE_THEMED_PAGES = {
"about",
"blog",
"privacy-policy",
"terms-and-conditions",
"website-fulfillment-policy",
}


Expand All @@ -36,4 +48,8 @@ async def read_static_page(
if page_name not in VALID_PAGES:
raise HTTPException(status_code=404, detail="Page not found")

return templates.TemplateResponse(request, VALID_PAGES[page_name], {"user": user})
context: dict = {"user": user}
if page_name in WEBSITE_THEMED_PAGES:
context.update(website_home_context())

return templates.TemplateResponse(request, VALID_PAGES[page_name], context)
3 changes: 3 additions & 0 deletions routers/website/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from routers.website import demo, home, leads

__all__ = ["demo", "home", "leads"]
112 changes: 112 additions & 0 deletions routers/website/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from typing import Optional

from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates

from utils.core.dependencies import get_optional_user
from utils.core.htmx import set_flash_cookie
from utils.core.models import User
from exceptions.http_exceptions import RateLimitError
from utils.core.rate_limit import check_website_lead_ip_rate_limit
from utils.website.content import website_demo_context
from utils.website.forms import BookDemoSubmission
from utils.website.notifications import notify_book_demo

router = APIRouter(tags=["website"])
templates = Jinja2Templates(directory="templates")


def _demo_template_context(
request: Request,
user: Optional[User],
*,
form_values: dict | None = None,
form_errors: dict | None = None,
form_error: str | None = None,
) -> dict:
return {
"request": request,
"user": user,
"form_values": form_values or {},
"form_errors": form_errors or {},
"form_error": form_error,
**website_demo_context(),
}


@router.get("/demo", name="read_demo")
async def read_demo(
request: Request,
user: Optional[User] = Depends(get_optional_user),
):
"""Public book-a-demo page."""
return templates.TemplateResponse(
request,
"website/demo.html",
_demo_template_context(request, user),
)


@router.post("/demo", name="submit_book_demo")
async def submit_book_demo(
request: Request,
user: Optional[User] = Depends(get_optional_user),
work_email: str = Form(""),
first_name: str = Form(""),
last_name: str = Form(""),
company: str = Form(""),
trade: str = Form(""),
phone: str = Form(""),
technicians: str = Form(""),
phone_answerer: str = Form(""),
):
form_values = {
"work_email": work_email,
"first_name": first_name,
"last_name": last_name,
"company": company,
"trade": trade,
"phone": phone,
"technicians": technicians,
"phone_answerer": phone_answerer,
}

try:
check_website_lead_ip_rate_limit(request)
except RateLimitError:
return templates.TemplateResponse(
request,
"website/demo.html",
_demo_template_context(
request,
user,
form_values=form_values,
form_error="Too many requests. Please try again in a little while.",
),
status_code=429,
)

submission, form_errors = BookDemoSubmission.from_form(**form_values)
if submission is None:
return templates.TemplateResponse(
request,
"website/demo.html",
_demo_template_context(
request,
user,
form_values=form_values,
form_errors=form_errors,
),
status_code=422,
)

notify_book_demo(submission)

response = RedirectResponse(url=str(request.url_for("read_demo")), status_code=303)
set_flash_cookie(
response,
"Thanks — we'll be in touch shortly to schedule your demo.",
level="success",
)
return response
25 changes: 25 additions & 0 deletions routers/website/home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import Optional

from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates

from utils.core.dependencies import get_optional_user
from utils.core.models import User
from utils.website.content import website_home_context

router = APIRouter(tags=["website"])
templates = Jinja2Templates(directory="templates")


@router.get("/", name="read_home")
async def read_home(
request: Request,
user: Optional[User] = Depends(get_optional_user),
):
"""Public Calliflowr website homepage."""
context = {"user": user, **website_home_context()}
return templates.TemplateResponse(
request,
"website/index.html",
context,
)
78 changes: 78 additions & 0 deletions routers/website/leads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from typing import Optional

from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import RedirectResponse

from utils.core.dependencies import get_optional_user
from utils.core.htmx import set_flash_cookie
from utils.core.models import User
from exceptions.http_exceptions import RateLimitError
from utils.core.rate_limit import check_website_lead_ip_rate_limit
from utils.website.forms import CallMeSubmission
from utils.website.notifications import notify_call_me

router = APIRouter(tags=["website"])


@router.post("/call-me", name="submit_call_me")
async def submit_call_me(
request: Request,
name: str = Form(""),
business: str = Form(""),
email: str = Form(""),
phone: str = Form(""),
industry: str = Form(""),
crm: str = Form(""),
crm_other: str = Form(""),
user: Optional[User] = Depends(get_optional_user),
):
del user # public endpoint; auth state does not affect submission

form_values = {
"name": name,
"business": business,
"email": email,
"phone": phone,
"industry": industry,
"crm": crm,
"crm_other": crm_other,
}

try:
check_website_lead_ip_rate_limit(request)
except RateLimitError:
response = RedirectResponse(
url=str(request.url_for("read_home")) + "#call-me",
status_code=303,
)
set_flash_cookie(
response,
"Too many requests. Please try again in a little while.",
level="danger",
)
return response

submission, form_errors = CallMeSubmission.from_form(**form_values)
if submission is None:
first_error = next(
iter(form_errors.values()), "Please check the form and try again."
)
response = RedirectResponse(
url=str(request.url_for("read_home")) + "#call-me",
status_code=303,
)
set_flash_cookie(response, first_error, level="danger")
return response

notify_call_me(submission)

response = RedirectResponse(
url=str(request.url_for("read_home")) + "#call-me",
status_code=303,
)
set_flash_cookie(
response,
"Thanks — we'll have Calli ring you shortly.",
level="success",
)
return response
Binary file modified static/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading