Jul 28 - Update the demo request button, and add demo registration page
This commit is contained in:
+29
-1
@@ -7,9 +7,37 @@ DB_NAME=jqc_features
|
|||||||
# Or set a full URI and it overrides the parts above:
|
# Or set a full URI and it overrides the parts above:
|
||||||
# DATABASE_URL=mysql+pymysql://user:pass@127.0.0.1:3306/jqc_features?charset=utf8mb4
|
# DATABASE_URL=mysql+pymysql://user:pass@127.0.0.1:3306/jqc_features?charset=utf8mb4
|
||||||
|
|
||||||
# Target for the "Request a demo" button
|
# Fallback "email us directly" link shown under the booking form
|
||||||
DEMO_CONTACT_URL=mailto:info@ltservicesinc.com
|
DEMO_CONTACT_URL=mailto:info@ltservicesinc.com
|
||||||
|
|
||||||
|
# --- Demo appointments (/demo) ---
|
||||||
|
# Owner notification address. Blank -> no mail, requests still land in /admin/demos.
|
||||||
|
DEMO_NOTIFY_EMAIL=info@ltservicesinc.com
|
||||||
|
# Email the customer a confirmation copy too (1/0)
|
||||||
|
DEMO_CONFIRM_CUSTOMER=1
|
||||||
|
# Bookable window and slot size
|
||||||
|
DEMO_HOUR_START=8
|
||||||
|
DEMO_HOUR_END=17
|
||||||
|
DEMO_SLOT_MINUTES=30
|
||||||
|
DEMO_MAX_DAYS_AHEAD=90
|
||||||
|
DEMO_TIMEZONE_LABEL=Eastern Time
|
||||||
|
# Max form submissions per IP per hour (per gunicorn worker)
|
||||||
|
DEMO_RATE_LIMIT=5
|
||||||
|
|
||||||
|
# --- Outgoing mail (SMTP). Leave SMTP_HOST blank to disable mail entirely. ---
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
# STARTTLS on port 587:
|
||||||
|
SMTP_USE_TLS=1
|
||||||
|
# Implicit TLS on port 465 (set SMTP_USE_TLS=0 if you use this):
|
||||||
|
SMTP_USE_SSL=0
|
||||||
|
SMTP_TIMEOUT=20
|
||||||
|
# From address; defaults to SMTP_USER when blank
|
||||||
|
MAIL_FROM=
|
||||||
|
MAIL_SUBJECT_PREFIX=[JQC]
|
||||||
|
|
||||||
# --- Admin panel ---
|
# --- Admin panel ---
|
||||||
# Sign session cookies + CSRF tokens. Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
# Sign session cookies + CSRF tokens. Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
SECRET_KEY=change_me_to_a_long_random_string
|
SECRET_KEY=change_me_to_a_long_random_string
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`,
|
- `app.py` — factory, models (`Section`, `Topic`, `DemoRequest`, `AuditLog`),
|
||||||
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
|
`log_action()`, ProxyFix wrap, `_configure_auth_logger()`, demo helpers
|
||||||
|
(`demo_slots`, `_rate_limited`, `_notify_demo`), public routes `/`, `/demo`,
|
||||||
|
`/demo/thanks`, `/healthz`.
|
||||||
|
- `mailer.py` — stdlib SMTP sender; `send_email()` / `send_email_async()`.
|
||||||
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit`
|
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit`
|
||||||
read-only audit viewer (filter by action/entity, 50/page).
|
read-only audit viewer (filter by action/entity, 50/page), `/admin/demos`
|
||||||
|
appointment inbox (+ status / delete).
|
||||||
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
|
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
|
||||||
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
|
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
|
||||||
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
|
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
|
||||||
@@ -42,6 +46,8 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
|
|||||||
link_label, media_type[none|image|video|embed], media_url, media_caption,
|
link_label, media_type[none|image|video|embed], media_url, media_caption,
|
||||||
sort_order, is_published)`
|
sort_order, is_published)`
|
||||||
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
|
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
|
||||||
|
- `demo_request(id, name, company, email, phone, preferred_date, preferred_time,
|
||||||
|
message, status[new|scheduled|done|cancelled], source_ip, created_at)`
|
||||||
|
|
||||||
Public page orders sections by `sort_order, num`; topics by `sort_order`, and
|
Public page orders sections by `sort_order, num`; topics by `sort_order`, and
|
||||||
shows only `is_published` topics (sections with no published topics are hidden).
|
shows only `is_published` topics (sections with no published topics are hidden).
|
||||||
@@ -165,11 +171,45 @@ shows only `is_published` topics (sections with no published topics are hidden).
|
|||||||
Google Fonts — that's why libs are vendored. Fonts still load fine on the real
|
Google Fonts — that's why libs are vendored. Fonts still load fine on the real
|
||||||
server; they degrade to system fonts if blocked.
|
server; they degrade to system fonts if blocked.
|
||||||
|
|
||||||
|
## Demo appointments (public booking + owner notification)
|
||||||
|
|
||||||
|
- CTA button now goes to **`/demo`** (a real form), not the `mailto:`.
|
||||||
|
`DEMO_CONTACT_URL` survives as the "email us directly" fallback link.
|
||||||
|
- `GET/POST /demo` → `templates/demo.html`; success redirects to
|
||||||
|
`/demo/thanks` (POST-redirect-GET, so a refresh can't double-book).
|
||||||
|
- Slots come from `demo_slots()` — `DEMO_HOUR_START/END` + `DEMO_SLOT_MINUTES`
|
||||||
|
(default 08:00–16:30 every 30 min). Server-side validation is the real gate:
|
||||||
|
the posted time must be in that list, the date must parse, be today-or-later,
|
||||||
|
within `DEMO_MAX_DAYS_AHEAD`, and be a weekday. Dates/times are stored exactly
|
||||||
|
as picked; `DEMO_TIMEZONE_LABEL` is display-only (no tz conversion anywhere).
|
||||||
|
- **One appointment per slot:** a request for a (date, time) that already has a
|
||||||
|
non-`cancelled` row is rejected with a flash. Cancelling frees the slot.
|
||||||
|
- **Abuse control:** hidden `website` honeypot (filled → fake success, nothing
|
||||||
|
stored) + per-IP hourly cap `DEMO_RATE_LIMIT` held in `_demo_hits` (in-memory,
|
||||||
|
therefore per gunicorn worker — it's a nuisance filter, not a DDoS defence;
|
||||||
|
use nginx `limit_req` if that ever matters). CSRF via the global `CSRFProtect`.
|
||||||
|
- **Notification:** `_notify_demo()` mails `DEMO_NOTIFY_EMAIL` with the details
|
||||||
|
and a link to `/admin/demos`, `Reply-To` set to the customer; with
|
||||||
|
`DEMO_CONFIRM_CUSTOMER=1` the customer also gets a copy. Both go through
|
||||||
|
`send_email_async()` on a daemon thread **after** the row is committed —
|
||||||
|
mail is best-effort and never raises, so a dead SMTP host loses the email,
|
||||||
|
never the appointment. Blank `SMTP_HOST`/`MAIL_FROM` = mail silently off
|
||||||
|
(logged via `jqc.mail`); requests still land in the admin inbox.
|
||||||
|
- Header values are CR/LF-stripped (`_clean_header`) so a submitted name can't
|
||||||
|
inject SMTP headers.
|
||||||
|
- **Admin inbox** `/admin/demos`: default view shows `new` + `scheduled`,
|
||||||
|
soonest first; chips filter by status; per-row status select and delete.
|
||||||
|
Nav badge counts `new`. Status POSTs redirect via a `back_status` form field —
|
||||||
|
never `request.referrer` (open-redirect). Every create/status/delete writes an
|
||||||
|
audit row with entity `demo` (the audit filter accepts it).
|
||||||
|
|
||||||
## Deploy delta cheatsheet
|
## Deploy delta cheatsheet
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mysql < add_admin.sql # audit_log (existing DBs)
|
sudo mysql < add_admin.sql # audit_log (existing DBs)
|
||||||
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
|
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
|
||||||
|
sudo mysql < add_demo.sql # demo_request table (existing DBs)
|
||||||
|
# .env: DEMO_NOTIFY_EMAIL + SMTP_HOST/PORT/USER/PASSWORD/MAIL_FROM for notifications
|
||||||
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
||||||
sudo ./venv/bin/pip install -r requirements.txt # bleach[css] + tinycss2 (CSS sanitize)
|
sudo ./venv/bin/pip install -r requirements.txt # bleach[css] + tinycss2 (CSS sanitize)
|
||||||
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable)
|
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable)
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ photos, or videos.
|
|||||||
Stack: **Flask + MySQL + Gunicorn + systemd + Nginx** on Ubuntu 24.04.
|
Stack: **Flask + MySQL + Gunicorn + systemd + Nginx** on Ubuntu 24.04.
|
||||||
|
|
||||||
```
|
```
|
||||||
app.py Flask app + Section/Topic models + routes
|
app.py Flask app + Section/Topic/DemoRequest models + routes
|
||||||
config.py env-driven config (DB + demo contact URL)
|
admin.py /admin panel (content CRUD, audit log, demo inbox)
|
||||||
|
mailer.py stdlib SMTP notifications (best-effort, threaded)
|
||||||
|
config.py env-driven config (DB, demo booking, SMTP)
|
||||||
schema.sql MySQL DDL + seed content (safe to re-run)
|
schema.sql MySQL DDL + seed content (safe to re-run)
|
||||||
|
add_demo.sql additive migration: demo_request table
|
||||||
templates/index.html
|
templates/index.html
|
||||||
|
templates/demo.html booking form (+ demo_thanks.html)
|
||||||
static/css/style.css
|
static/css/style.css
|
||||||
static/js/main.js accordion expand/collapse
|
static/js/main.js accordion expand/collapse
|
||||||
gunicorn.conf.py
|
gunicorn.conf.py
|
||||||
@@ -257,3 +261,43 @@ This jail bans credential-guessing that reaches the password check (a real
|
|||||||
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
|
browser session with a valid CSRF token). Dumb bots that POST without a CSRF
|
||||||
token get an HTTP 400 and never reach the check — they can't guess a password
|
token get an HTTP 400 and never reach the check — they can't guess a password
|
||||||
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
|
anyway. To also throttle those, add an nginx `limit_req` on `/admin/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo appointments
|
||||||
|
|
||||||
|
The **Request a demo** button at the bottom of the public page opens `/demo`, a
|
||||||
|
booking form: name, company, email, phone, preferred date + time slot, and a
|
||||||
|
message. Valid submissions are stored in `demo_request` and the owner is
|
||||||
|
emailed; the customer gets a confirmation copy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql < add_demo.sql # existing databases only; schema.sql has it too
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set in `.env` and restart (`sudo systemctl restart jqc-features`):
|
||||||
|
|
||||||
|
```
|
||||||
|
DEMO_NOTIFY_EMAIL=info@ltservicesinc.com
|
||||||
|
SMTP_HOST=smtp.yourprovider.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=notifications@ltservicesinc.com
|
||||||
|
SMTP_PASSWORD=...
|
||||||
|
MAIL_FROM=notifications@ltservicesinc.com
|
||||||
|
DEMO_TIMEZONE_LABEL=Eastern Time
|
||||||
|
```
|
||||||
|
|
||||||
|
Bookable hours default to 08:00–16:30 in 30-minute slots, weekdays, up to 90
|
||||||
|
days ahead (`DEMO_HOUR_START`, `DEMO_HOUR_END`, `DEMO_SLOT_MINUTES`,
|
||||||
|
`DEMO_MAX_DAYS_AHEAD`). A slot already taken by a non-cancelled request can't be
|
||||||
|
booked twice.
|
||||||
|
|
||||||
|
**Mail is best-effort:** it goes out on a background thread *after* the request
|
||||||
|
is saved, and a failure is logged rather than shown to the visitor. With
|
||||||
|
`SMTP_HOST` blank no mail is sent at all — requests still appear in the admin
|
||||||
|
panel, so nothing is ever lost.
|
||||||
|
|
||||||
|
Manage them at **`/admin/demos`**: open requests first, filter by status
|
||||||
|
(`new` / `scheduled` / `done` / `cancelled`), set a status or delete. The nav
|
||||||
|
badge counts unhandled ones. Every action is written to the audit log under the
|
||||||
|
`demo` type.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Additive migration: demo appointment requests from the public page.
|
||||||
|
-- Idempotent — safe to re-run.
|
||||||
|
|
||||||
|
USE jqc_features;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS demo_request (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
company VARCHAR(160) NULL,
|
||||||
|
email VARCHAR(200) NOT NULL,
|
||||||
|
phone VARCHAR(40) NULL,
|
||||||
|
preferred_date DATE NOT NULL,
|
||||||
|
preferred_time VARCHAR(5) NOT NULL, -- 'HH:MM', local business hours
|
||||||
|
message TEXT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'new', -- new/scheduled/done/cancelled
|
||||||
|
source_ip VARCHAR(45) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_demo_slot (preferred_date, preferred_time),
|
||||||
|
KEY idx_demo_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -11,7 +11,10 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
from app import db, log_action, sanitize_html, AuditLog, Section, Topic
|
from app import (
|
||||||
|
db, log_action, sanitize_html, AuditLog, DemoRequest, DEMO_STATUSES,
|
||||||
|
Section, Topic,
|
||||||
|
)
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
@@ -40,6 +43,19 @@ def _sniff_image(head):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.context_processor
|
||||||
|
def inject_pending_demos():
|
||||||
|
"""Badge count on the nav. Only queried for a signed-in admin, so the login
|
||||||
|
page never touches the database."""
|
||||||
|
if not session.get("admin"):
|
||||||
|
return {"pending_demos": 0}
|
||||||
|
try:
|
||||||
|
return {"pending_demos": DemoRequest.query.filter(
|
||||||
|
DemoRequest.status == "new").count()}
|
||||||
|
except Exception: # noqa: BLE001 - a missing table must not 500 the panel
|
||||||
|
return {"pending_demos": 0}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ auth
|
# ------------------------------------------------------------------ auth
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@wraps(view)
|
@wraps(view)
|
||||||
@@ -130,6 +146,69 @@ def dashboard():
|
|||||||
return render_template("admin/dashboard.html", sections=sections)
|
return render_template("admin/dashboard.html", sections=sections)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ demo requests
|
||||||
|
@admin_bp.route("/demos")
|
||||||
|
@login_required
|
||||||
|
def demos():
|
||||||
|
"""Appointment requests from the public /demo form, soonest first."""
|
||||||
|
page = _int(request.args.get("page"), 1)
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
status = request.args.get("status", "")
|
||||||
|
|
||||||
|
q = DemoRequest.query
|
||||||
|
if status in DEMO_STATUSES:
|
||||||
|
q = q.filter(DemoRequest.status == status)
|
||||||
|
elif status == "":
|
||||||
|
# Default view hides what's already dealt with.
|
||||||
|
q = q.filter(DemoRequest.status.in_(("new", "scheduled")))
|
||||||
|
|
||||||
|
pagination = (
|
||||||
|
q.order_by(DemoRequest.preferred_date.asc(),
|
||||||
|
DemoRequest.preferred_time.asc(), DemoRequest.id.asc())
|
||||||
|
.paginate(page=page, per_page=50, error_out=False)
|
||||||
|
)
|
||||||
|
new_count = DemoRequest.query.filter(DemoRequest.status == "new").count()
|
||||||
|
return render_template(
|
||||||
|
"admin/demos.html",
|
||||||
|
pagination=pagination,
|
||||||
|
requests=pagination.items,
|
||||||
|
status=status,
|
||||||
|
statuses=DEMO_STATUSES,
|
||||||
|
new_count=new_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/demo/<int:req_id>/status", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def demo_status(req_id):
|
||||||
|
req = DemoRequest.query.get_or_404(req_id)
|
||||||
|
new_status = request.form.get("status", "")
|
||||||
|
if new_status not in DEMO_STATUSES:
|
||||||
|
abort(400)
|
||||||
|
req.status = new_status
|
||||||
|
db.session.commit()
|
||||||
|
log_action(session.get("admin"), "update", "demo", req.id,
|
||||||
|
f"{new_status}: {req.name} · {req.when}")
|
||||||
|
flash(f"Request from {req.name} marked {new_status}.", "ok")
|
||||||
|
# Come back to the same filtered view (never trust Referer for a redirect).
|
||||||
|
back = request.form.get("back_status", "")
|
||||||
|
return redirect(url_for("admin.demos",
|
||||||
|
status=back if back in DEMO_STATUSES else ""))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/demo/<int:req_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def demo_delete(req_id):
|
||||||
|
req = DemoRequest.query.get_or_404(req_id)
|
||||||
|
name, rid, when = req.name, req.id, req.when
|
||||||
|
db.session.delete(req)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(session.get("admin"), "delete", "demo", rid, f"{name} · {when}")
|
||||||
|
flash(f"Request from {name} deleted.", "ok")
|
||||||
|
return redirect(url_for("admin.demos"))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ audit log
|
# ------------------------------------------------------------------ audit log
|
||||||
@admin_bp.route("/audit")
|
@admin_bp.route("/audit")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -143,7 +222,7 @@ def audit():
|
|||||||
q = AuditLog.query
|
q = AuditLog.query
|
||||||
if action in ("create", "update", "delete"):
|
if action in ("create", "update", "delete"):
|
||||||
q = q.filter(AuditLog.action == action)
|
q = q.filter(AuditLog.action == action)
|
||||||
if entity in ("section", "topic"):
|
if entity in ("section", "topic", "demo"):
|
||||||
q = q.filter(AuditLog.entity == entity)
|
q = q.filter(AuditLog.entity == entity)
|
||||||
|
|
||||||
pagination = (
|
pagination = (
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
from bleach.css_sanitizer import CSSSanitizer
|
from bleach.css_sanitizer import CSSSanitizer
|
||||||
from flask import Flask, render_template
|
from flask import Flask, flash, redirect, render_template, request, url_for
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
from mailer import send_email_async
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
@@ -65,6 +70,31 @@ class Topic(db.Model):
|
|||||||
return "youtube.com" in u or "youtu.be" in u
|
return "youtube.com" in u or "youtu.be" in u
|
||||||
|
|
||||||
|
|
||||||
|
class DemoRequest(db.Model):
|
||||||
|
"""A demo appointment requested from the public page. `preferred_date` /
|
||||||
|
`preferred_time` are what the customer picked (no timezone conversion —
|
||||||
|
they're read back as the site's advertised local business hours)."""
|
||||||
|
__tablename__ = "demo_request"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
company = db.Column(db.String(160))
|
||||||
|
email = db.Column(db.String(200), nullable=False)
|
||||||
|
phone = db.Column(db.String(40))
|
||||||
|
preferred_date = db.Column(db.Date, nullable=False)
|
||||||
|
preferred_time = db.Column(db.String(5), nullable=False) # 'HH:MM'
|
||||||
|
message = db.Column(db.Text)
|
||||||
|
status = db.Column(db.String(16), nullable=False, default="new")
|
||||||
|
source_ip = db.Column(db.String(45))
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def when(self):
|
||||||
|
return f"{self.preferred_date:%a %b %d, %Y} at {self.preferred_time}"
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_STATUSES = ("new", "scheduled", "done", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(db.Model):
|
class AuditLog(db.Model):
|
||||||
__tablename__ = "audit_log"
|
__tablename__ = "audit_log"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -145,6 +175,85 @@ def sanitize_html(raw):
|
|||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- demo form
|
||||||
|
# Loose sanity check only — the real proof an address works is the confirmation
|
||||||
|
# mail the customer receives. Rejecting exotic-but-valid addresses is worse than
|
||||||
|
# accepting a typo the owner can see and follow up on.
|
||||||
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
|
||||||
|
|
||||||
|
# In-memory per-IP throttle. Per gunicorn worker (not shared), which is fine:
|
||||||
|
# it exists to stop a bored visitor spamming the form, not a distributed flood.
|
||||||
|
# nginx limit_req is the right tool if that ever matters.
|
||||||
|
_demo_hits = defaultdict(list)
|
||||||
|
_demo_hits_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def demo_slots(config):
|
||||||
|
"""Bookable clock times as 'HH:MM', e.g. 08:00, 08:30, ... 16:30."""
|
||||||
|
start = max(0, min(23, config.get("DEMO_HOUR_START", 8)))
|
||||||
|
end = max(start + 1, min(24, config.get("DEMO_HOUR_END", 17)))
|
||||||
|
step = max(5, config.get("DEMO_SLOT_MINUTES", 30))
|
||||||
|
slots, minute = [], start * 60
|
||||||
|
while minute < end * 60:
|
||||||
|
slots.append(f"{minute // 60:02d}:{minute % 60:02d}")
|
||||||
|
minute += step
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(ip, limit):
|
||||||
|
"""True if `ip` has already submitted `limit` requests in the last hour."""
|
||||||
|
if limit <= 0:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
with _demo_hits_lock:
|
||||||
|
hits = [t for t in _demo_hits[ip] if now - t < 3600]
|
||||||
|
if len(hits) >= limit:
|
||||||
|
_demo_hits[ip] = hits
|
||||||
|
return True
|
||||||
|
hits.append(now)
|
||||||
|
_demo_hits[ip] = hits
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_demo(app, req):
|
||||||
|
"""Mail the owner about a new appointment, and (optionally) confirm to the
|
||||||
|
customer. Both are fire-and-forget: the row is already committed."""
|
||||||
|
cfg = app.config
|
||||||
|
admin_link = request.url_root.rstrip("/") + url_for("admin.demos")
|
||||||
|
owner_body = (
|
||||||
|
"A new demo appointment was requested from the JQC features site.\n\n"
|
||||||
|
f"When: {req.when} ({cfg['DEMO_TIMEZONE_LABEL']})\n"
|
||||||
|
f"Name: {req.name}\n"
|
||||||
|
f"Company: {req.company or '—'}\n"
|
||||||
|
f"Email: {req.email}\n"
|
||||||
|
f"Phone: {req.phone or '—'}\n\n"
|
||||||
|
f"Message:\n{req.message or '—'}\n\n"
|
||||||
|
f"Manage requests: {admin_link}\n"
|
||||||
|
)
|
||||||
|
send_email_async(
|
||||||
|
cfg, cfg.get("DEMO_NOTIFY_EMAIL"),
|
||||||
|
f"Demo request — {req.name} · {req.when}",
|
||||||
|
owner_body,
|
||||||
|
reply_to=req.email, reply_name=req.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.get("DEMO_CONFIRM_CUSTOMER"):
|
||||||
|
send_email_async(
|
||||||
|
cfg, req.email,
|
||||||
|
"We received your demo request",
|
||||||
|
(
|
||||||
|
f"Hi {req.name},\n\n"
|
||||||
|
"Thanks for your interest in JQC — LT Services' Janitorial "
|
||||||
|
"Quality Control program.\n\n"
|
||||||
|
f"You asked for a demonstration on {req.when} "
|
||||||
|
f"({cfg['DEMO_TIMEZONE_LABEL']}). We'll confirm that time by "
|
||||||
|
"email or phone shortly; if it turns out not to work on our "
|
||||||
|
"side we'll suggest the nearest alternative.\n\n"
|
||||||
|
"— LT Services Inc.\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configure_auth_logger(app):
|
def _configure_auth_logger(app):
|
||||||
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
|
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
|
||||||
file fail2ban watches. Kept separate from the app log so the filter regex
|
file fail2ban watches. Kept separate from the app log so the filter regex
|
||||||
@@ -200,6 +309,106 @@ def create_app():
|
|||||||
demo_url=app.config["DEMO_CONTACT_URL"],
|
demo_url=app.config["DEMO_CONTACT_URL"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route("/demo", methods=["GET", "POST"])
|
||||||
|
def demo():
|
||||||
|
"""Public demo-appointment form. On success the request is stored and
|
||||||
|
the owner is notified by mail (best-effort, on a background thread)."""
|
||||||
|
cfg = app.config
|
||||||
|
slots = demo_slots(cfg)
|
||||||
|
today = date.today()
|
||||||
|
max_date = today + timedelta(days=cfg["DEMO_MAX_DAYS_AHEAD"])
|
||||||
|
form = {}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
f = request.form
|
||||||
|
form = {k: f.get(k, "").strip() for k in (
|
||||||
|
"name", "company", "email", "phone", "preferred_date",
|
||||||
|
"preferred_time", "message",
|
||||||
|
)}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Honeypot: a hidden field only a bot fills in. Pretend success so
|
||||||
|
# the bot doesn't learn to work around it.
|
||||||
|
if f.get("website", "").strip():
|
||||||
|
return redirect(url_for("demo_thanks"))
|
||||||
|
|
||||||
|
client_ip = request.remote_addr or "-"
|
||||||
|
if _rate_limited(client_ip, cfg["DEMO_RATE_LIMIT"]):
|
||||||
|
errors.append(
|
||||||
|
"Too many requests from this connection. Please try again "
|
||||||
|
"later or email us directly."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not form["name"]:
|
||||||
|
errors.append("Please tell us your name.")
|
||||||
|
if not EMAIL_RE.match(form["email"] or ""):
|
||||||
|
errors.append("Please enter a valid email address.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
wanted = datetime.strptime(form["preferred_date"], "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
wanted = None
|
||||||
|
errors.append("Please pick a date for the demonstration.")
|
||||||
|
if wanted:
|
||||||
|
if wanted < today:
|
||||||
|
errors.append("Please pick a date that hasn't passed yet.")
|
||||||
|
elif wanted > max_date:
|
||||||
|
errors.append(
|
||||||
|
f"Please pick a date within the next "
|
||||||
|
f"{cfg['DEMO_MAX_DAYS_AHEAD']} days."
|
||||||
|
)
|
||||||
|
elif wanted.weekday() >= 5:
|
||||||
|
errors.append("Demonstrations run Monday through Friday.")
|
||||||
|
|
||||||
|
if form["preferred_time"] not in slots:
|
||||||
|
errors.append("Please pick an available time.")
|
||||||
|
|
||||||
|
# One appointment per slot. Cancelled ones free the slot again.
|
||||||
|
if not errors and DemoRequest.query.filter(
|
||||||
|
DemoRequest.preferred_date == wanted,
|
||||||
|
DemoRequest.preferred_time == form["preferred_time"],
|
||||||
|
DemoRequest.status != "cancelled",
|
||||||
|
).first():
|
||||||
|
errors.append("That time was just taken — please pick another.")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for msg in errors:
|
||||||
|
flash(msg, "error")
|
||||||
|
else:
|
||||||
|
req = DemoRequest(
|
||||||
|
name=form["name"][:120],
|
||||||
|
company=form["company"][:160] or None,
|
||||||
|
email=form["email"][:200],
|
||||||
|
phone=form["phone"][:40] or None,
|
||||||
|
preferred_date=wanted,
|
||||||
|
preferred_time=form["preferred_time"],
|
||||||
|
message=form["message"][:2000] or None,
|
||||||
|
status="new",
|
||||||
|
source_ip=client_ip[:45],
|
||||||
|
)
|
||||||
|
db.session.add(req)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(
|
||||||
|
"public", "create", "demo", req.id,
|
||||||
|
f"{req.name} · {req.when}",
|
||||||
|
)
|
||||||
|
_notify_demo(app, req)
|
||||||
|
return redirect(url_for("demo_thanks"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"demo.html",
|
||||||
|
slots=slots,
|
||||||
|
form=form,
|
||||||
|
min_date=today.isoformat(),
|
||||||
|
max_date=max_date.isoformat(),
|
||||||
|
tz_label=cfg["DEMO_TIMEZONE_LABEL"],
|
||||||
|
demo_url=cfg["DEMO_CONTACT_URL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/demo/thanks")
|
||||||
|
def demo_thanks():
|
||||||
|
return render_template("demo_thanks.html", demo_url=app.config["DEMO_CONTACT_URL"])
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -16,9 +16,39 @@ class Config:
|
|||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280}
|
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280}
|
||||||
|
|
||||||
# Public contact button target shown in the closing CTA.
|
# Public contact button target shown in the closing CTA. Still used as the
|
||||||
|
# "or just email us" fallback link under the booking form.
|
||||||
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
|
DEMO_CONTACT_URL = os.environ.get("DEMO_CONTACT_URL", "mailto:info@ltservicesinc.com")
|
||||||
|
|
||||||
|
# --- Demo appointment requests ---
|
||||||
|
# Where the owner notification is sent. Blank -> no mail is sent, the
|
||||||
|
# request is still stored and visible in /admin/demos.
|
||||||
|
DEMO_NOTIFY_EMAIL = os.environ.get("DEMO_NOTIFY_EMAIL", "")
|
||||||
|
# Send the customer a "we got it" copy as well.
|
||||||
|
DEMO_CONFIRM_CUSTOMER = os.environ.get("DEMO_CONFIRM_CUSTOMER", "1") == "1"
|
||||||
|
# Bookable window, local business hours. Slots are generated every
|
||||||
|
# DEMO_SLOT_MINUTES from DEMO_HOUR_START up to (not including) DEMO_HOUR_END.
|
||||||
|
DEMO_HOUR_START = int(os.environ.get("DEMO_HOUR_START", "8"))
|
||||||
|
DEMO_HOUR_END = int(os.environ.get("DEMO_HOUR_END", "17"))
|
||||||
|
DEMO_SLOT_MINUTES = int(os.environ.get("DEMO_SLOT_MINUTES", "30"))
|
||||||
|
# How far ahead a customer may book, in days.
|
||||||
|
DEMO_MAX_DAYS_AHEAD = int(os.environ.get("DEMO_MAX_DAYS_AHEAD", "90"))
|
||||||
|
# Label only — dates/times are stored exactly as the customer picked them.
|
||||||
|
DEMO_TIMEZONE_LABEL = os.environ.get("DEMO_TIMEZONE_LABEL", "Local time")
|
||||||
|
# Max requests accepted from one IP per hour (per gunicorn worker).
|
||||||
|
DEMO_RATE_LIMIT = int(os.environ.get("DEMO_RATE_LIMIT", "5"))
|
||||||
|
|
||||||
|
# --- Outgoing mail (SMTP) ---
|
||||||
|
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||||
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||||
|
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||||
|
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||||
|
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "1") == "1" # STARTTLS on 587
|
||||||
|
SMTP_USE_SSL = os.environ.get("SMTP_USE_SSL", "0") == "1" # implicit TLS on 465
|
||||||
|
SMTP_TIMEOUT = int(os.environ.get("SMTP_TIMEOUT", "20"))
|
||||||
|
MAIL_FROM = os.environ.get("MAIL_FROM", "") or SMTP_USER
|
||||||
|
MAIL_SUBJECT_PREFIX = os.environ.get("MAIL_SUBJECT_PREFIX", "[JQC]")
|
||||||
|
|
||||||
# --- Admin / session ---
|
# --- Admin / session ---
|
||||||
# SECRET_KEY signs session cookies and CSRF tokens. MUST be set in production.
|
# SECRET_KEY signs session cookies and CSRF tokens. MUST be set in production.
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-only-insecure-change-me")
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Minimal SMTP sender for owner/customer notifications.
|
||||||
|
|
||||||
|
Deliberately stdlib-only (smtplib + email) — no new dependency, no queue, no
|
||||||
|
broker. Mail is sent on a daemon thread so a slow or dead SMTP host never makes
|
||||||
|
the visitor wait, and a failure is logged rather than raised: the demo request
|
||||||
|
is already committed to the database by the time we get here, so the owner can
|
||||||
|
always see it in /admin/demos even if mail is misconfigured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import threading
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr, parseaddr
|
||||||
|
|
||||||
|
mail_log = logging.getLogger("jqc.mail")
|
||||||
|
|
||||||
|
|
||||||
|
def mail_enabled(config):
|
||||||
|
"""Mail can only go out with a host and a From address configured."""
|
||||||
|
return bool(config.get("SMTP_HOST") and config.get("MAIL_FROM"))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_header(value):
|
||||||
|
"""Strip CR/LF so a user-supplied name can't inject extra headers."""
|
||||||
|
return " ".join(str(value or "").split())[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(config, to, subject, body, reply_to=None, reply_name=None):
|
||||||
|
"""Send one plain-text mail. Returns True on success, False otherwise.
|
||||||
|
Never raises — callers treat mail as best-effort."""
|
||||||
|
if not mail_enabled(config) or not to:
|
||||||
|
mail_log.info("Mail skipped (SMTP not configured): %s", _clean_header(subject))
|
||||||
|
return False
|
||||||
|
|
||||||
|
prefix = config.get("MAIL_SUBJECT_PREFIX", "")
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = _clean_header(f"{prefix} {subject}".strip())
|
||||||
|
msg["From"] = config["MAIL_FROM"]
|
||||||
|
msg["To"] = to
|
||||||
|
if reply_to:
|
||||||
|
addr = parseaddr(_clean_header(reply_to))[1]
|
||||||
|
if addr:
|
||||||
|
msg["Reply-To"] = formataddr((_clean_header(reply_name), addr))
|
||||||
|
msg.set_content(body)
|
||||||
|
|
||||||
|
host = config["SMTP_HOST"]
|
||||||
|
port = config.get("SMTP_PORT", 587)
|
||||||
|
timeout = config.get("SMTP_TIMEOUT", 20)
|
||||||
|
try:
|
||||||
|
if config.get("SMTP_USE_SSL"):
|
||||||
|
server = smtplib.SMTP_SSL(host, port, timeout=timeout)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(host, port, timeout=timeout)
|
||||||
|
with server:
|
||||||
|
server.ehlo()
|
||||||
|
if config.get("SMTP_USE_TLS") and not config.get("SMTP_USE_SSL"):
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
if config.get("SMTP_USER"):
|
||||||
|
server.login(config["SMTP_USER"], config.get("SMTP_PASSWORD", ""))
|
||||||
|
server.send_message(msg)
|
||||||
|
mail_log.info("Mail sent to %s: %s", to, msg["Subject"])
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001 - mail must never break the request
|
||||||
|
mail_log.error("Mail to %s failed: %s", to, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_async(config, to, subject, body, reply_to=None, reply_name=None):
|
||||||
|
"""Fire-and-forget wrapper. `config` is copied to a plain dict first so the
|
||||||
|
thread never touches the Flask app/request context."""
|
||||||
|
cfg = {k: config.get(k) for k in (
|
||||||
|
"SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", "SMTP_USE_TLS",
|
||||||
|
"SMTP_USE_SSL", "SMTP_TIMEOUT", "MAIL_FROM", "MAIL_SUBJECT_PREFIX",
|
||||||
|
)}
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=send_email,
|
||||||
|
args=(cfg, to, subject, body),
|
||||||
|
kwargs={"reply_to": reply_to, "reply_name": reply_name},
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
+16
@@ -46,6 +46,22 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
KEY idx_audit_created (created_at)
|
KEY idx_audit_created (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS demo_request (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
company VARCHAR(160) NULL,
|
||||||
|
email VARCHAR(200) NOT NULL,
|
||||||
|
phone VARCHAR(40) NULL,
|
||||||
|
preferred_date DATE NOT NULL,
|
||||||
|
preferred_time VARCHAR(5) NOT NULL, -- 'HH:MM', local business hours
|
||||||
|
message TEXT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'new', -- new/scheduled/done/cancelled
|
||||||
|
source_ip VARCHAR(45) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_demo_slot (preferred_date, preferred_time),
|
||||||
|
KEY idx_demo_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
-- Seed data (idempotent). Re-running updates content in place.
|
-- Seed data (idempotent). Re-running updates content in place.
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -215,6 +215,19 @@ form{display:inline}
|
|||||||
.badge--update{background:#FBF0E1;color:#8A5A12;border-color:#F0D9B3}
|
.badge--update{background:#FBF0E1;color:#8A5A12;border-color:#F0D9B3}
|
||||||
.badge--delete{background:#FBEAE6;color:#9C3325;border-color:#F0C7BE}
|
.badge--delete{background:#FBEAE6;color:#9C3325;border-color:#F0C7BE}
|
||||||
|
|
||||||
|
/* demo requests: per-row status + delete controls */
|
||||||
|
.inline-form{display:inline-flex;align-items:center;gap:6px;margin:0 0 6px}
|
||||||
|
.mini-select{
|
||||||
|
font:inherit;font-size:.82rem;color:var(--ink);background:var(--card);
|
||||||
|
border:1px solid var(--hair);border-radius:8px;padding:5px 8px;
|
||||||
|
}
|
||||||
|
.mini-select:focus{outline:none;border-color:var(--aqua)}
|
||||||
|
.anav__count{
|
||||||
|
display:inline-block;margin-left:6px;padding:1px 7px;border-radius:100px;
|
||||||
|
background:var(--aqua);color:#04201E;font-family:"IBM Plex Mono",monospace;
|
||||||
|
font-size:.68rem;font-weight:600;vertical-align:middle;
|
||||||
|
}
|
||||||
|
|
||||||
.pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:20px}
|
.pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:20px}
|
||||||
.pager__info{
|
.pager__info{
|
||||||
font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted);
|
font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted);
|
||||||
|
|||||||
@@ -217,6 +217,82 @@ body{
|
|||||||
font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.06em;
|
font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.06em;
|
||||||
padding:0 28px 40px;margin:0;
|
padding:0 28px 40px;margin:0;
|
||||||
}
|
}
|
||||||
|
.cta__alt{
|
||||||
|
margin:18px 0 0;font-size:.86rem;color:rgba(247,245,240,.6);
|
||||||
|
}
|
||||||
|
.cta__alt a{color:var(--aqua);text-decoration:none;border-bottom:1px solid rgba(23,176,166,.4)}
|
||||||
|
.cta__alt a:hover{border-bottom-color:var(--aqua)}
|
||||||
|
.cta--slim{margin-top:0;padding-top:40px}
|
||||||
|
|
||||||
|
/* ---------- BOOKING (demo appointment) ---------- */
|
||||||
|
.book{
|
||||||
|
position:relative;z-index:1;max-width:680px;margin:0 auto;
|
||||||
|
padding:56px 28px 72px;
|
||||||
|
}
|
||||||
|
.book__back{
|
||||||
|
display:inline-block;font-family:"IBM Plex Mono",monospace;font-size:.78rem;
|
||||||
|
letter-spacing:.06em;color:var(--muted);text-decoration:none;margin-bottom:34px;
|
||||||
|
}
|
||||||
|
.book__back:hover{color:var(--aqua-deep)}
|
||||||
|
.book__kicker{
|
||||||
|
font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.16em;
|
||||||
|
text-transform:uppercase;color:var(--aqua-deep);margin:0 0 14px;
|
||||||
|
}
|
||||||
|
.book__title{
|
||||||
|
font-family:"Bricolage Grotesque",sans-serif;font-weight:800;
|
||||||
|
font-size:clamp(2rem,6vw,3.1rem);line-height:1.02;letter-spacing:-.03em;margin:0;
|
||||||
|
}
|
||||||
|
.book__lede{color:var(--ink-soft);margin:18px 0 0;max-width:54ch}
|
||||||
|
.book__head{margin-bottom:38px}
|
||||||
|
|
||||||
|
.book__alerts{
|
||||||
|
border:1px solid #E4B7B7;background:#FBEDED;border-radius:var(--radius);
|
||||||
|
padding:14px 18px;margin-bottom:26px;
|
||||||
|
}
|
||||||
|
.book__alert{margin:0;color:#8C2F2F;font-size:.92rem}
|
||||||
|
.book__alert + .book__alert{margin-top:8px}
|
||||||
|
|
||||||
|
.book__form{display:flex;flex-direction:column;gap:22px}
|
||||||
|
.book__hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||||
|
.field{display:flex;flex-direction:column;gap:8px;min-width:0}
|
||||||
|
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:22px}
|
||||||
|
.field label{
|
||||||
|
font-family:"IBM Plex Mono",monospace;font-size:.76rem;letter-spacing:.08em;
|
||||||
|
text-transform:uppercase;color:var(--muted);
|
||||||
|
}
|
||||||
|
.field .req{color:var(--signal)}
|
||||||
|
.field input,.field select,.field textarea{
|
||||||
|
font:inherit;font-size:1rem;color:var(--ink);background:var(--white);
|
||||||
|
border:1px solid var(--hair);border-radius:10px;padding:12px 14px;width:100%;
|
||||||
|
transition:border-color .2s var(--ease),box-shadow .2s var(--ease);
|
||||||
|
}
|
||||||
|
.field textarea{resize:vertical;min-height:110px}
|
||||||
|
.field input:focus,.field select:focus,.field textarea:focus{
|
||||||
|
outline:none;border-color:var(--aqua);box-shadow:0 0 0 3px rgba(23,176,166,.16);
|
||||||
|
}
|
||||||
|
.hint{margin:0;font-size:.8rem;color:var(--muted)}
|
||||||
|
|
||||||
|
.book__submit{
|
||||||
|
align-self:flex-start;margin-top:6px;padding:16px 38px;border:none;cursor:pointer;
|
||||||
|
background:var(--aqua);color:#04201E;border-radius:100px;
|
||||||
|
font-family:"IBM Plex Mono",monospace;font-weight:600;font-size:.92rem;letter-spacing:.03em;
|
||||||
|
transition:transform .25s var(--ease),box-shadow .25s var(--ease);
|
||||||
|
}
|
||||||
|
.book__submit:hover{transform:translateY(-2px);box-shadow:0 12px 30px -10px rgba(23,176,166,.6)}
|
||||||
|
.book__submit:focus-visible{outline:2px solid var(--aqua-deep);outline-offset:3px}
|
||||||
|
.book__fallback{margin:0;font-size:.9rem;color:var(--muted)}
|
||||||
|
.book__fallback a{color:var(--aqua-deep)}
|
||||||
|
|
||||||
|
.book--done{text-align:center;padding-top:88px}
|
||||||
|
.book--done .book__lede{margin-left:auto;margin-right:auto}
|
||||||
|
.book--done .book__fallback{margin-top:22px}
|
||||||
|
.book--done .book__back{margin:34px 0 0}
|
||||||
|
.book__stamp{
|
||||||
|
width:64px;height:64px;margin:0 auto 26px;border-radius:50%;
|
||||||
|
background:var(--aqua);display:grid;place-items:center;
|
||||||
|
}
|
||||||
|
.book__stamp svg{width:30px;height:30px;fill:none;stroke:var(--white);stroke-width:2.4;
|
||||||
|
stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
/* ---------- RESPONSIVE ---------- */
|
/* ---------- RESPONSIVE ---------- */
|
||||||
@media (max-width:760px){
|
@media (max-width:760px){
|
||||||
@@ -226,6 +302,9 @@ body{
|
|||||||
.block{grid-template-columns:1fr;gap:22px;padding:40px 0}
|
.block{grid-template-columns:1fr;gap:22px;padding:40px 0}
|
||||||
.block__rail{position:static}
|
.block__rail{position:static}
|
||||||
.report{padding:0 22px}
|
.report{padding:0 22px}
|
||||||
|
.book{padding:40px 22px 56px}
|
||||||
|
.field-row{grid-template-columns:1fr}
|
||||||
|
.book__submit{align-self:stretch;text-align:center}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- A11Y ---------- */
|
/* ---------- A11Y ---------- */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
href="{{ url_for('admin.audit', action=val, entity=entity) }}">{{ label }}</a>
|
href="{{ url_for('admin.audit', action=val, entity=entity) }}">{{ label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="filters__sep">·</span>
|
<span class="filters__sep">·</span>
|
||||||
{% set entities = [('', 'All types'), ('section','section'), ('topic','topic')] %}
|
{% set entities = [('', 'All types'), ('section','section'), ('topic','topic'), ('demo','demo')] %}
|
||||||
{% for val, label in entities %}
|
{% for val, label in entities %}
|
||||||
<a class="filter-chip {{ 'is-active' if entity == val }}"
|
<a class="filter-chip {{ 'is-active' if entity == val }}"
|
||||||
href="{{ url_for('admin.audit', action=action, entity=val) }}">{{ label }}</a>
|
href="{{ url_for('admin.audit', action=action, entity=val) }}">{{ label }}</a>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
<nav class="anav">
|
<nav class="anav">
|
||||||
<a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a>
|
<a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a>
|
||||||
<div class="anav__right">
|
<div class="anav__right">
|
||||||
|
<a class="anav__link" href="{{ url_for('admin.demos') }}">Demo requests{% if pending_demos %}<span
|
||||||
|
class="anav__count">{{ pending_demos }}</span>{% endif %}</a>
|
||||||
<a class="anav__link" href="{{ url_for('admin.audit') }}">Audit</a>
|
<a class="anav__link" href="{{ url_for('admin.audit') }}">Audit</a>
|
||||||
<a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a>
|
<a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a>
|
||||||
<span class="anav__user">{{ session.get('admin') }}</span>
|
<span class="anav__user">{{ session.get('admin') }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% block title %}Demo requests{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Demo requests</h1>
|
||||||
|
<p class="muted">Appointments booked from the public page, soonest first.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Dashboard</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<span class="filters__label">Show</span>
|
||||||
|
{% set views = [('', 'Open'), ('new','new'), ('scheduled','scheduled'), ('done','done'), ('cancelled','cancelled')] %}
|
||||||
|
{% for val, label in views %}
|
||||||
|
<a class="filter-chip {{ 'is-active' if status == val }}"
|
||||||
|
href="{{ url_for('admin.demos', status=val) }}">{{ label }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if new_count %}<span class="filters__sep">·</span><span class="muted">{{ new_count }} new</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if requests %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Requested for</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Received (UTC)</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in requests %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono nowrap">{{ r.preferred_date.strftime('%Y-%m-%d') }} {{ r.preferred_time }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ r.name }}</strong><br>
|
||||||
|
<a href="mailto:{{ r.email }}">{{ r.email }}</a>
|
||||||
|
{% if r.phone %}<br><span class="mono">{{ r.phone }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ r.company or '—' }}</td>
|
||||||
|
<td>{{ r.message or '' }}</td>
|
||||||
|
<td class="mono nowrap">{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '—' }}</td>
|
||||||
|
<td><span class="badge badge--{{ 'create' if r.status == 'new' else ('update' if r.status == 'scheduled' else 'delete') }}">{{ r.status }}</span></td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<form method="post" action="{{ url_for('admin.demo_status', req_id=r.id) }}" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="back_status" value="{{ status }}">
|
||||||
|
<select name="status" class="mini-select">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if r.status == s }}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Set</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('admin.demo_delete', req_id=r.id) }}" class="inline-form"
|
||||||
|
onsubmit="return confirm('Delete the request from {{ r.name }}?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn--danger btn--sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<nav class="pager">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.demos', page=pagination.prev_num, status=status) }}">← Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">← Previous</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="pager__info">Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.demos', page=pagination.next_num, status=status) }}">Next →</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="btn btn--ghost btn--sm is-disabled">Next →</span>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">No demo requests{{ ' with this status' if status }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Request a demo — JQC · LT Services</title>
|
||||||
|
<meta name="description" content="Book a live demonstration of LT Services' JQC quality control platform.">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<main class="book">
|
||||||
|
<a class="book__back" href="{{ url_for('index') }}">← Back to JQC features</a>
|
||||||
|
|
||||||
|
<header class="book__head">
|
||||||
|
<p class="book__kicker">Live demonstration</p>
|
||||||
|
<h1 class="book__title">Book a demo</h1>
|
||||||
|
<p class="book__lede">
|
||||||
|
Pick a day and time that suits you and we'll walk you through JQC — inspections, issue tracking, SLA alerts
|
||||||
|
and reporting — on your own facilities' terms. Monday to Friday, {{ tz_label }}.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="book__alerts" role="alert">
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<p class="book__alert book__alert--{{ cat }}">{{ msg }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form class="book__form" method="post" action="{{ url_for('demo') }}" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<!-- honeypot: hidden from people, irresistible to bots -->
|
||||||
|
<div class="book__hp" aria-hidden="true">
|
||||||
|
<label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Your name <span class="req">*</span></label>
|
||||||
|
<input id="name" name="name" type="text" maxlength="120" required autocomplete="name"
|
||||||
|
value="{{ form.name or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="company">Company / facility</label>
|
||||||
|
<input id="company" name="company" type="text" maxlength="160" autocomplete="organization"
|
||||||
|
value="{{ form.company or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="phone">Phone</label>
|
||||||
|
<input id="phone" name="phone" type="tel" maxlength="40" autocomplete="tel"
|
||||||
|
value="{{ form.phone or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email <span class="req">*</span></label>
|
||||||
|
<input id="email" name="email" type="email" maxlength="200" required autocomplete="email"
|
||||||
|
value="{{ form.email or '' }}">
|
||||||
|
<p class="hint">We'll send a confirmation here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="preferred_date">Preferred date <span class="req">*</span></label>
|
||||||
|
<input id="preferred_date" name="preferred_date" type="date" required
|
||||||
|
min="{{ min_date }}" max="{{ max_date }}" value="{{ form.preferred_date or '' }}">
|
||||||
|
<p class="hint">Weekdays only.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="preferred_time">Preferred time <span class="req">*</span></label>
|
||||||
|
<select id="preferred_time" name="preferred_time" required>
|
||||||
|
<option value="">Select a time…</option>
|
||||||
|
{% for slot in slots %}
|
||||||
|
<option value="{{ slot }}" {{ 'selected' if form.preferred_time == slot }}>{{ slot }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="hint">{{ tz_label }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="message">Anything we should prepare?</label>
|
||||||
|
<textarea id="message" name="message" rows="4" maxlength="2000"
|
||||||
|
placeholder="Number of sites, current QC process, people who'll join…">{{ form.message or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="book__submit" type="submit">Request this appointment</button>
|
||||||
|
<p class="book__fallback">
|
||||||
|
Prefer email? <a href="{{ demo_url }}">Contact us directly</a>.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="cta cta--slim">
|
||||||
|
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Request received — JQC · LT Services</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<main class="book book--done">
|
||||||
|
<div class="book__stamp" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<polyline points="4 12 10 18 20 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="book__title">Request received</h1>
|
||||||
|
<p class="book__lede">
|
||||||
|
Thank you — your appointment request is with us. We've emailed you a copy, and someone from LT Services
|
||||||
|
will confirm the time shortly.
|
||||||
|
</p>
|
||||||
|
<p class="book__fallback">
|
||||||
|
Need to change something? <a href="{{ demo_url }}">Email us</a> or
|
||||||
|
<a href="{{ url_for('demo') }}">submit another time</a>.
|
||||||
|
</p>
|
||||||
|
<a class="book__back" href="{{ url_for('index') }}">← Back to JQC features</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="cta cta--slim">
|
||||||
|
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -110,7 +110,8 @@
|
|||||||
<div class="cta__inner">
|
<div class="cta__inner">
|
||||||
<p class="cta__kicker">Paperless · Maintenance-free · No subscription</p>
|
<p class="cta__kicker">Paperless · Maintenance-free · No subscription</p>
|
||||||
<h2 class="cta__title">LT would be glad to run a live demonstration.</h2>
|
<h2 class="cta__title">LT would be glad to run a live demonstration.</h2>
|
||||||
<a class="cta__btn" href="{{ demo_url }}">Request a demo</a>
|
<a class="cta__btn" href="{{ url_for('demo') }}">Request a demo</a>
|
||||||
|
<p class="cta__alt">Pick a day and time — or <a href="{{ demo_url }}">email us directly</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
<p class="cta__legal">© LT Services Inc. · JQC Quality Control Program</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user