Skip to content

Commit d524e80

Browse files
committed
frontend-v1
1 parent 513ff6d commit d524e80

109 files changed

Lines changed: 6901 additions & 148 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
# Secret key for JWT
1+
# Secret key for JWT (generate with: openssl rand -base64 32)
22
SECRET_KEY=
33

4-
# Base URL
5-
BASE_URL=http://localhost:8000
4+
# Dev server port (8001 avoids conflict with other projects on 8000)
5+
APP_PORT=8001
6+
7+
# Base URL (must match APP_PORT for local dev)
8+
BASE_URL=http://127.0.0.1:8001
69

710
# Database connection mode (0 = direct, 1 = pooled)
811
USE_POOL=0
@@ -14,8 +17,9 @@ DB_SSLMODE=prefer
1417
# Direct connection settings (when USE_POOL=0)
1518
DB_USER=postgres
1619
DB_PASSWORD=postgres
17-
DB_PORT=5432
18-
DB_NAME=
20+
# Use 5433 if another project already uses 5432
21+
DB_PORT=5433
22+
DB_NAME=calliflowr
1923

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

3842
# Resend
3943
RESEND_API_KEY=
40-
EMAIL_FROM=
44+
EMAIL_FROM=
45+
46+
# Website lead notifications (defaults to hello@calliflowr.com)
47+
# LEADS_NOTIFY_EMAIL=

dev.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Start the local dev server (reads APP_PORT from .env)."""
2+
3+
import os
4+
5+
from dotenv import load_dotenv
6+
7+
load_dotenv()
8+
9+
if __name__ == "__main__":
10+
import uvicorn
11+
12+
port = int(os.getenv("APP_PORT", "8001"))
13+
host = os.getenv("APP_HOST", "127.0.0.1")
14+
uvicorn.run("main:app", host=host, port=port, reload=True)

main.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
2+
import os
23
from contextlib import asynccontextmanager
34
from dotenv import load_dotenv
4-
from fastapi import FastAPI, Request, Depends, status
5+
from fastapi import FastAPI, Request, status
56
from fastapi.responses import RedirectResponse, Response
67
from fastapi.staticfiles import StaticFiles
78
from fastapi.templating import Jinja2Templates
@@ -16,9 +17,9 @@
1617
static_pages,
1718
invitation,
1819
)
20+
from routers.website import home, demo, leads
1921
from utils.core.dependencies import (
2022
get_user_from_request,
21-
require_unauthenticated_client,
2223
)
2324
from utils.core.auth import refresh_token_is_persistent, set_auth_cookies
2425
from utils.core.rate_limit import get_trusted_proxy_hosts
@@ -109,6 +110,9 @@ async def csrf_middleware(request: Request, call_next):
109110
# --- Include Routers ---
110111

111112

113+
app.include_router(home.router)
114+
app.include_router(demo.router)
115+
app.include_router(leads.router)
112116
app.include_router(account.router)
113117
app.include_router(dashboard.router)
114118
app.include_router(invitation.router)
@@ -389,17 +393,8 @@ async def general_exception_handler(request: Request, exc: Exception):
389393
)
390394

391395

392-
# --- Home Page ---
393-
394-
395-
@app.get("/")
396-
async def read_home(
397-
request: Request, _: None = Depends(require_unauthenticated_client)
398-
):
399-
return templates.TemplateResponse(request, "index.html", {"user": None})
400-
401-
402396
if __name__ == "__main__":
403397
import uvicorn
404398

405-
uvicorn.run(app, host="0.0.0.0", port=8000)
399+
port = int(os.getenv("APP_PORT", "8001"))
400+
uvicorn.run(app, host="0.0.0.0", port=port)

routers/core/static_pages.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,27 @@
33
from fastapi.templating import Jinja2Templates
44
from utils.core.dependencies import get_optional_user
55
from utils.core.models import User
6+
from utils.website.content import website_home_context
67

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

1011
# Define valid static pages to prevent arbitrary template access
1112
VALID_PAGES = {
1213
"about": "static_pages/about.html",
14+
"blog": "static_pages/blog.html",
15+
"customers": "static_pages/customers.html",
1316
"privacy-policy": "static_pages/privacy_policy.html",
14-
"terms-of-service": "static_pages/terms_of_service.html",
17+
"terms-and-conditions": "static_pages/terms_and_conditions.html",
18+
"website-fulfillment-policy": "static_pages/website_fulfillment_policy.html",
19+
}
20+
21+
WEBSITE_THEMED_PAGES = {
22+
"about",
23+
"blog",
24+
"privacy-policy",
25+
"terms-and-conditions",
26+
"website-fulfillment-policy",
1527
}
1628

1729

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

39-
return templates.TemplateResponse(request, VALID_PAGES[page_name], {"user": user})
51+
context: dict = {"user": user}
52+
if page_name in WEBSITE_THEMED_PAGES:
53+
context.update(website_home_context())
54+
55+
return templates.TemplateResponse(request, VALID_PAGES[page_name], context)

routers/website/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from routers.website import demo, home, leads
2+
3+
__all__ = ["demo", "home", "leads"]

routers/website/demo.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import Optional
2+
3+
from fastapi import APIRouter, Depends, Form, Request
4+
from fastapi.responses import RedirectResponse
5+
from fastapi.templating import Jinja2Templates
6+
7+
from utils.core.dependencies import get_optional_user
8+
from utils.core.htmx import set_flash_cookie
9+
from utils.core.models import User
10+
from exceptions.http_exceptions import RateLimitError
11+
from utils.core.rate_limit import check_website_lead_ip_rate_limit
12+
from utils.website.content import website_demo_context
13+
from utils.website.forms import BookDemoSubmission
14+
from utils.website.notifications import notify_book_demo
15+
16+
router = APIRouter(tags=["website"])
17+
templates = Jinja2Templates(directory="templates")
18+
19+
20+
def _demo_template_context(
21+
request: Request,
22+
user: Optional[User],
23+
*,
24+
form_values: dict | None = None,
25+
form_errors: dict | None = None,
26+
form_error: str | None = None,
27+
) -> dict:
28+
return {
29+
"request": request,
30+
"user": user,
31+
"form_values": form_values or {},
32+
"form_errors": form_errors or {},
33+
"form_error": form_error,
34+
**website_demo_context(),
35+
}
36+
37+
38+
@router.get("/demo", name="read_demo")
39+
async def read_demo(
40+
request: Request,
41+
user: Optional[User] = Depends(get_optional_user),
42+
):
43+
"""Public book-a-demo page."""
44+
return templates.TemplateResponse(
45+
request,
46+
"website/demo.html",
47+
_demo_template_context(request, user),
48+
)
49+
50+
51+
@router.post("/demo", name="submit_book_demo")
52+
async def submit_book_demo(
53+
request: Request,
54+
user: Optional[User] = Depends(get_optional_user),
55+
work_email: str = Form(""),
56+
first_name: str = Form(""),
57+
last_name: str = Form(""),
58+
company: str = Form(""),
59+
trade: str = Form(""),
60+
phone: str = Form(""),
61+
technicians: str = Form(""),
62+
phone_answerer: str = Form(""),
63+
):
64+
form_values = {
65+
"work_email": work_email,
66+
"first_name": first_name,
67+
"last_name": last_name,
68+
"company": company,
69+
"trade": trade,
70+
"phone": phone,
71+
"technicians": technicians,
72+
"phone_answerer": phone_answerer,
73+
}
74+
75+
try:
76+
check_website_lead_ip_rate_limit(request)
77+
except RateLimitError:
78+
return templates.TemplateResponse(
79+
request,
80+
"website/demo.html",
81+
_demo_template_context(
82+
request,
83+
user,
84+
form_values=form_values,
85+
form_error="Too many requests. Please try again in a little while.",
86+
),
87+
status_code=429,
88+
)
89+
90+
submission, form_errors = BookDemoSubmission.from_form(**form_values)
91+
if submission is None:
92+
return templates.TemplateResponse(
93+
request,
94+
"website/demo.html",
95+
_demo_template_context(
96+
request,
97+
user,
98+
form_values=form_values,
99+
form_errors=form_errors,
100+
),
101+
status_code=422,
102+
)
103+
104+
notify_book_demo(submission)
105+
106+
response = RedirectResponse(url=str(request.url_for("read_demo")), status_code=303)
107+
set_flash_cookie(
108+
response,
109+
"Thanks — we'll be in touch shortly to schedule your demo.",
110+
level="success",
111+
)
112+
return response

routers/website/home.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from typing import Optional
2+
3+
from fastapi import APIRouter, Depends, Request
4+
from fastapi.templating import Jinja2Templates
5+
6+
from utils.core.dependencies import get_optional_user
7+
from utils.core.models import User
8+
from utils.website.content import website_home_context
9+
10+
router = APIRouter(tags=["website"])
11+
templates = Jinja2Templates(directory="templates")
12+
13+
14+
@router.get("/", name="read_home")
15+
async def read_home(
16+
request: Request,
17+
user: Optional[User] = Depends(get_optional_user),
18+
):
19+
"""Public Calliflowr website homepage."""
20+
context = {"user": user, **website_home_context()}
21+
return templates.TemplateResponse(
22+
request,
23+
"website/index.html",
24+
context,
25+
)

routers/website/leads.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from typing import Optional
2+
3+
from fastapi import APIRouter, Depends, Form, Request
4+
from fastapi.responses import RedirectResponse
5+
6+
from utils.core.dependencies import get_optional_user
7+
from utils.core.htmx import set_flash_cookie
8+
from utils.core.models import User
9+
from exceptions.http_exceptions import RateLimitError
10+
from utils.core.rate_limit import check_website_lead_ip_rate_limit
11+
from utils.website.forms import CallMeSubmission
12+
from utils.website.notifications import notify_call_me
13+
14+
router = APIRouter(tags=["website"])
15+
16+
17+
@router.post("/call-me", name="submit_call_me")
18+
async def submit_call_me(
19+
request: Request,
20+
name: str = Form(""),
21+
business: str = Form(""),
22+
email: str = Form(""),
23+
phone: str = Form(""),
24+
industry: str = Form(""),
25+
crm: str = Form(""),
26+
crm_other: str = Form(""),
27+
user: Optional[User] = Depends(get_optional_user),
28+
):
29+
del user # public endpoint; auth state does not affect submission
30+
31+
form_values = {
32+
"name": name,
33+
"business": business,
34+
"email": email,
35+
"phone": phone,
36+
"industry": industry,
37+
"crm": crm,
38+
"crm_other": crm_other,
39+
}
40+
41+
try:
42+
check_website_lead_ip_rate_limit(request)
43+
except RateLimitError:
44+
response = RedirectResponse(
45+
url=str(request.url_for("read_home")) + "#call-me",
46+
status_code=303,
47+
)
48+
set_flash_cookie(
49+
response,
50+
"Too many requests. Please try again in a little while.",
51+
level="danger",
52+
)
53+
return response
54+
55+
submission, form_errors = CallMeSubmission.from_form(**form_values)
56+
if submission is None:
57+
first_error = next(iter(form_errors.values()), "Please check the form and try again.")
58+
response = RedirectResponse(
59+
url=str(request.url_for("read_home")) + "#call-me",
60+
status_code=303,
61+
)
62+
set_flash_cookie(response, first_error, level="danger")
63+
return response
64+
65+
notify_call_me(submission)
66+
67+
response = RedirectResponse(
68+
url=str(request.url_for("read_home")) + "#call-me",
69+
status_code=303,
70+
)
71+
set_flash_cookie(
72+
response,
73+
"Thanks — we'll have Calli ring you shortly.",
74+
level="success",
75+
)
76+
return response

static/apple-touch-icon.png

-4.81 KB
Loading

0 commit comments

Comments
 (0)