Populate the client website template with catalog feature flags.

Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed.

Refs #1
Refs #2

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 07:55:26 -05:00
co-authored by Cursor
parent 45d0888d33
commit 787f0e48fb
297 changed files with 32534 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.venv
**/__pycache__/
*.py[cod]
db.sqlite3
.env
htmlcov/
.pytest_cache/
.mypy_cache/
*.log
staticfiles/
proposal/
template/
docs/
*.pptx
.~lock.*
+99
View File
@@ -0,0 +1,99 @@
# Local development defaults. Copy to `.env` (gitignored) — never commit secrets.
# docker compose auto-loads `.env` for ${VAR} substitution into the web container.
#
# After seeding a client: scripts/bootstrap-client.sh <slug> "<Site Name>" <domain>
# then copy this file and fill secrets.
DJANGO_ENV=dev
DJANGO_DEBUG=true
DJANGO_SECRET_KEY=dev-only-change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Leave empty for SQLite, or point at local compose Postgres:
# DATABASE_URL=postgres://client_site:client_site@127.0.0.1:5432/client_site
SITE_UNDER_CONSTRUCTION=false
SITE_NAME=Your Company
SITE_TAGLINE=A tagline for the public site
PUBLIC_SITE_URL=http://127.0.0.1:8000
CONTACT_PHONE=
CONTACT_EMAIL=hello@example.com
CONTACT_ADDRESS=
CONTACT_SERVICE_AREA=
CREDIT_NAME=AI ML Operations, LLC
CREDIT_URL=https://aimloperations.com
# ---------------------------------------------------------------------------
# Catalog feature flags
# Local/dev defaults ON so the template is a working seed.
# Client prod/beta secrets: false unless that add-on was purchased.
# Disabled apps are omitted from INSTALLED_APPS (no models, admin, URLs, nav).
# ---------------------------------------------------------------------------
FEATURE_EMAIL_SMS=true
FEATURE_DIRECT_MAIL=true
FEATURE_BLOG=true
FEATURE_PAYMENTS=true
FEATURE_SOCIAL=true
FEATURE_SOCIAL_AI=true
# reCAPTCHA (optional locally — form skips captcha when empty)
RECAPTCHA_PUBLIC_KEY=
RECAPTCHA_PRIVATE_KEY=
# SMTP2GO — required when FEATURE_EMAIL_SMS=true in beta/prod
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Inbox From. Docker Compose .env cannot parse: "Name" <addr>
# Use bare address (SITE_NAME is wrapped) OR one quoted string:
# DEFAULT_FROM_EMAIL="Your Company <noreply@example.com>"
DEFAULT_FROM_EMAIL=noreply@example.com
# Console (default in DJANGO_ENV=dev) prints mail to container logs.
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
SMTP2GO_SMS_API_KEY=
# Unified SMTP2GO webhook (email + SMS): …/portal/messaging/webhooks/smtp2go/
# Authorization = Bearer + this secret.
SMTP2GO_WEBHOOK_SECRET=
# Postcards — PCM Integrations. Required when FEATURE_DIRECT_MAIL=true in beta/prod
POSTCARD_PROVIDER=pcm
PCM_API_KEY=
PCM_API_SECRET=
# PCM_CHILD_REF_NBR=
PCM_WEBHOOK_SECRETS=
# PCM_WEBHOOK_SECRET=
# Quote JSON (compose/shell break on bare {…, …}).
# PCM_RETURN_ADDRESS='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}'
PCM_RETURN_ADDRESS=
# Stripe — required when FEATURE_PAYMENTS=true
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd
# Social — SOCIAL_TOKEN_ENCRYPTION_KEY required when FEATURE_SOCIAL=true
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
META_APP_ID=
META_APP_SECRET=
# Generate: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SOCIAL_TOKEN_ENCRYPTION_KEY=
# Ollama (LAN) — FEATURE_SOCIAL_AI. Default is the internal host, not public Ollama.
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
# Nominatim (LAN) — address autocomplete via Django /api/address-suggest/
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
NOMINATIM_COUNTRY_CODES=us
# Tianji analytics (off in local/dev by default)
TIANJI_ENABLED=false
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=
# DISPATCH_DUE_INTERVAL_SECONDS=15
GUNICORN_WORKERS=2
+92
View File
@@ -0,0 +1,92 @@
# Secret env files for server-infra deploy. Never commit.
# Copy on the control node:
# ~/Documents/secrets/<slug>_site/<slug>_site_prod.env
# ~/Documents/secrets/<slug>_site/<slug>_site_beta.env
#
# Docker Compose: if a secret contains $ (e.g. DATABASE_URL password), escape
# each $ as $$ or compose will treat $word as a variable.
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
SITE_UNDER_CONSTRUCTION=true
SITE_NAME=Your Company
SITE_TAGLINE=A tagline for the public site
PUBLIC_SITE_URL=https://example.com
DEFAULT_FROM_EMAIL=noreply@example.com
CONTACT_PHONE=
CONTACT_EMAIL=
CONTACT_ADDRESS=
CONTACT_SERVICE_AREA=
CREDIT_NAME=AI ML Operations, LLC
CREDIT_URL=https://aimloperations.com
# Optional catalog features — false unless purchased.
# Turning a flag on later: set true, redeploy, migrate. Do not auto-drop tables.
FEATURE_EMAIL_SMS=false
FEATURE_DIRECT_MAIL=false
FEATURE_BLOG=false
FEATURE_PAYMENTS=false
FEATURE_SOCIAL=false
FEATURE_SOCIAL_AI=false
# Shared external Postgres (10.0.0.230)
DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site
WEB_PORT=8004
RECAPTCHA_PUBLIC_KEY=replace-me
RECAPTCHA_PRIVATE_KEY=replace-me
# Required when FEATURE_EMAIL_SMS=true
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525
EMAIL_USE_TLS=true
SMTP2GO_SMS_API_KEY=
SMTP2GO_WEBHOOK_SECRET=
# Required when FEATURE_DIRECT_MAIL=true
POSTCARD_PROVIDER=pcm
PCM_API_KEY=
PCM_API_SECRET=
PCM_WEBHOOK_SECRETS=
PCM_RETURN_ADDRESS=
# Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_CURRENCY=usd
# Required when FEATURE_SOCIAL=true
META_APP_ID=
META_APP_SECRET=
SOCIAL_TOKEN_ENCRYPTION_KEY=
# Required when FEATURE_SOCIAL_AI=true (requires FEATURE_SOCIAL)
OLLAMA_BASE_URL=http://10.0.0.128:11434
OLLAMA_MODEL=llama3.2
OLLAMA_TIMEOUT_SECONDS=120
NOMINATIM_BASE_URL=http://10.0.0.128:8089
NOMINATIM_TIMEOUT_SECONDS=8
NOMINATIM_COUNTRY_CODES=us
TIANJI_ENABLED=true
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# BETA overrides — <slug>-preview.aimloperations.com
# DJANGO_ENV=beta
# SITE_UNDER_CONSTRUCTION=false
# PUBLIC_SITE_URL=https://example-preview.aimloperations.com
# DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site_beta
# WEB_PORT=8014
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
+74
View File
@@ -0,0 +1,74 @@
name: Deploy Beta
on:
push:
branches:
- main
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="client-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
deploy-beta:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy client_site beta to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app client_site \
--env beta \
--ref "${{ gitea.sha }}"
+72
View File
@@ -0,0 +1,72 @@
name: Deploy Prod
on:
workflow_dispatch: {}
jobs:
unit-tests:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
DATABASE_URL: ""
DB_HOST: ""
run: |
cd site
uv run python manage.py test
docker:
needs: unit-tests
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
set -euo pipefail
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="client-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://client_site:client_site@db:5432/client_site \
-e SITE_UNDER_CONSTRUCTION=false \
web uv run python manage.py test
deploy-prod:
needs: docker
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps:
- name: Deploy client_site prod to all webservers
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app client_site \
--env prod \
--ref "${{ gitea.sha }}"
+5 -2
View File
@@ -144,8 +144,8 @@ venv.bak/
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mkdocs documentation (do not ignore ./site — that is the Django project)
# /site
# mypy
.mypy_cache/
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file
.pypirc
# Django collectstatic output
staticfiles/
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY site ./site
COPY scripts/docker-entrypoint.sh /entrypoint.sh
COPY scripts/worker-entrypoint.sh /worker-entrypoint.sh
RUN chmod +x /entrypoint.sh /worker-entrypoint.sh
WORKDIR /app/site
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+61 -1
View File
@@ -1,3 +1,63 @@
# web_django_template
Template repository for web design products
Gitea template for AI ML Operations client websites
([Web Design & Hosting](https://aimloperations.com/web_design)).
Clone this repo (or **Use this template**), run `scripts/bootstrap-client.sh`,
set branding + `FEATURE_*` flags in the secret env file, deploy.
Always included: public site (landing, about, contact) and client portal
(login, dashboard, leads, UTM analytics). Optional catalog add-ons are
separate Django apps. A flag that is off keeps the app out of
`INSTALLED_APPS` — no models, admin, URLs, or portal nav.
## Catalog → flags
| Catalog item | Flag | App |
|---|---|---|
| Public site | (always on) | `public` |
| Client portal + UTM | (always on) | `accounts`, `dashboard`, `leads`, `analytics`, `contacts`, `core` |
| Email & SMS | `FEATURE_EMAIL_SMS` | `email_sms` |
| Direct mail (PCM) | `FEATURE_DIRECT_MAIL` | `directmail` |
| Blog | `FEATURE_BLOG` | `blog` |
| Payments (Stripe) | `FEATURE_PAYMENTS` | `payments` (requires email/SMS) |
| Social consolidation | `FEATURE_SOCIAL` | `social` |
| AI social generator | `FEATURE_SOCIAL_AI` | `social_ai` (requires social) |
## New client
```bash
# 1. Create repo from this Gitea template, clone it.
# 2. Rename package + branding placeholders:
./scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com
# 3. Brand public templates and static/brand/ (logo, colors, copy).
# 4. Secrets on the control node:
mkdir -p ~/Documents/secrets/acme_site
cp .env.prod.example ~/Documents/secrets/acme_site/acme_site_prod.env
# Flip only purchased FEATURE_* flags. Fill provider secrets.
./scripts/validate-env.sh ~/Documents/secrets/acme_site/acme_site_prod.env
# 5. Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI.
# 6. Deploy. Create a portal user (createsuperuser).
```
Local:
```bash
cp .env.example .env
uv sync
cd site
uv run python manage.py migrate
uv run python manage.py runserver
# or: docker compose up
```
## Stack
Django 6 + uv · Postgres (prod/beta); SQLite locally · dj-queue · Docker ·
server-infra Ansible deploy.
Ollama for social drafts lives on the LAN at `http://10.0.0.128:11434`.
Do not call a public Ollama host.
+23
View File
@@ -0,0 +1,23 @@
# Production compose for server-infra deploy. No bundled Postgres — use shared
# external DB via DATABASE_URL in .env (see .env.prod.example).
#
# Web runs on every app host (active/active). Start the dj-queue worker on
# exactly ONE host via:
# docker compose -f docker-compose.prod.yml --profile worker up -d
# Worker also polls dispatch_due so scheduled campaigns/social posts fire.
services:
web:
build: .
restart: unless-stopped
ports:
- "${WEB_PORT:-8004}:8000"
env_file:
- .env
worker:
build: .
restart: unless-stopped
profiles: ["worker"]
entrypoint: ["/worker-entrypoint.sh"]
env_file:
- .env
+116
View File
@@ -0,0 +1,116 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: client_site
POSTGRES_USER: client_site
POSTGRES_PASSWORD: client_site
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U client_site -d client_site"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
web:
build: .
ports:
- "8000:8000"
# Bind-mount source so edit → runserver auto-reload (no rebuild).
volumes:
- ./site:/app/site
# No required env_file — CI has no .env. Defaults below; for local secrets:
# docker compose --env-file .env up
#
# Do NOT interpolate ${DATABASE_URL} here. On the Act runner / control node that
# var often points at shared prod/beta Postgres; compose would bake it into
# containerized tests. Use COMPOSE_DATABASE_URL only if you need to override.
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
SITE_UNDER_CONSTRUCTION: ${SITE_UNDER_CONSTRUCTION:-false}
FEATURE_EMAIL_SMS: ${FEATURE_EMAIL_SMS:-true}
FEATURE_DIRECT_MAIL: ${FEATURE_DIRECT_MAIL:-true}
FEATURE_BLOG: ${FEATURE_BLOG:-true}
FEATURE_PAYMENTS: ${FEATURE_PAYMENTS:-true}
FEATURE_SOCIAL: ${FEATURE_SOCIAL:-true}
FEATURE_SOCIAL_AI: ${FEATURE_SOCIAL_AI:-true}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
# Dev default = console. Set smtp backend in `.env` for real SMTP2GO sends.
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm}
PCM_API_KEY: ${PCM_API_KEY:-}
PCM_API_SECRET: ${PCM_API_SECRET:-}
PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-}
PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-}
PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-}
# Quote JSON in `.env`: PCM_RETURN_ADDRESS='{"firstName":"…",...}'
PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-}
PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-}
PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-}
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
depends_on:
db:
condition: service_healthy
worker:
build: .
entrypoint: ["/worker-entrypoint.sh"]
volumes:
- ./site:/app/site
environment:
DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://client_site:client_site@db:5432/client_site}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://127.0.0.1:8000}
DISPATCH_DUE_INTERVAL_SECONDS: ${DISPATCH_DUE_INTERVAL_SECONDS:-15}
EMAIL_HOST: ${EMAIL_HOST:-mail.smtp2go.com}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_PORT: ${EMAIL_PORT:-2525}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-true}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.com}
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm}
PCM_API_KEY: ${PCM_API_KEY:-}
PCM_API_SECRET: ${PCM_API_SECRET:-}
PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-}
PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-}
PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-}
PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-}
PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-}
PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-}
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
META_APP_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "web-django-template"
version = "0.1.0"
description = "Template for AI ML Operations client websites (public site + portal + optional catalog apps)"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"django>=6.0,<7",
"dj-queue[postgres]>=0.13.0",
"django-phonenumber-field>=8.1.0",
"django-recaptcha>=4.1.0",
"cryptography>=44.0.0",
"gunicorn>=23.0.0",
"phonenumbers>=9.0.0",
"psycopg[binary]>=3.3.3",
"requests>=2.32.0",
"stripe>=11.0.0",
"whitenoise>=6.9.0",
]
[dependency-groups]
dev = [
"pre-commit>=4.1.0",
]
[tool.uv]
package = false
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Seed a client repo from this template.
# Usage: scripts/bootstrap-client.sh <slug> "<Site Name>" <domain>
# Example: scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com
set -euo pipefail
SLUG="${1:?Usage: $0 <slug> \"<Site Name>\" <domain>}"
SITE_NAME="${2:?Usage: $0 <slug> \"<Site Name>\" <domain>}"
DOMAIN="${3:?Usage: $0 <slug> \"<Site Name>\" <domain>}"
if [[ ! "$SLUG" =~ ^[a-z][a-z0-9_]*$ ]]; then
echo "slug must be snake_case starting with a letter (e.g. acme_site)" >&2
exit 1
fi
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ "$SLUG" == "client_site" ]]; then
echo "slug is already client_site — branding defaults only."
else
echo "Renaming package client_site → $SLUG"
if [[ -d site/client_site ]]; then
mv site/client_site "site/$SLUG"
fi
grep -rl 'client_site' \
--exclude-dir=.git --exclude-dir=.venv --exclude-dir=__pycache__ \
--exclude='*.png' --exclude='*.jpg' --exclude='*.woff*' --exclude='*.eot' \
--exclude='uv.lock' . \
| while read -r f; do
sed -i "s/client_site/${SLUG}/g" "$f"
done
fi
python3 -c "
from pathlib import Path
name, domain = '''${SITE_NAME}''', '''${DOMAIN}'''
for rel in ('.env.example', '.env.prod.example'):
p = Path(rel)
if not p.exists():
continue
text = p.read_text()
text = text.replace('SITE_NAME=Your Company', f'SITE_NAME={name}')
text = text.replace('hello@example.com', f'hello@{domain}')
text = text.replace('noreply@example.com', f'noreply@{domain}')
text = text.replace('PUBLIC_SITE_URL=https://example.com', f'PUBLIC_SITE_URL=https://{domain}')
text = text.replace('DJANGO_ALLOWED_HOSTS=example.com,www.example.com',
f'DJANGO_ALLOWED_HOSTS={domain},www.{domain}')
p.write_text(text)
"
echo
echo "Next:"
echo " 1. Brand public templates + static/brand/ (logo, colors, copy)."
echo " 2. Copy .env.prod.example → ~/Documents/secrets/${SLUG}/${SLUG}_prod.env"
echo " Flip only the FEATURE_* flags they bought. Fill provider secrets."
echo " 3. Validate: ./scripts/validate-env.sh ~/Documents/secrets/${SLUG}/${SLUG}_prod.env"
echo " 4. Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM vhost, CI deploy."
echo " 5. Deploy. Smoke public home/contact, portal login, leads, UTM."
echo " Disabled feature URLs must 404 and must not appear in portal nav."
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/site
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
uv run python manage.py migrate --noinput
# Local compose defaults to DJANGO_ENV=dev: runserver + source bind-mount → hot reload.
# Prod/beta images keep gunicorn (no file watch).
if [[ "${DJANGO_ENV:-}" == "dev" || "${DJANGO_USE_RUNSERVER:-}" == "true" ]]; then
echo "Starting Django runserver (auto-reload on)."
exec uv run python manage.py runserver "${GUNICORN_BIND:-0.0.0.0:8000}"
fi
uv run python manage.py collectstatic --noinput
exec uv run gunicorn client_site.wsgi:application \
--bind "${GUNICORN_BIND:-0.0.0.0:8000}" \
--workers "${GUNICORN_WORKERS:-2}"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:?Usage: $0 <env-file>}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file not found: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
DJANGO_ENV="${DJANGO_ENV:-prod}"
_true() { [[ "${1:-}" =~ ^(1|true|yes|on)$ ]]; }
required_vars=(
DJANGO_SECRET_KEY
DJANGO_ALLOWED_HOSTS
DATABASE_URL
WEB_PORT
)
if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then
required_vars+=(
RECAPTCHA_PUBLIC_KEY
RECAPTCHA_PRIVATE_KEY
)
fi
if [[ "$DJANGO_ENV" == "prod" ]]; then
required_vars+=(
SITE_UNDER_CONSTRUCTION
TIANJI_WEBSITE_ID
)
fi
if _true "${FEATURE_EMAIL_SMS:-}"; then
required_vars+=(
EMAIL_HOST_USER
EMAIL_HOST_PASSWORD
SMTP2GO_SMS_API_KEY
SMTP2GO_WEBHOOK_SECRET
)
fi
if _true "${FEATURE_DIRECT_MAIL:-}"; then
required_vars+=(
PCM_API_KEY
PCM_API_SECRET
PCM_WEBHOOK_SECRETS
PCM_RETURN_ADDRESS
)
fi
if _true "${FEATURE_PAYMENTS:-}"; then
required_vars+=(
STRIPE_SECRET_KEY
STRIPE_PUBLISHABLE_KEY
STRIPE_WEBHOOK_SECRET
)
if ! _true "${FEATURE_EMAIL_SMS:-}"; then
echo "FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS" >&2
exit 1
fi
fi
if _true "${FEATURE_SOCIAL:-}"; then
required_vars+=(SOCIAL_TOKEN_ENCRYPTION_KEY)
fi
if _true "${FEATURE_SOCIAL_AI:-}"; then
required_vars+=(OLLAMA_BASE_URL OLLAMA_MODEL)
if ! _true "${FEATURE_SOCIAL:-}"; then
echo "FEATURE_SOCIAL_AI requires FEATURE_SOCIAL" >&2
exit 1
fi
fi
missing=()
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
done
if ((${#missing[@]} > 0)); then
echo "Missing required environment variables in $ENV_FILE:" >&2
printf ' - %s\n' "${missing[@]}" >&2
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>_site/<slug>_site_${DJANGO_ENV}.env and set values." >&2
exit 1
fi
if [[ "$DJANGO_ENV" == "prod" && "$DJANGO_SECRET_KEY" == change-me* ]]; then
echo "DJANGO_SECRET_KEY must be changed from the example value for production." >&2
exit 1
fi
echo "Environment validation passed (DJANGO_ENV=$DJANGO_ENV)."
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
cd /app/site
wait_for_database() {
if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then
return 0
fi
echo "Waiting for database..."
for _ in $(seq 1 30); do
if uv run python - <<'PY'
import os
import sys
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
import django
from django.db import connections
from django.db.utils import OperationalError
django.setup()
try:
connections["default"].ensure_connection()
except OperationalError:
sys.exit(1)
PY
then
echo "Database is ready."
return 0
fi
sleep 2
done
echo "Database did not become ready in time." >&2
exit 1
}
wait_for_database
uv run python manage.py migrate --noinput
# Email branding (and anything else that resolves static URLs) needs the
# ManifestStaticFilesStorage map. Web entrypoint already collectstatics;
# worker must too or hashed lookups raise ValueError.
uv run python manage.py collectstatic --noinput
interval="${DISPATCH_DUE_INTERVAL_SECONDS:-15}"
echo "Starting dispatch_due poller (every ${interval}s)."
uv run python manage.py dispatch_due --loop --interval "${interval}" &
dispatch_pid=$!
cleanup() {
echo "Stopping dispatch_due poller..."
kill "${dispatch_pid}" 2>/dev/null || true
wait "${dispatch_pid}" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Dev uses ImmediateBackend: enqueue runs inline in dispatch_due. No dj-queue.
if [[ "${DJANGO_ENV:-}" == "dev" ]]; then
echo "Dev worker: dispatch_due loop publishes scheduled work."
wait "${dispatch_pid}"
exit $?
fi
# dj-queue supervisor (workers + dispatcher + scheduler). Run on ONE host only.
uv run python manage.py dj_queue
View File
+9
View File
@@ -0,0 +1,9 @@
from django.contrib import admin
from accounts.models import RealtorProfile
@admin.register(RealtorProfile)
class RealtorProfileAdmin(admin.ModelAdmin):
list_display = ("user", "display_name", "phone")
search_fields = ("user__username", "display_name")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
+33
View File
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RealtorProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('display_name', models.CharField(blank=True, max_length=120)),
('phone', models.CharField(blank=True, max_length=32)),
('title', models.CharField(blank=True, max_length=120)),
('bio', models.TextField(blank=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='realtor_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]
+19
View File
@@ -0,0 +1,19 @@
from django.conf import settings
from django.db import models
from core.models import TimeStampedModel
class RealtorProfile(TimeStampedModel):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="realtor_profile",
)
display_name = models.CharField(max_length=120, blank=True)
phone = models.CharField(max_length=32, blank=True)
title = models.CharField(max_length=120, blank=True)
bio = models.TextField(blank=True)
def __str__(self) -> str:
return self.display_name or self.user.get_username()
@@ -0,0 +1,42 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Portal · {{ SITE_NAME }}</title>
<link rel="icon" href="{% static 'brand/favicon-32.png' %}" type="image/png">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Work+Sans:300,400,500,700%7CPoppins:400,600,700">
<link rel="stylesheet" href="{% static 'css/portal.css' %}">
</head>
<body class="portal">
<div class="login-wrap">
<div class="login-card">
<h1>{{ SITE_NAME }} portal</h1>
<p class="sub">{{ SITE_TAGLINE }}</p>
<p class="sub" style="margin-top:0;margin-bottom:16px;font-size:13px">Leads, campaigns, and social — one place.</p>
{% if form.errors %}
<ul class="portal-flash">
<li class="error">Invalid email or password.</li>
</ul>
{% endif %}
<form class="form-grid" method="post" action="{% url 'accounts:login' %}">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<div class="field">
<label for="id_username">Email</label>
<input id="id_username" type="text" name="username" autocomplete="username" required value="{{ form.username.value|default:'' }}">
</div>
<div class="field">
<label for="id_password">Password</label>
<input id="id_password" type="password" name="password" autocomplete="current-password" required>
</div>
<button class="btn btn-primary" type="submit" data-tianji-event="login_submit">Sign in</button>
</form>
<p style="margin-top:16px;font-size:13px;color:#6b7280">
<a href="{% url 'public:home' %}">← Back to site</a>
</p>
</div>
</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
from django.contrib.auth import views as auth_views
from django.urls import path
app_name = "accounts"
urlpatterns = [
path(
"login/",
auth_views.LoginView.as_view(template_name="accounts/login.html"),
name="login",
),
path(
"logout/",
auth_views.LogoutView.as_view(),
name="logout",
),
]
View File
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from analytics.models import Attribution, PageView, UTMVisit
@admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin):
list_display = ("path", "created_at")
list_filter = ("created_at",)
search_fields = ("path",)
date_hierarchy = "created_at"
readonly_fields = ("id", "path", "created_at", "updated_at")
@admin.register(UTMVisit)
class UTMVisitAdmin(admin.ModelAdmin):
list_display = ("utm_source", "utm_campaign", "path", "created_at")
search_fields = ("utm_source", "utm_campaign", "correlation_id")
@admin.register(Attribution)
class AttributionAdmin(admin.ModelAdmin):
list_display = ("lead", "utm_source", "utm_campaign", "created_at")
+17
View File
@@ -0,0 +1,17 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "analytics"
def ready(self):
from core.registry import register_portal_nav
register_portal_nav(
section="analytics",
label="Analytics",
url_name="analytics:report",
group="Overview",
order=30,
)
+106
View File
@@ -0,0 +1,106 @@
import logging
from django.utils.crypto import get_random_string
from analytics.models import Attribution, PageView, UTMVisit
logger = logging.getLogger(__name__)
CORRELATION_COOKIE = "ms_cid"
# Portal, auth, health, assets, and transactional public endpoints.
_SKIP_PREFIXES = (
"/portal/",
"/admin/",
"/accounts/",
"/api/",
"/healthz",
"/static/",
"/media/",
"/unsubscribe/",
)
_SKIP_PATHS = frozenset({"/robots.txt", "/sitemap.xml", "/favicon.ico"})
class PublicPageViewMiddleware:
"""Record successful GET hits on public marketing pages."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if _should_record_page_view(request, response):
try:
PageView.objects.create(path=request.path[:512])
except Exception:
logger.exception("Failed to record page view for %s", request.path)
return response
def _should_record_page_view(request, response) -> bool:
if request.method != "GET":
return False
if getattr(response, "status_code", 0) != 200:
return False
path = request.path
if path in _SKIP_PATHS:
return False
return not any(path.startswith(prefix) for prefix in _SKIP_PREFIXES)
class UTMTrackingMiddleware:
"""Capture UTM params into UTMVisit and stash a correlation id cookie."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
cid = request.COOKIES.get(CORRELATION_COOKIE) or get_random_string(32)
request.utm_correlation_id = cid
params = request.GET
has_utm = any(params.get(k) for k in (
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"
))
if has_utm or params.get("utm_source"):
UTMVisit.objects.create(
correlation_id=cid,
path=request.path[:512],
referrer=(request.META.get("HTTP_REFERER") or "")[:1024],
utm_source=params.get("utm_source", "")[:128],
utm_medium=params.get("utm_medium", "")[:128],
utm_campaign=params.get("utm_campaign", "")[:128],
utm_term=params.get("utm_term", "")[:128],
utm_content=params.get("utm_content", "")[:128],
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:512],
)
response = self.get_response(request)
if CORRELATION_COOKIE not in request.COOKIES:
response.set_cookie(
CORRELATION_COOKIE,
cid,
max_age=60 * 60 * 24 * 30,
samesite="Lax",
)
return response
def attribute_lead_from_request(request, lead) -> Attribution | None:
cid = getattr(request, "utm_correlation_id", None) or request.COOKIES.get(
CORRELATION_COOKIE
)
visit = None
if cid:
visit = (
UTMVisit.objects.filter(correlation_id=cid).order_by("-created_at").first()
)
return Attribution.objects.create(
lead=lead,
visit=visit,
utm_source=visit.utm_source if visit else "",
utm_medium=visit.utm_medium if visit else "",
utm_campaign=visit.utm_campaign if visit else "",
)
+66
View File
@@ -0,0 +1,66 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('leads', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UTMVisit',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('correlation_id', models.CharField(db_index=True, max_length=64)),
('path', models.CharField(blank=True, max_length=512)),
('referrer', models.URLField(blank=True, max_length=1024)),
('utm_source', models.CharField(blank=True, max_length=128)),
('utm_medium', models.CharField(blank=True, max_length=128)),
('utm_campaign', models.CharField(blank=True, max_length=128)),
('utm_term', models.CharField(blank=True, max_length=128)),
('utm_content', models.CharField(blank=True, max_length=128)),
('user_agent', models.CharField(blank=True, max_length=512)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='PageView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('path', models.CharField(db_index=True, max_length=512)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['created_at', 'path'], name='analytics_p_created_9e8b64_idx')],
},
),
migrations.CreateModel(
name='Attribution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('utm_source', models.CharField(blank=True, max_length=128)),
('utm_medium', models.CharField(blank=True, max_length=128)),
('utm_campaign', models.CharField(blank=True, max_length=128)),
('lead', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='attribution', to='leads.lead')),
('visit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='analytics.utmvisit')),
],
options={
'abstract': False,
},
),
]
+52
View File
@@ -0,0 +1,52 @@
from django.db import models
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
from leads.models import Lead
class PageView(UUIDPrimaryKeyModel, TimeStampedModel):
"""One successful GET of a public marketing page."""
path = models.CharField(max_length=512, db_index=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["created_at", "path"]),
]
def __str__(self) -> str:
return self.path or "/"
class UTMVisit(UUIDPrimaryKeyModel, TimeStampedModel):
correlation_id = models.CharField(max_length=64, db_index=True)
path = models.CharField(max_length=512, blank=True)
referrer = models.URLField(blank=True, max_length=1024)
utm_source = models.CharField(max_length=128, blank=True)
utm_medium = models.CharField(max_length=128, blank=True)
utm_campaign = models.CharField(max_length=128, blank=True)
utm_term = models.CharField(max_length=128, blank=True)
utm_content = models.CharField(max_length=128, blank=True)
user_agent = models.CharField(max_length=512, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.utm_source or 'direct'} / {self.path}"
class Attribution(TimeStampedModel):
lead = models.OneToOneField(
Lead, on_delete=models.CASCADE, related_name="attribution"
)
visit = models.ForeignKey(
UTMVisit, null=True, blank=True, on_delete=models.SET_NULL
)
utm_source = models.CharField(max_length=128, blank=True)
utm_medium = models.CharField(max_length=128, blank=True)
utm_campaign = models.CharField(max_length=128, blank=True)
def __str__(self) -> str:
return f"attr {self.lead_id}{self.utm_source or 'direct'}"
+3
View File
@@ -0,0 +1,3 @@
"""Analytics services."""
from analytics.middleware import attribute_lead_from_request # noqa: F401
@@ -0,0 +1,150 @@
{% extends "portal_base.html" %}
{% block title %}Analytics · Portal{% endblock %}
{% block topbar_title %}Analytics{% endblock %}
{% block portal_content %}
<div class="analytics-overview">
<div class="stat-card">
<div class="label">Views (last 30 days)</div>
<div class="value">{{ pageviews_last_30_days }}</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Top pages</h2><span class="muted" style="font-size:12px">Public visits, last 30 days</span></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Page</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
{% for row in top_pages %}
<tr>
<td>{{ row.path }}</td>
<td>{{ row.count }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="empty-state">No public page views in the last 30 days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">UTM landings</div>
<div class="value">{{ total_visits }}</div>
</div>
<div class="stat-card">
<div class="label">Attributed leads</div>
<div class="value">{{ total_attributed }}</div>
</div>
<div class="stat-card">
<div class="label">Top source</div>
<div class="value" style="font-size:18px">{{ top_source|default:"—" }}</div>
</div>
<div class="stat-card">
<div class="label">Top campaign</div>
<div class="value" style="font-size:18px">{{ top_campaign|default:"—" }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Landing volume by source</h2></div>
<div class="panel-b">
<div class="chart-placeholder">
{% for row in visits_by_source|slice:":8" %}
<div class="bar" style="height:{{ row.bar_pct }}%" title="{{ row.utm_source|default:'(direct)' }}: {{ row.count }}"></div>
{% empty %}
<div class="bar" style="height:12%"></div>
{% endfor %}
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>UTM landings</h2><span class="muted" style="font-size:12px">From ?utm_* hits</span></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Source</th>
<th>Medium</th>
<th>Campaign</th>
<th>Landings</th>
</tr>
</thead>
<tbody>
{% for row in visits_by_combo %}
<tr>
<td>{{ row.utm_source|default:"(direct)" }}</td>
<td>{{ row.utm_medium|default:"—" }}</td>
<td>{{ row.utm_campaign|default:"—" }}</td>
<td>{{ row.count }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No UTM landings yet. Open a public URL with ?utm_source=…</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recent landings</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>When</th>
<th>Path</th>
<th>Source</th>
<th>Campaign</th>
</tr>
</thead>
<tbody>
{% for visit in recent_visits %}
<tr>
<td>{{ visit.created_at|date:"M j, g:i A" }}</td>
<td>{{ visit.path }}</td>
<td>{{ visit.utm_source|default:"—" }}</td>
<td>{{ visit.utm_campaign|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No visits recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Lead attribution</h2><span class="muted" style="font-size:12px">After contact-form submit</span></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Source</th>
<th>Medium</th>
<th>Campaign</th>
<th>Leads</th>
</tr>
</thead>
<tbody>
{% for row in leads_by_combo %}
<tr>
<td>{{ row.utm_source|default:"(direct)" }}</td>
<td>{{ row.utm_medium|default:"—" }}</td>
<td>{{ row.utm_campaign|default:"—" }}</td>
<td>{{ row.count }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No attributed leads yet — submit the contact form after a UTM landing.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from analytics.models import PageView, UTMVisit
class PublicPageViewTests(TestCase):
def setUp(self):
self.client = Client()
def test_home_records_page_view(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(PageView.objects.get().path, "/")
def test_about_records_page_view(self):
self.client.get(reverse("public:about"))
self.assertEqual(PageView.objects.filter(path="/about/").count(), 1)
def test_plain_visit_does_not_create_utm_visit(self):
self.client.get("/")
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(UTMVisit.objects.count(), 0)
def test_utm_hit_records_both(self):
self.client.get("/?utm_source=test&utm_campaign=demo")
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(UTMVisit.objects.count(), 1)
def test_healthz_not_recorded(self):
self.client.get("/healthz/")
self.assertEqual(PageView.objects.count(), 0)
def test_portal_and_admin_not_recorded(self):
self.client.get("/portal/")
self.client.get("/admin/")
self.assertEqual(PageView.objects.count(), 0)
def test_robots_and_sitemap_not_recorded(self):
self.client.get("/robots.txt")
self.client.get("/sitemap.xml")
self.assertEqual(PageView.objects.count(), 0)
def test_missing_page_not_recorded(self):
response = self.client.get("/not-a-real-page/")
self.assertEqual(response.status_code, 404)
self.assertEqual(PageView.objects.count(), 0)
def test_contact_post_not_recorded(self):
self.client.post(reverse("public:contact"), {})
self.assertEqual(PageView.objects.count(), 0)
class AnalyticsReportTests(TestCase):
def setUp(self):
self.client = Client()
user = get_user_model().objects.create_user(
username="monica", password="pass-word-1"
)
self.client.force_login(user)
def test_views_card_uses_public_pageviews(self):
self.client.get("/")
self.client.get(reverse("public:about"))
self.client.get(reverse("public:about"))
stale = PageView.objects.create(path="/terms/")
PageView.objects.filter(pk=stale.pk).update(
created_at=timezone.now() - timedelta(days=31)
)
response = self.client.get(reverse("analytics:report"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["pageviews_last_30_days"], 3)
top = {row["path"]: row["count"] for row in response.context["top_pages"]}
self.assertEqual(top["/about/"], 2)
self.assertEqual(top["/"], 1)
self.assertNotIn("/terms/", top)
self.assertContains(response, "Top pages")
self.assertContains(response, "/about/")
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from analytics import views
app_name = "analytics"
urlpatterns = [
path("", views.report, name="report"),
]
+79
View File
@@ -0,0 +1,79 @@
from datetime import timedelta
from django.contrib.auth.decorators import login_required
from django.db.models import Count
from django.shortcuts import render
from django.utils import timezone
from analytics.models import Attribution, PageView, UTMVisit
def _bar_pct(rows, key="count"):
max_count = max((row[key] for row in rows), default=1) or 1
for row in rows:
row["bar_pct"] = max(12, int(100 * row[key] / max_count))
return rows
@login_required
def report(request):
since_30d = timezone.now() - timedelta(days=30)
pageviews_qs = PageView.objects.filter(created_at__gte=since_30d)
pageviews_last_30_days = pageviews_qs.count()
top_pages = list(
pageviews_qs.values("path").annotate(count=Count("id")).order_by("-count")[:20]
)
visits_by_source = _bar_pct(
list(
UTMVisit.objects.values("utm_source")
.annotate(count=Count("id"))
.order_by("-count")[:20]
)
)
visits_by_combo = list(
UTMVisit.objects.values("utm_source", "utm_medium", "utm_campaign")
.annotate(count=Count("id"))
.order_by("-count")[:50]
)
top_visit = visits_by_source[0] if visits_by_source else None
top_visit_campaign = (
UTMVisit.objects.exclude(utm_campaign="")
.values("utm_campaign")
.annotate(count=Count("id"))
.order_by("-count")
.first()
)
leads_by_source = _bar_pct(
list(
Attribution.objects.values("utm_source")
.annotate(count=Count("id"))
.order_by("-count")[:20]
)
)
leads_by_combo = list(
Attribution.objects.values("utm_source", "utm_medium", "utm_campaign")
.annotate(count=Count("id"))
.order_by("-count")[:50]
)
recent_visits = UTMVisit.objects.all()[:25]
return render(
request,
"analytics/report.html",
{
"pageviews_last_30_days": pageviews_last_30_days,
"top_pages": top_pages,
"total_visits": UTMVisit.objects.count(),
"total_attributed": Attribution.objects.count(),
"top_source": (top_visit or {}).get("utm_source") or "(direct)",
"top_campaign": (top_visit_campaign or {}).get("utm_campaign") or "",
"visits_by_source": visits_by_source,
"visits_by_combo": visits_by_combo,
"leads_by_source": leads_by_source,
"leads_by_combo": leads_by_combo,
"recent_visits": recent_visits,
},
)
View File
+11
View File
@@ -0,0 +1,11 @@
from django.contrib import admin
from blog.models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "is_published", "published_at", "author", "updated_at")
list_filter = ("is_published",)
prepopulated_fields = {"slug": ("title",)}
search_fields = ("title", "excerpt", "body")
+12
View File
@@ -0,0 +1,12 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "blog"
verbose_name = "Blog"
def ready(self):
from blog import hooks
hooks.register()
+18
View File
@@ -0,0 +1,18 @@
from core.registry import register_feature, register_portal_nav, register_public_nav
def register() -> None:
register_feature("blog")
register_public_nav(
section="blog",
label="Blog",
url_name="blog:list",
order=40,
)
register_portal_nav(
section="blog",
label="Blog posts",
url_name="blog_portal:portal_list",
group="Content",
order=10,
)
+36
View File
@@ -0,0 +1,36 @@
# Generated by Django 6.1 on 2026-08-26 11:38
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=220, unique=True)),
('excerpt', models.TextField(blank=True)),
('body', models.TextField()),
('is_published', models.BooleanField(default=False)),
('published_at', models.DateTimeField(blank=True, null=True)),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-published_at', '-created_at'],
},
),
]
View File
+45
View File
@@ -0,0 +1,45 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
class Post(UUIDPrimaryKeyModel, TimeStampedModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True)
excerpt = models.TextField(blank=True)
body = models.TextField()
is_published = models.BooleanField(default=False)
published_at = models.DateTimeField(null=True, blank=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="blog_posts",
)
class Meta:
ordering = ["-published_at", "-created_at"]
def __str__(self) -> str:
return self.title
def get_absolute_url(self) -> str:
return reverse("blog:detail", kwargs={"slug": self.slug})
def save(self, *args, **kwargs):
if not self.slug:
base = slugify(self.title)[:200] or "post"
slug = base
n = 2
while Post.objects.filter(slug=slug).exclude(pk=self.pk).exists():
slug = f"{base}-{n}"
n += 1
self.slug = slug
if self.is_published and self.published_at is None:
self.published_at = timezone.now()
super().save(*args, **kwargs)
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from blog import views
app_name = "blog_portal"
urlpatterns = [
path("", views.portal_list, name="portal_list"),
path("new/", views.portal_edit, name="portal_new"),
path("<uuid:pk>/", views.portal_edit, name="portal_edit"),
path("<uuid:pk>/delete/", views.portal_delete, name="portal_delete"),
]
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from blog import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="list"),
path("<slug:slug>/", views.post_detail, name="detail"),
]
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<p><a href="{% url 'blog:list' %}">← Blog</a></p>
<h1>{{ post.title }}</h1>
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
<div>{{ post.body|linebreaks }}</div>
</div>
</section>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Blog · {{ SITE_NAME }}{% endblock %}
{% block content %}
<section class="section section-lg">
<div class="container">
<h1 class="text-uppercase">Blog</h1>
{% for post in posts %}
<article style="margin:0 0 32px">
<h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
<p class="muted">{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}</p>
<p>{{ post.excerpt|default:post.body|truncatewords:40 }}</p>
</article>
{% empty %}
<p>No posts yet.</p>
{% endfor %}
</div>
</section>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal_base.html" %}
{% block title %}{% if post %}Edit{% else %}New{% endif %} post · Portal{% endblock %}
{% block topbar_title %}{% if post %}Edit post{% else %}New post{% endif %}{% endblock %}
{% block portal_content %}
<form method="post" class="form-grid">
{% csrf_token %}
<div class="field"><label>Title</label><input name="title" required value="{{ post.title|default:'' }}"></div>
<div class="field"><label>Slug</label><input name="slug" value="{{ post.slug|default:'' }}" placeholder="auto from title"></div>
<div class="field"><label>Excerpt</label><textarea name="excerpt" style="min-height:64px">{{ post.excerpt|default:'' }}</textarea></div>
<div class="field"><label>Body</label><textarea name="body" required style="min-height:220px">{{ post.body|default:'' }}</textarea></div>
<label><input type="checkbox" name="is_published" {% if post.is_published %}checked{% endif %}> Published</label>
<button class="btn btn-primary" type="submit">Save</button>
</form>
{% if post %}
<form method="post" action="{% url 'blog_portal:portal_delete' post.pk %}" style="margin-top:24px" onsubmit="return confirm('Delete this post?');">
{% csrf_token %}
<button class="btn btn-ghost" type="submit">Delete</button>
</form>
{% endif %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal_base.html" %}
{% block title %}Blog posts · Portal{% endblock %}
{% block topbar_title %}Blog posts{% endblock %}
{% block portal_content %}
<p><a class="btn btn-primary" href="{% url 'blog_portal:portal_new' %}">New post</a></p>
<table class="table">
<thead><tr><th>Title</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for post in posts %}
<tr>
<td><a href="{% url 'blog_portal:portal_edit' post.pk %}">{{ post.title }}</a></td>
<td>{% if post.is_published %}Published{% else %}Draft{% endif %}</td>
<td><a href="{{ post.get_absolute_url }}">View</a></td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No posts yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from blog.models import Post
class BlogPublicTests(TestCase):
def test_list_hides_drafts(self):
Post.objects.create(title="Draft", body="x", is_published=False)
Post.objects.create(
title="Live",
body="hello",
is_published=True,
published_at=timezone.now(),
)
response = Client().get(reverse("blog:list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Live")
self.assertNotContains(response, "Draft")
def test_portal_requires_login(self):
response = Client().get(reverse("blog_portal:portal_list"))
self.assertEqual(response.status_code, 302)
class BlogPortalTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("editor", password="test-pass-123")
self.client = Client()
self.client.login(username="editor", password="test-pass-123")
def test_create_published_post(self):
response = self.client.post(
reverse("blog_portal:portal_new"),
{"title": "Hello", "body": "World", "is_published": "on"},
)
self.assertEqual(response.status_code, 302)
post = Post.objects.get()
self.assertTrue(post.is_published)
self.assertEqual(post.slug, "hello")
+64
View File
@@ -0,0 +1,64 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.text import slugify
from django.views.decorators.http import require_http_methods, require_POST
from blog.models import Post
def post_list(request):
posts = Post.objects.filter(is_published=True)
return render(request, "blog/list.html", {"posts": posts})
def post_detail(request, slug):
post = get_object_or_404(Post, slug=slug, is_published=True)
return render(request, "blog/detail.html", {"post": post})
@login_required
def portal_list(request):
posts = Post.objects.all()
return render(request, "blog/portal/list.html", {"posts": posts})
@login_required
@require_http_methods(["GET", "POST"])
def portal_edit(request, pk=None):
post = get_object_or_404(Post, pk=pk) if pk else None
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
body = (request.POST.get("body") or "").strip()
excerpt = (request.POST.get("excerpt") or "").strip()
slug = (request.POST.get("slug") or "").strip()
is_published = request.POST.get("is_published") == "on"
if not title or not body:
messages.error(request, "Title and body are required.")
else:
if post is None:
post = Post(author=request.user)
post.title = title
post.body = body
post.excerpt = excerpt
post.slug = slugify(slug)[:220] if slug else ""
post.is_published = is_published
if is_published and post.published_at is None:
post.published_at = timezone.now()
if not is_published:
post.published_at = None
post.save()
messages.success(request, f'Saved “{post.title}”.')
return redirect("blog_portal:portal_list")
return render(request, "blog/portal/edit.html", {"post": post})
@login_required
@require_POST
def portal_delete(request, pk):
post = get_object_or_404(Post, pk=pk)
title = post.title
post.delete()
messages.success(request, f'Deleted “{title}”.')
return redirect("blog_portal:portal_list")
+1
View File
@@ -0,0 +1 @@
+9
View File
@@ -0,0 +1,9 @@
"""ASGI config for client_site."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings")
application = get_asgi_application()
+12
View File
@@ -0,0 +1,12 @@
"""Load environment-specific Django settings based on DJANGO_ENV."""
import os
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
if _environment == "prod":
from .prod import * # noqa: F403
elif _environment == "beta":
from .beta import * # noqa: F403
else:
from .dev import * # noqa: F403
+381
View File
@@ -0,0 +1,381 @@
"""Shared Django settings for all environments."""
import json
import os
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key, default)
def env_bool(key: str, default: bool = False) -> bool:
value = os.environ.get(key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default)
if not value:
return []
value = value.strip()
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()]
def database_config() -> dict:
database_url = env("DATABASE_URL")
if database_url:
parsed = urlparse(database_url)
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": parsed.path.lstrip("/"),
"USER": parsed.username or "",
"PASSWORD": parsed.password or "",
"HOST": parsed.hostname or "",
"PORT": str(parsed.port or 5432),
}
}
if env("DB_HOST"):
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("DB_NAME", "client_site"),
"USER": env("DB_USER", "client_site"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST"),
"PORT": env("DB_PORT", "5432"),
}
}
return {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
def build_csrf_trusted_origins(
allowed_hosts: list[str], explicit: list[str] | None = None
) -> list[str]:
"""Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs."""
if explicit:
return explicit
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
origins: list[str] = []
for host in allowed_hosts:
if not host or host == "*" or host.startswith("."):
continue
hostname = host.split(":")[0]
scheme = "http" if hostname in local_hosts else "https"
origins.append(f"{scheme}://{host}")
return origins
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
"django-insecure-dev-only-change-me-before-production",
)
DEBUG = env_bool("DJANGO_DEBUG", False)
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
ALLOWED_HOSTS,
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
)
from django.core.exceptions import ImproperlyConfigured
# Catalog feature flags. Secret env is the source of truth.
# Dev/test default ON so the template is a working seed; prod/beta secrets
# must set each flag explicitly (see scripts/validate-env.sh).
_feature_default = os.environ.get("DJANGO_ENV", "dev").lower() not in {"prod", "beta"}
FEATURE_EMAIL_SMS = env_bool("FEATURE_EMAIL_SMS", _feature_default)
FEATURE_DIRECT_MAIL = env_bool("FEATURE_DIRECT_MAIL", _feature_default)
FEATURE_BLOG = env_bool("FEATURE_BLOG", _feature_default)
FEATURE_PAYMENTS = env_bool("FEATURE_PAYMENTS", _feature_default)
FEATURE_SOCIAL = env_bool("FEATURE_SOCIAL", _feature_default)
FEATURE_SOCIAL_AI = env_bool("FEATURE_SOCIAL_AI", _feature_default)
if FEATURE_PAYMENTS and not FEATURE_EMAIL_SMS:
raise ImproperlyConfigured("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS")
if FEATURE_SOCIAL_AI and not FEATURE_SOCIAL:
raise ImproperlyConfigured("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL")
CORE_APPS = [
"core.apps.CoreConfig",
"public.apps.PublicConfig",
"accounts.apps.AccountsConfig",
"dashboard.apps.DashboardConfig",
"leads.apps.LeadsConfig",
"contacts.apps.ContactsConfig",
"analytics.apps.AnalyticsConfig",
]
OPTIONAL_APPS = [
(FEATURE_EMAIL_SMS, "email_sms.apps.EmailSmsConfig"),
(FEATURE_DIRECT_MAIL, "directmail.apps.DirectmailConfig"),
(FEATURE_BLOG, "blog.apps.BlogConfig"),
(FEATURE_PAYMENTS, "payments.apps.PaymentsConfig"),
(FEATURE_SOCIAL, "social.apps.SocialConfig"),
(FEATURE_SOCIAL_AI, "social_ai.apps.SocialAiConfig"),
]
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"phonenumber_field",
"django_recaptcha",
"dj_queue",
]
INSTALLED_APPS = (
CORE_APPS
+ [app for enabled, app in OPTIONAL_APPS if enabled]
+ DJANGO_APPS
)
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"analytics.middleware.UTMTrackingMiddleware",
"analytics.middleware.PublicPageViewMiddleware",
"public.middleware.UnderConstructionMiddleware",
]
ROOT_URLCONF = "client_site.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "client_site" / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"public.context_processors.site_branding",
"public.context_processors.tianji_tracking",
"core.context_processors.feature_registry",
],
},
},
]
WSGI_APPLICATION = "client_site.wsgi.application"
DATABASES = database_config()
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/Chicago"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
BASE_DIR / "client_site" / "static",
]
# Uploaded blobs live in the DB (core.StoredFile). Default storage is
# in-memory only so nothing is written to disk accidentally.
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "dashboard:home"
LOGOUT_REDIRECT_URL = "accounts:login"
# --- Feature gates ---
# When true, public visitors only see the under-construction page.
# Prod launch holding page: SITE_UNDER_CONSTRUCTION=true
# Beta (full app): SITE_UNDER_CONSTRUCTION=false
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False)
# --- Branding / public site ---
SITE_NAME = env("SITE_NAME", "Your Company")
SITE_TAGLINE = env("SITE_TAGLINE", "A tagline for the public site")
PUBLIC_SITE_URL = env("PUBLIC_SITE_URL", "")
CONTACT_PHONE = env("CONTACT_PHONE", "")
CONTACT_EMAIL = env("CONTACT_EMAIL", "hello@example.com")
CONTACT_ADDRESS = env("CONTACT_ADDRESS", "")
CONTACT_SERVICE_AREA = env("CONTACT_SERVICE_AREA", "")
CREDIT_NAME = env("CREDIT_NAME", "AI ML Operations, LLC")
CREDIT_URL = env("CREDIT_URL", "https://aimloperations.com")
def format_from_email(address: str | None, display_name: str | None = None) -> str:
"""
Ensure From header includes a display name for inbox UIs.
Bare ``noreply@…`` shows as "noreply". Prefer
``"Your Company" <noreply@…>`` (RFC 5322).
Docker Compose ``.env`` cannot parse split quotes like
``"Name" <addr>`` — use a bare address (this helper adds SITE_NAME)
or one pair of quotes around the whole value:
``DEFAULT_FROM_EMAIL="Your Company <addr@domain>"``.
"""
raw = (address or "").strip()
if not raw:
return ""
# Strip a single layer of wrapping quotes from env/compose.
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}:
raw = raw[1:-1].strip()
if "<" in raw and ">" in raw:
return raw
name = (display_name or SITE_NAME or "").strip()
if not name:
return raw
safe = name.replace("\\", "\\\\").replace('"', '\\"')
return f'"{safe}" <{raw}>'
# Inbox "From" — not the same as EMAIL_HOST_USER (SMTP login).
DEFAULT_FROM_EMAIL = format_from_email(
env("DEFAULT_FROM_EMAIL", "noreply@example.com")
)
# --- reCAPTCHA v3 ---
RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "")
RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "")
# --- Email (SMTP2GO) ---
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com")
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "")
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
EMAIL_BACKEND = env(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend",
)
# --- SMTP2GO SMS ---
SMTP2GO_SMS_API_KEY = env("SMTP2GO_SMS_API_KEY", "")
SMTP2GO_SMS_API_URL = env(
"SMTP2GO_SMS_API_URL",
"https://api.smtp2go.com/v3/sms/send",
)
# Optional shared secret: ?token=… or Authorization: Bearer …
SMTP2GO_WEBHOOK_SECRET = env("SMTP2GO_WEBHOOK_SECRET", "")
# --- Postcards (PCM Integrations default) ---
POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm")
# Login credentials for POST /auth/login → short-lived Bearer token.
PCM_API_KEY = env("PCM_API_KEY", "")
PCM_API_SECRET = env("PCM_API_SECRET", "")
# Optional child-app reference for PCM multi-account (childRefNbr).
PCM_CHILD_REF_NBR = env("PCM_CHILD_REF_NBR", "")
# PCM webhook auth: each subscription has its own signature secret (copy from PCM UI).
# Prefer PCM_WEBHOOK_SECRETS=sec1,sec2,… ; PCM_WEBHOOK_SECRET still accepted (single).
PCM_WEBHOOK_SECRETS = env("PCM_WEBHOOK_SECRETS", "")
PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "")
# JSON object: company, firstName, lastName, address, address2, city, state, zipCode
PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "")
PCM_RETURN_LINE1 = env("PCM_RETURN_LINE1", "")
PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "")
PCM_RETURN_CITY = env("PCM_RETURN_CITY", "")
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "")
# Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
LOB_API_KEY = env("LOB_API_KEY", "")
CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "")
POSTGRID_API_KEY = env("POSTGRID_API_KEY", "")
# --- Social (native APIs) ---
META_APP_ID = env("META_APP_ID", "")
META_APP_SECRET = env("META_APP_SECRET", "")
# LinkedIn Client ID/Secret live in SocialAppCredentials (portal UI), not env.
SOCIAL_TOKEN_ENCRYPTION_KEY = env("SOCIAL_TOKEN_ENCRYPTION_KEY", "")
# --- Ollama (social post drafting) ---
# Host-network reachable from app hosts; default matches internal LAN Ollama.
OLLAMA_BASE_URL = env("OLLAMA_BASE_URL", "http://10.0.0.128:11434")
OLLAMA_MODEL = env("OLLAMA_MODEL", "llama3.2")
OLLAMA_TIMEOUT_SECONDS = int(env("OLLAMA_TIMEOUT_SECONDS", "120") or "120")
# --- Stripe (FEATURE_PAYMENTS) ---
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", "")
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", "")
STRIPE_CURRENCY = env("STRIPE_CURRENCY", "usd")
# --- Nominatim (address suggest; server-side proxy only) ---
# Self-hosted on ai-server-4080. Nominatim has no native API keys — gate with
# LAN UFW + this Django proxy. Optional NOMINATIM_API_KEY is forwarded as
# X-API-Key if you later put auth in front of Nominatim.
NOMINATIM_BASE_URL = env("NOMINATIM_BASE_URL", "http://10.0.0.128:8089")
NOMINATIM_TIMEOUT_SECONDS = int(env("NOMINATIM_TIMEOUT_SECONDS", "8") or "8")
NOMINATIM_COUNTRY_CODES = env("NOMINATIM_COUNTRY_CODES", "us")
NOMINATIM_USER_AGENT = env(
"NOMINATIM_USER_AGENT",
"client_site/1.0 (address-suggest; contact admin)",
)
NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "")
# --- Tianji analytics (pageviews + events; notice banner, not opt-in) ---
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
TIANJI_TRACKER_URL = env(
"TIANJI_TRACKER_URL",
"https://tianji.aimloperations.com/tracker.js",
)
TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "cmshzhxdf6gee10qzkzrfn9iw")
# --- Django Tasks ---
# Dev: ImmediateBackend (settings/dev.py). Beta/prod: dj-queue (settings/beta.py|prod.py).
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
},
}
+31
View File
@@ -0,0 +1,31 @@
"""Beta/staging settings — full functionality for preview."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if DEBUG:
import warnings
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
# Beta always runs the full app unless explicitly overridden.
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F405
DATABASE_ROUTERS = ["dj_queue.routers.DjQueueRouter"]
TASKS = {
"default": {
"BACKEND": "dj_queue.backend.DjQueueBackend",
"QUEUES": [],
"OPTIONS": {},
},
}
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
+38
View File
@@ -0,0 +1,38 @@
"""Development settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = True
SITE_UNDER_CONSTRUCTION = env_bool("SITE_UNDER_CONSTRUCTION", False) # noqa: F405
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.memory.InMemoryStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
EMAIL_BACKEND = env( # noqa: F405
"EMAIL_BACKEND",
"django.core.mail.backends.console.EmailBackend",
)
# ALLOWED_HOSTS default "*" → empty CSRF_TRUSTED_ORIGINS, which breaks browser
# Origin checks on POST. Always trust local runserver origins in dev.
_local_csrf = [
"http://127.0.0.1:8000",
"http://localhost:8000",
"http://0.0.0.0:8000",
"http://127.0.0.1:8001",
"http://localhost:8001",
]
CSRF_TRUSTED_ORIGINS = list(
dict.fromkeys([*_local_csrf, *CSRF_TRUSTED_ORIGINS]) # noqa: F405
)
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
+61
View File
@@ -0,0 +1,61 @@
"""Environment-specific logging configuration."""
import os
def build_logging_config(level: str, environment: str) -> dict:
"""Return a Django LOGGING dict for the given level and environment name."""
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": (
f"{{levelname}} {{asctime}} {{name}} {{filename}}:{{lineno}} "
f"{{process:d}} {{thread:d}} [env={environment}] {{message}}"
),
"style": "{",
},
"simple": {
"format": (
f"{{levelname}} [env={environment}] "
f"{{filename}}:{{lineno}} {{message}}"
),
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose" if environment == "dev" else "simple",
},
},
"root": {
"handlers": ["console"],
"level": level,
},
"loggers": {
"django": {
"handlers": ["console"],
"level": level,
"propagate": False,
},
"django.request": {
"handlers": ["console"],
"level": "ERROR" if environment == "prod" else level,
"propagate": False,
},
},
}
def logging_level_for_env(environment: str) -> str:
override = os.environ.get("DJANGO_LOG_LEVEL")
if override:
return override.upper()
if environment == "dev":
return "DEBUG"
if environment == "beta":
return "INFO"
return "WARNING"
+31
View File
@@ -0,0 +1,31 @@
"""Production settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = False
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if not env("DJANGO_SECRET_KEY"): # noqa: F405
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Prod holds visitors on under-construction until launch (set false when ready).
if env("SITE_UNDER_CONSTRUCTION") is None: # noqa: F405
SITE_UNDER_CONSTRUCTION = True # noqa: F405
DATABASE_ROUTERS = ["dj_queue.routers.DjQueueRouter"]
TASKS = {
"default": {
"BACKEND": "dj_queue.backend.DjQueueBackend",
"QUEUES": [],
"OPTIONS": {},
},
}
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,35 @@
/* Address autocomplete dropdown (public + portal). */
.address-ac-wrap {
position: relative;
}
.address-ac-list {
position: absolute;
z-index: 40;
left: 0;
right: 0;
top: calc(100% + 2px);
margin: 0;
padding: 4px 0;
list-style: none;
max-height: 240px;
overflow-y: auto;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.address-ac-item {
padding: 8px 12px;
font-size: 13px;
line-height: 1.35;
color: #111827;
cursor: pointer;
}
.address-ac-item:hover,
.address-ac-item:focus {
background: #f3f4f6;
outline: none;
}
+6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+692
View File
@@ -0,0 +1,692 @@
:root {
--monica-primary: #00626c;
--monica-primary-light: #008898;
--monica-ink: #212121;
--monica-muted: #6b7280;
--monica-surface: #f4f7f7;
--monica-border: #d9e3e4;
--monica-ok: #1a7f4b;
--monica-warn: #b45309;
--monica-danger: #b91c1c;
--portal-sidebar: 240px;
--exit-dark: #080808;
--exit-teal: #00626c;
--exit-teal-bright: #008898;
}
/* Portal shell */
body.portal {
margin: 0;
background: var(--monica-surface);
font-family: "Work Sans", sans-serif;
color: var(--monica-ink);
}
.portal-shell { display: flex; min-height: 100vh; }
.portal-sidebar {
width: var(--portal-sidebar);
background: #080808;
color: #cbd5e1;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.portal-brand {
padding: 16px 18px;
border-bottom: 1px solid rgba(255,255,255,0.08);
font-family: Poppins, sans-serif;
font-weight: 700;
color: #fff;
font-size: 14px;
text-decoration: none;
display: flex;
align-items: center;
gap: 10px;
}
.portal-brand img {
height: 36px;
width: auto;
display: block;
}
.portal-brand span { color: #7dd3da; font-size: 12px; letter-spacing: 0.04em; }
.portal-nav { padding: 12px 0; flex: 1; }
.portal-nav a {
display: block;
padding: 10px 18px;
color: #94a3b8;
text-decoration: none;
font-size: 14px;
border-left: 3px solid transparent;
}
.portal-nav a:hover { color: #fff; background: rgba(255,255,255,0.04); }
.portal-nav a.active { color: #fff; background: rgba(0,98,108,0.28); border-left-color: #00a0ab; }
.portal-nav .nav-section {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #64748b;
padding: 16px 18px 6px;
}
.portal-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.portal-topbar {
background: #fff;
border-bottom: 1px solid var(--monica-border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.portal-topbar h1 { margin: 0; font-size: 20px; font-family: Poppins, sans-serif; font-weight: 600; }
.portal-content { padding: 24px; flex: 1; }
.portal-user { font-size: 13px; color: var(--monica-muted); }
.portal-user strong { color: var(--monica-ink); }
/* Portal widgets */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
.analytics-overview {
display: grid;
grid-template-columns: minmax(140px, 1fr) 4fr;
gap: 16px;
margin-bottom: 24px;
align-items: stretch;
}
.analytics-overview .stat-card {
display: flex;
flex-direction: column;
justify-content: center;
}
.analytics-overview .panel { margin-bottom: 0; }
@media (max-width: 800px) {
.analytics-overview { grid-template-columns: 1fr; }
}
.stat-card {
background: #fff;
border: 1px solid var(--monica-border);
padding: 18px;
}
.stat-card .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--monica-muted); }
.stat-card .value { font-size: 28px; font-weight: 700; font-family: Poppins, sans-serif; margin-top: 4px; }
.stat-card .delta { font-size: 12px; margin-top: 4px; color: var(--monica-ok); }
.panel {
background: #fff;
border: 1px solid var(--monica-border);
margin-bottom: 20px;
}
.panel-h {
padding: 14px 18px;
border-bottom: 1px solid var(--monica-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; }
.panel-b { padding: 18px; }
.btn {
display: inline-block;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
text-decoration: none;
font-family: "Work Sans", sans-serif;
}
.btn-primary { background: var(--monica-primary); color: #fff; }
.btn-primary:hover { background: #004e56; color: #fff; text-decoration: none; }
.btn-ghost { background: transparent; border: 1px solid var(--monica-border); color: var(--monica-ink); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--monica-muted);
padding: 10px 12px;
border-bottom: 1px solid var(--monica-border);
font-weight: 600;
}
.table td { padding: 12px; border-bottom: 1px solid var(--monica-border); vertical-align: middle; }
.table tr:hover td { background: #f8fafc; }
.table a { color: var(--monica-primary); text-decoration: none; font-weight: 500; }
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 2px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-new { background: #d0eef0; color: #00626c; }
.badge-contacted { background: #fef3c7; color: #92400e; }
.badge-won { background: #d1fae5; color: #065f46; }
.badge-lost { background: #fee2e2; color: #991b1b; }
.badge-sent { background: #d0eef0; color: #00626c; }
.badge-scheduled { background: #ede9fe; color: #5b21b6; }
.badge-delivered { background: #d1fae5; color: #065f46; }
.badge-opened { background: #dbeafe; color: #1e40af; }
.badge-clicked { background: #ede9fe; color: #5b21b6; }
.badge-failed { background: #fee2e2; color: #991b1b; }
.badge-optin { background: #d1fae5; color: #065f46; }
.badge-optout { background: #f3f4f6; color: #4b5563; }
.badge-draft { background: #f3f4f6; color: #4b5563; }
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-cancelled { background: #f3f4f6; color: #4b5563; }
.badge-sending { background: #d0eef0; color: #00626c; }
.badge-queued { background: #ede9fe; color: #5b21b6; }
.badge-bounced { background: #fee2e2; color: #991b1b; }
.badge-published { background: #d1fae5; color: #065f46; }
.badge-publishing { background: #d0eef0; color: #00626c; }
.form-grid { display: grid; gap: 16px; }
.form-grid.cols-2 { grid-template-columns: 1fr 1fr; }
@media (max-width: 700px) { .form-grid.cols-2 { grid-template-columns: 1fr; } }
.field label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; color: #374151; }
.field input, .field select, .field textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--monica-border);
font: inherit;
background: #fff;
box-sizing: border-box;
}
.field textarea { min-height: 100px; resize: vertical; }
.field .hint { font-size: 12px; color: var(--monica-muted); margin-top: 4px; }
.channel-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--monica-border); margin-bottom: 18px; }
.channel-tabs a {
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: var(--monica-muted);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.channel-tabs a.active { color: var(--monica-primary); border-bottom-color: var(--monica-primary); }
.chart-placeholder {
height: 220px;
background: linear-gradient(180deg, #e8f4f5 0%, #fff 100%);
border: 1px dashed #a8d8dc;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 24px 16px 12px;
}
.chart-placeholder > .bar {
flex: 1;
background: var(--monica-primary);
opacity: 0.75;
border-radius: 2px 2px 0 0;
min-height: 8px;
}
.chart-placeholder .chart-bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
min-width: 0;
height: 100%;
}
.chart-placeholder .chart-bar-col .bar {
width: 100%;
background: var(--monica-primary);
opacity: 0.75;
border-radius: 2px 2px 0 0;
min-height: 8px;
transition: height 0.25s ease;
}
.chart-placeholder .chart-bar-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 8px;
text-align: center;
}
.chart-placeholder .chart-bar-value {
font-size: 13px;
font-weight: 600;
color: #0f172a;
line-height: 1.2;
}
.chart-placeholder .chart-bar-label {
font-size: 11px;
color: var(--monica-muted);
line-height: 1.2;
white-space: nowrap;
}
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.preview-pane {
background: #f8fafc;
border: 1px solid var(--monica-border);
padding: 16px;
font-size: 14px;
min-height: 160px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
font-size: 12px;
}
.calendar-grid .dow { text-align: center; color: var(--monica-muted); font-weight: 600; padding: 6px; }
.calendar-grid .day {
aspect-ratio: 1;
border: 1px solid var(--monica-border);
background: #fff;
padding: 6px;
position: relative;
}
.calendar-grid .day.has-post { background: #e8f4f5; }
.calendar-grid .day .dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--monica-primary);
margin-top: 4px;
}
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, #080808, #00626c 60%, #e8f4f5 60%);
padding: 24px;
}
.login-card {
background: #fff;
width: 100%;
max-width: 400px;
padding: 36px 32px;
border: 1px solid var(--monica-border);
}
.login-card h1 { font-family: Poppins, sans-serif; font-size: 22px; margin: 0 0 6px; }
.login-card .sub { color: var(--monica-muted); font-size: 14px; margin-bottom: 24px; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 900px) {
.portal-shell { flex-direction: column; }
.portal-sidebar { width: 100%; }
.portal-nav { display: flex; flex-wrap: wrap; padding: 8px; }
.portal-nav .nav-section { display: none; }
.portal-nav a { border-left: none; border-bottom: 2px solid transparent; padding: 8px 12px; }
.portal-nav a.active { border-bottom-color: #00a0ab; }
.split { grid-template-columns: 1fr; }
}
/* Public brand — EXIT Realty logo from exitrealtywheaton.com */
.page .rd-navbar-brand {
display: inline-flex;
align-items: center;
gap: 10px;
}
.page .rd-navbar-brand img {
display: block !important;
height: 44px;
width: auto;
max-width: 160px;
}
.page .rd-navbar-brand::before,
.page .rd-navbar-brand::after { content: none !important; display: none !important; }
.page .rd-navbar-brand .brand-site {
font-family: Poppins, sans-serif;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.04em;
color: #00626c;
text-decoration: none;
line-height: 1.2;
}
.page .rd-navbar-brand .brand-site span {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #6b7280;
}
/* Shared extras */
.muted { color: var(--monica-muted); font-size: 12px; }
.text-warn { color: var(--monica-warn); font-size: 12px; }
.hint-block { font-size: 13px; color: var(--monica-muted); margin: 12px 0 0; }
.plain-list { margin: 0 0 12px; padding-left: 18px; }
.plain-list li { margin-bottom: 6px; }
.check-row { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.check-row.account-pick { align-items: flex-start; }
.account-pick-copy {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.account-pick-copy strong { display: block; font-weight: 600; }
.account-rename {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.account-rename input[type="text"] {
flex: 1;
min-width: 160px;
max-width: 100%;
padding: 8px 10px;
border: 1px solid var(--monica-border);
font: inherit;
background: #fff;
box-sizing: border-box;
}
.row-warn td { background: #fffbeb; }
.auth-alert {
background: #fff7ed;
border: 1px solid #fdba74;
color: #9a3412;
padding: 12px 16px;
margin-bottom: 16px;
font-size: 14px;
}
.auth-alert a { color: #9a3412; font-weight: 600; }
/* Social accounts */
.connect-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 900px) {
.connect-grid { grid-template-columns: 1fr; }
}
.connect-card {
display: flex; gap: 14px; align-items: flex-start; text-align: left;
padding: 14px; border: 1px solid var(--monica-border); background: #fff;
cursor: pointer; font: inherit; width: 100%;
}
.connect-card:hover { border-color: var(--monica-primary); }
.connect-card.is-active { border-color: var(--monica-primary); box-shadow: inset 0 0 0 1px var(--monica-primary); }
.connect-card p { margin: 4px 0 0; font-size: 13px; color: var(--monica-muted); }
.connect-form-panel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--monica-border);
}
.connect-steps {
margin: 0 0 16px;
padding-left: 18px;
font-size: 13px;
color: var(--monica-muted);
}
.connect-steps li { margin-bottom: 8px; }
.connect-steps a { color: var(--monica-primary); }
.platform-icon {
width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center;
justify-content: center; color: #fff; font-weight: 700; flex-shrink: 0; font-size: 14px;
}
.platform-icon.meta { background: #1877f2; }
.platform-icon.ig { background: linear-gradient(45deg, #f58529, #dd2a7b, #8134af); font-size: 12px; }
.platform-icon.li { background: #0a66c2; }
.platform-pill {
display: inline-block; padding: 2px 8px; font-size: 11px; font-weight: 600;
color: #fff; border-radius: 2px;
}
.platform-pill.meta { background: #1877f2; }
.platform-pill.ig { background: #dd2a7b; }
.platform-pill.li { background: #0a66c2; }
/* Import wizard */
.steps {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px;
}
.step {
display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--monica-muted);
padding: 8px 12px; background: #fff; border: 1px solid var(--monica-border);
}
.step span {
width: 22px; height: 22px; border-radius: 50%; background: #e5e7eb; color: #374151;
display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700;
}
.step.active { border-color: var(--monica-primary); color: var(--monica-ink); }
.step.active span { background: var(--monica-primary); color: #fff; }
.dropzone {
border: 2px dashed var(--monica-border); padding: 32px; text-align: center; background: #f8fafc;
}
/* Designer shared layout */
.designer-layout {
display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 20px; align-items: start;
}
.designer-layout.with-ai {
grid-template-columns: minmax(260px, 340px) minmax(240px, 320px) 1fr;
}
@media (max-width: 1200px) {
.designer-layout.with-ai { grid-template-columns: 1fr 1fr; }
.designer-layout.with-ai .designer-preview-col { grid-column: 1 / -1; }
}
@media (max-width: 960px) {
.designer-layout,
.designer-layout.with-ai { grid-template-columns: 1fr; }
}
/* PCM Integrations embedded designer */
.pcm-iframe-wrap {
padding: 0 !important;
min-height: 70vh;
background: #f8fafc;
}
.pcm-iframe-wrap iframe {
display: block;
width: 100%;
height: min(80vh, 900px);
border: 0;
background: #fff;
}
.pcm-designer .table tr.is-active td {
background: #e8f4f5;
}
/* Size picker */
.size-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.size-option {
border: 1px solid var(--monica-border); background: #fff; padding: 10px 12px;
text-align: left; cursor: pointer; font: inherit; color: inherit;
}
.size-option:hover { border-color: var(--monica-primary); }
.size-option.active {
border-color: var(--monica-primary); background: #e8f4f5;
box-shadow: inset 3px 0 0 var(--monica-primary);
}
.size-option .sz-name { font-weight: 700; font-size: 13px; font-family: Poppins, sans-serif; }
.size-option .sz-meta { font-size: 11px; color: var(--monica-muted); margin-top: 2px; }
.size-option .sz-badge {
display: inline-block; margin-top: 6px; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; color: var(--monica-primary);
}
/* Media library */
.media-library { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.media-tile {
position: relative; aspect-ratio: 4 / 3; border: 2px solid transparent;
background-size: cover; background-position: center; background-color: #e5e7eb;
cursor: pointer; padding: 0; overflow: hidden;
}
.media-tile.active { border-color: var(--monica-primary); }
.media-tile .media-label {
position: absolute; left: 0; right: 0; bottom: 0; padding: 4px 6px;
background: rgba(15, 23, 42, 0.72); color: #fff; font-size: 10px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.media-tile.upload-tile {
border: 2px dashed var(--monica-border); background: #f8fafc;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: var(--monica-muted); font-size: 11px; font-weight: 600;
}
.media-tile.upload-tile:hover { border-color: var(--monica-primary); color: var(--monica-primary); }
.media-tile.upload-tile span { font-size: 22px; line-height: 1; color: var(--monica-primary); }
.dropzone.compact { padding: 16px; font-size: 13px; cursor: pointer; }
.dropzone.compact:hover { border-color: var(--monica-primary); }
.library-hint { font-size: 12px; color: var(--monica-muted); margin: 8px 0 0; }
/* AI chat assist */
.ai-chat {
display: flex; flex-direction: column; height: min(640px, 75vh);
background: #fff; border: 1px solid var(--monica-border);
}
.ai-chat-h {
padding: 12px 14px; border-bottom: 1px solid var(--monica-border);
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.ai-chat-h h2 { margin: 0; font-size: 15px; font-family: Poppins, sans-serif; font-weight: 600; }
.ai-pill {
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
background: #e8f4f5; color: var(--monica-primary); padding: 3px 8px;
}
.ai-chat-messages {
flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
}
.ai-msg { max-width: 92%; font-size: 13px; line-height: 1.45; }
.ai-msg.bot { align-self: flex-start; }
.ai-msg.user { align-self: flex-end; }
.ai-msg .bubble {
padding: 10px 12px; border: 1px solid var(--monica-border); background: #fff;
}
.ai-msg.user .bubble {
background: var(--monica-primary); color: #fff; border-color: var(--monica-primary);
}
.ai-msg .who {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--monica-muted); margin-bottom: 4px;
}
.ai-msg.user .who { text-align: right; color: var(--monica-primary); }
.ai-draft {
margin-top: 8px; padding: 10px; background: #f8fafc; border: 1px dashed #a8d8dc;
white-space: pre-wrap; font-size: 12px; color: var(--monica-ink);
}
.ai-msg-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.ai-chat-prompts {
padding: 8px 12px; border-top: 1px solid var(--monica-border);
display: flex; flex-wrap: wrap; gap: 6px; background: #fafbfc;
}
.chip {
border: 1px solid var(--monica-border); background: #fff; font: inherit;
font-size: 11px; font-weight: 600; padding: 5px 10px; cursor: pointer; color: var(--monica-ink);
}
.chip:hover { border-color: var(--monica-primary); color: var(--monica-primary); }
.ai-chat-input {
border-top: 1px solid var(--monica-border); padding: 10px 12px;
display: flex; gap: 8px; align-items: flex-end;
}
.ai-chat-input textarea {
flex: 1; min-height: 44px; max-height: 100px; resize: vertical;
border: 1px solid var(--monica-border); padding: 8px 10px; font: inherit; font-size: 13px;
}
.ai-chat-input .btn { flex-shrink: 0; }
/* Postcard */
.postcard {
width: 100%; max-width: 420px; aspect-ratio: 6 / 4; position: relative; overflow: hidden;
background: #111; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.25); color: #fff;
transition: max-width 0.2s, aspect-ratio 0.2s;
}
.postcard.size-4x6 { max-width: 420px; aspect-ratio: 6 / 4; }
.postcard.size-6x9 { max-width: 480px; aspect-ratio: 9 / 6; }
.postcard.size-6x11 { max-width: 520px; aspect-ratio: 11 / 6; }
.postcard.size-square { max-width: 400px; aspect-ratio: 1 / 1; }
.postcard-media {
position: absolute; inset: 0; background-size: cover; background-position: center;
}
.postcard-overlay { position: absolute; inset: 0; pointer-events: none; }
.postcard-overlay.ov-dark { background: linear-gradient(transparent 35%, rgba(0,0,0,.75)); }
.postcard-overlay.ov-light { background: linear-gradient(transparent 50%, rgba(255,255,255,.92)); }
.postcard-overlay.ov-none { background: none; }
.postcard-overlay.ov-light ~ .postcard-copy,
.ov-light + .postcard-copy { color: #111; }
.postcard-copy {
position: absolute; left: 20px; right: 20px; bottom: 18px; z-index: 2;
}
.postcard-headline {
font-family: Poppins, sans-serif; font-weight: 700; font-size: 28px; letter-spacing: 0.04em;
border-left: 4px solid #00626c; padding-left: 10px; line-height: 1.1;
}
.postcard-sub { margin-top: 8px; font-size: 14px; opacity: 0.95; }
.postcard-back { background: #fafafa; color: #212121; }
.postcard-back-grid { display: grid; grid-template-columns: 1.2fr 1fr; height: 100%; }
.postcard-back-msg { padding: 16px; display: flex; flex-direction: column; gap: 10px; border-right: 1px dashed #cbd5e1; }
.postcard-back-body { font-size: 12px; white-space: pre-wrap; flex: 1; line-height: 1.45; }
.postcard-agent { font-size: 11px; font-weight: 600; color: #00626c; }
.postcard-qr {
width: 48px; height: 48px; background: #111; color: #fff; font-size: 10px;
display: flex; align-items: center; justify-content: center; letter-spacing: 0.05em;
}
.postcard-back-mail { padding: 12px; position: relative; }
.stamp {
width: 44px; height: 52px; border: 2px dashed #94a3b8; float: right;
font-size: 9px; color: #94a3b8; display: flex; align-items: center; justify-content: center;
}
.addr-block {
margin-top: 48px; font-size: 12px; line-height: 1.4;
border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;
}
/* Social live preview */
.platform-toggles { display: flex; flex-wrap: wrap; gap: 8px; }
.toggle-pill {
border: 1px solid var(--monica-border); padding: 8px 12px; font-size: 13px;
background: #fff; display: inline-flex; align-items: center; gap: 6px;
}
.toggle-pill.warn { border-color: #fdba74; background: #fff7ed; }
.social-phone {
width: 100%; max-width: 360px; background: #fff; border: 1px solid #d1d5db;
box-shadow: 0 8px 30px rgba(15,23,42,.12); overflow: hidden; font-size: 14px;
}
.social-phone.ig { max-width: 340px; }
.soc-header {
display: flex; align-items: center; gap: 10px; padding: 12px 14px; position: relative;
}
.soc-avatar {
width: 40px; height: 40px; background: #00626c; color: #fff; display: flex;
align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0;
}
.soc-avatar.round { border-radius: 50%; background: linear-gradient(45deg, #f58529, #dd2a7b); }
.soc-avatar.sq { border-radius: 4px; background: #0a66c2; }
.soc-name { font-weight: 700; font-size: 13px; }
.soc-meta { font-size: 11px; color: #6b7280; }
.soc-more { margin-left: auto; color: #6b7280; }
.soc-caption { padding: 0 14px 12px; white-space: pre-wrap; line-height: 1.4; font-size: 13px; }
.soc-image {
height: 200px; background-size: cover; background-position: center; background-color: #e5e7eb;
}
.soc-image.square { height: 340px; }
.soc-actions {
padding: 10px 14px; border-top: 1px solid #e5e7eb; font-size: 12px; color: #4b5563; font-weight: 600;
}
.ig-act { font-size: 18px; border-top: none; padding-top: 8px; }
/* Portal logout button in topbar */
.portal-user form { display: inline; margin: 0; }
.portal-user button.linkish {
background: none; border: 0; padding: 0; color: var(--monica-primary);
font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: underline;
}
.portal-flash { list-style: none; margin: 0 0 16px; padding: 0; }
.portal-flash li {
padding: 10px 14px; background: #e8f4f5; border-left: 3px solid var(--monica-primary);
margin-bottom: 8px; font-size: 14px;
}
.portal-flash li.error { background: #fee2e2; border-left-color: #b91c1c; }
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.toolbar-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.toolbar-filters input, .toolbar-filters select {
padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff;
}
.empty-state { padding: 24px; color: var(--monica-muted); font-size: 14px; }
+211
View File
@@ -0,0 +1,211 @@
:root {
--bg: #1a2332;
--bg-2: #243044;
--ink: #f3efe6;
--muted: #b7c0ce;
--accent: #c4a574;
--accent-2: #7ea08a;
--danger: #c97b7b;
--radius: 2px;
--font-display: "Fraunces", Georgia, serif;
--font-body: "Source Sans 3", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-body);
color: var(--ink);
background:
radial-gradient(1200px 600px at 10% -10%, #2c3d55 0%, transparent 55%),
radial-gradient(900px 500px at 100% 0%, #3a2f28 0%, transparent 45%),
var(--bg);
min-height: 100vh;
line-height: 1.5;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.site-header, .site-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
gap: 1rem;
}
.site-header nav { display: flex; gap: 1rem; flex-wrap: wrap; }
.brand {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--ink);
font-weight: 700;
}
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
.hero {
min-height: 70vh;
display: grid;
align-content: end;
gap: 0.75rem;
padding: 2rem 0 3rem;
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2.4rem, 6vw, 4.2rem);
margin: 0;
max-width: 14ch;
line-height: 1.05;
}
.hero p { max-width: 42ch; color: var(--muted); font-size: 1.1rem; }
.btn {
display: inline-block;
background: var(--accent);
color: #1a2332;
padding: 0.7rem 1.2rem;
border: 0;
font-weight: 600;
cursor: pointer;
}
.btn.secondary { background: transparent; color: var(--ink); border: 1px solid var(--muted); }
.flash { list-style: none; padding: 0 1.5rem; }
.flash li { background: var(--bg-2); padding: 0.6rem 0.9rem; margin: 0.4rem 0; }
.form-grid { display: grid; gap: 0.8rem; max-width: 32rem; }
.form-grid label { display: grid; gap: 0.3rem; color: var(--muted); }
.form-grid input, .form-grid textarea, .form-grid select {
background: var(--bg-2);
border: 1px solid #3a4a63;
color: var(--ink);
padding: 0.6rem 0.7rem;
font: inherit;
}
.portal-shell { display: grid; grid-template-columns: 220px 1fr; gap: 1.5rem; min-height: 70vh; }
.portal-nav {
background: rgba(36, 48, 68, 0.85);
padding: 1rem;
display: grid;
gap: 0.55rem;
align-content: start;
}
.portal-nav a { color: var(--ink); }
.portal-logout { margin: 0; }
.portal-logout button {
background: none;
border: 0;
padding: 0;
color: var(--ink);
font: inherit;
cursor: pointer;
text-align: left;
}
.portal-logout button:hover { text-decoration: underline; }
.brand-mini { font-family: var(--font-display); margin: 0 0 0.5rem; color: var(--accent); }
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.8rem; }
.stat {
background: rgba(36, 48, 68, 0.9);
padding: 1rem;
}
.stat strong { display: block; font-size: 1.6rem; font-family: var(--font-display); }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid #33455f; }
.holding {
min-height: 80vh;
display: grid;
align-content: center;
gap: 1rem;
max-width: 36rem;
}
.holding h1 { font-family: var(--font-display); font-size: clamp(2rem, 5vw, 3rem); margin: 0; }
.muted { color: var(--muted); }
.error { color: var(--danger); }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.site-footer {
flex-wrap: wrap;
}
.footer-meta { display: grid; gap: 0.25rem; }
.footer-meta p { margin: 0; color: var(--muted); }
.footer-links { margin: 0; }
.footer-link-btn {
background: none;
border: none;
padding: 0;
color: var(--muted);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.footer-link-btn:hover {
color: var(--accent);
text-decoration: underline;
}
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1100;
padding: 1rem;
background: rgba(26, 35, 50, 0.97);
border-top: 1px solid rgba(196, 165, 116, 0.35);
backdrop-filter: blur(10px);
}
.cookie-consent-content {
max-width: 960px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cookie-consent-text {
flex: 1 1 420px;
color: var(--muted);
font-size: 0.95rem;
margin: 0;
}
.cookie-consent-text-short { display: none; }
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
@media (max-width: 640px) {
.cookie-consent-banner {
padding: 0.625rem 0.75rem;
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
}
.cookie-consent-content {
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
}
.cookie-consent-text-short { display: inline; }
.cookie-consent-text-full { display: none; }
.cookie-consent-text {
flex: none;
font-size: 0.8125rem;
line-height: 1.35;
}
.cookie-consent-actions { width: 100%; }
.cookie-consent-actions .btn {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}
@media (max-width: 800px) {
.portal-shell { grid-template-columns: 1fr; }
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,373 @@
/* EXIT Realty / MKDRealtor public brand overrides on Real Estate theme */
:root {
--monica-primary: #00626c;
--monica-primary-light: #008898;
--monica-ink: #212121;
--monica-muted: #6b7280;
--monica-surface: #f4f7f7;
--monica-border: #d9e3e4;
}
.footer-brand-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.footer-brand-row img {
height: 40px;
width: auto;
}
.about-subtitle {
font-size: 18px;
color: var(--monica-muted);
}
.page .button-primary,
.page .button-primary:focus,
.page .button-primary:active,
.cookie-consent-banner .button-primary,
.cookie-consent-banner .button-primary:focus,
.cookie-consent-banner .button-primary:active {
background-color: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
color: #fff !important;
}
.page .button-primary:hover,
.cookie-consent-banner .button-primary:hover {
background-color: var(--monica-primary-light) !important;
border-color: var(--monica-primary-light) !important;
color: #fff !important;
}
/* Theme default blue (#0045b6) → EXIT Realty teal on public chrome */
html .page .text-primary {
color: var(--monica-primary) !important;
}
.page .breadcrumbs-custom-path a,
.page a.link-default {
color: var(--monica-primary);
}
.page a.link-default:hover {
color: var(--monica-primary-light);
}
.page .button-gray-bordered:hover,
.page .button-gray-bordered:active,
.page .button-default:hover,
.page .button-default:active,
.page .button-accent-outline:hover,
.page .button-accent-outline:active,
.page .button-primary-outline:hover,
.page .button-primary-outline:active,
.page .button-primary-white:hover,
.page .button-primary-white:active,
.page .button.button-primary-lighten,
.page .button.button-primary-lighten:focus {
background-color: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
color: #fff !important;
}
.page .button.button-primary-lighten:hover,
.page .button.button-primary-lighten:active {
background-color: var(--monica-ink) !important;
border-color: var(--monica-ink) !important;
}
.page .button-accent-outline,
.page .button-accent-outline:focus {
border-color: var(--monica-primary) !important;
}
.page .button-video:hover {
color: var(--monica-primary) !important;
}
.page .btn-primary,
.page .btn-primary:active,
.page .btn-primary:focus {
background: var(--monica-primary) !important;
border-color: var(--monica-primary) !important;
}
.page .box-chloe__icon,
.page .box-light-icon,
.page .box-minimal-icon .box-chloe__icon {
color: var(--monica-primary);
}
/* Mobile drawer active/hover — theme uses #0045b6 */
.rd-navbar-fixed .rd-nav-item:hover .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.focus .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.active .rd-nav-link,
.rd-navbar-fixed .rd-nav-item.opened .rd-nav-link {
background: var(--monica-primary) !important;
color: #fff !important;
}
/* Scroll-to-top (injected outside .page) */
.ui-to-top {
background: var(--monica-primary) !important;
color: #fff !important;
box-shadow: 0 6px 16px rgba(0, 98, 108, 0.35);
}
.ui-to-top:hover,
.ui-to-top:focus {
background: var(--monica-primary-light) !important;
color: #fff !important;
}
/* Django flash messages */
.flash {
list-style: none;
margin: 0;
padding: 12px 0 0;
}
.flash li {
max-width: 1200px;
margin: 0 auto 8px;
padding: 12px 16px;
background: #e8f4f5;
border-left: 3px solid #00626c;
color: #0d4a52;
font-size: 14px;
}
.flash li.error,
.flash li.danger {
background: #fee2e2;
border-left-color: #b91c1c;
color: #991b1b;
}
/* Form field errors */
.form-wrap .errorlist {
list-style: none;
margin: 6px 0 0;
padding: 0;
color: #b91c1c;
font-size: 13px;
}
/* Cookie consent — match public EXIT Realty teal brand */
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 11000;
padding: 1rem 1.25rem;
background: rgba(17, 24, 28, 0.96);
border-top: 2px solid var(--monica-primary);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28);
font-family: "Work Sans", sans-serif;
}
.cookie-consent-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cookie-consent-text {
flex: 1 1 420px;
color: #d7dee3;
font-size: 0.9375rem;
line-height: 1.45;
margin: 0;
font-family: "Work Sans", sans-serif;
font-weight: 400;
}
.cookie-consent-text-short {
display: none;
}
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.cookie-consent-banner .button {
min-width: 10rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: "Work Sans", sans-serif;
font-weight: 500;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.footer-link-btn {
background: none;
border: none;
padding: 0;
color: #7dd3da;
font: inherit;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
.footer-link-btn:hover {
text-decoration: underline;
color: #a8e8ed;
}
.cookie-consent-link {
color: #7dd3da;
text-decoration: underline;
text-underline-offset: 2px;
}
.cookie-consent-link:hover {
color: #fff;
}
.legal-prose {
max-width: 42rem;
}
.legal-prose .legal-lead {
font-size: 1.05rem;
color: var(--monica-ink);
margin-bottom: 1.75rem;
}
.legal-prose h2 {
font-size: 1.35rem;
margin-top: 2rem;
margin-bottom: 0.75rem;
text-transform: none;
letter-spacing: 0;
}
.legal-prose p {
margin-bottom: 1rem;
color: #4b5563;
line-height: 1.65;
}
.legal-prose .legal-updated {
margin-top: 2.5rem;
font-size: 0.875rem;
color: var(--monica-muted);
}
/* Contact form — breathe on narrow viewports */
@media (max-width: 767.98px) {
.contact-form-section > .container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.contact-form-section .section-lg {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.contact-form-section h3 {
margin-bottom: 1.25rem;
padding-right: 0.25rem;
}
.contact-message-form .form-wrap {
margin-bottom: 0.35rem;
}
.contact-message-form .form-label-outside {
margin-bottom: 0.35rem;
}
.contact-message-form .row-10 {
margin-left: 0;
margin-right: 0;
}
.contact-message-form .row-10 > [class*="col-"] {
padding-left: 0;
padding-right: 0;
}
}
.footer-aside-copy {
text-align: right;
}
.footer-aside-copy .rights {
margin: 0;
}
.footer-made-by {
margin: 6px 0 0;
font-size: 13px;
color: #9ca3af;
font-family: "Work Sans", sans-serif;
}
.footer-made-by a {
color: #d1d5db;
text-decoration: underline;
}
.footer-made-by a:hover {
color: #fff;
}
.footer-minimal .footer-made-by {
margin-top: 8px;
}
.footer-minimal-inner {
flex-wrap: wrap;
gap: 12px;
}
.pref-check {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
color: var(--monica-ink);
cursor: pointer;
}
.pref-check input {
width: 16px;
height: 16px;
}
@media (max-width: 640px) {
.cookie-consent-banner {
padding: 0.625rem 0.75rem;
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
}
.cookie-consent-content {
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
}
.cookie-consent-text-short {
display: inline;
}
.cookie-consent-text-full {
display: none;
}
.cookie-consent-text {
flex: none;
font-size: 0.8125rem;
line-height: 1.35;
}
.cookie-consent-actions {
width: 100%;
}
}
/* Keep hero slide-2 heading visually aligned with slide-1 h1 (SEO: one h1). */
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 2.5rem;
line-height: 1.15;
margin-bottom: 0.5rem;
}
@media (min-width: 768px) {
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 3.5rem;
}
}
@media (min-width: 1200px) {
.swiper-slider-minimal .jumbotron-classic-content h1.font-weight-bold,
.swiper-slider-minimal .jumbotron-classic-content h2.font-weight-bold {
font-size: 4.5rem;
}
}
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>
<json>
<![CDATA[
{
"fontFamily": "lg",
"majorVersion": 1,
"minorVersion": 0,
"fontURL": "https://github.com/sachinchoolur/lightGallery",
"copyright": "sachin",
"license": "MLT",
"licenseURL": "http://opensource.org/licenses/MIT",
"version": "Version 1.0",
"fontId": "lg",
"psName": "lg",
"subFamily": "Regular",
"fullName": "lg",
"description": "Font generated by IcoMoon."
}
]]>
</json>
</metadata>
<defs>
<font id="lg" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe01a;" glyph-name="pause_circle_outline" data-tags="pause_circle_outline" d="M554 256.667v340h86v-340h-86zM512 84.667q140 0 241 101t101 241-101 241-241 101-241-101-101-241 101-241 241-101zM512 852.667q176 0 301-125t125-301-125-301-301-125-301 125-125 301 125 301 301 125zM384 256.667v340h86v-340h-86z" />
<glyph unicode="&#xe01d;" glyph-name="play_circle_outline" data-tags="play_circle_outline" d="M512 84.667q140 0 241 101t101 241-101 241-241 101-241-101-101-241 101-241 241-101zM512 852.667q176 0 301-125t125-301-125-301-301-125-301 125-125 301 125 301 301 125zM426 234.667v384l256-192z" />
<glyph unicode="&#xe033;" glyph-name="stack-2" data-tags="stack-2" d="M384 853.334h426.667q53 0 90.5-37.5t37.5-90.5v-426.667q0-53-37.5-90.5t-90.5-37.5h-426.667q-53 0-90.5 37.5t-37.5 90.5v426.667q0 53 37.5 90.5t90.5 37.5zM170.667 675.334v-547.333q0-17.667 12.5-30.167t30.167-12.5h547.333q-13.333-37.667-46.333-61.5t-74.333-23.833h-426.667q-53 0-90.5 37.5t-37.5 90.5v426.667q0 41.333 23.833 74.333t61.5 46.333zM810.667 768h-426.667q-17.667 0-30.167-12.5t-12.5-30.167v-426.667q0-17.667 12.5-30.167t30.167-12.5h426.667q17.667 0 30.167 12.5t12.5 30.167v426.667q0 17.667-12.5 30.167t-30.167 12.5z" />
<glyph unicode="&#xe070;" glyph-name="clear" data-tags="clear" d="M810 664.667l-238-238 238-238-60-60-238 238-238-238-60 60 238 238-238 238 60 60 238-238 238 238z" />
<glyph unicode="&#xe094;" glyph-name="arrow-left" data-tags="arrow-left" d="M426.667 768q17.667 0 30.167-12.5t12.5-30.167q0-18-12.667-30.333l-225.667-225.667h665q17.667 0 30.167-12.5t12.5-30.167-12.5-30.167-30.167-12.5h-665l225.667-225.667q12.667-12.333 12.667-30.333 0-17.667-12.5-30.167t-30.167-12.5q-18 0-30.333 12.333l-298.667 298.667q-12.333 13-12.333 30.333t12.333 30.333l298.667 298.667q12.667 12.333 30.333 12.333z" />
<glyph unicode="&#xe095;" glyph-name="arrow-right" data-tags="arrow-right" d="M597.333 768q18 0 30.333-12.333l298.667-298.667q12.333-12.333 12.333-30.333t-12.333-30.333l-298.667-298.667q-12.333-12.333-30.333-12.333-18.333 0-30.5 12.167t-12.167 30.5q0 18 12.333 30.333l226 225.667h-665q-17.667 0-30.167 12.5t-12.5 30.167 12.5 30.167 30.167 12.5h665l-226 225.667q-12.333 12.333-12.333 30.333 0 18.333 12.167 30.5t30.5 12.167z" />
<glyph unicode="&#xe0f2;" glyph-name="vertical_align_bottom" data-tags="vertical_align_bottom" d="M170 128.667h684v-86h-684v86zM682 384.667l-170-172-170 172h128v426h84v-426h128z" />
<glyph unicode="&#xe1ff;" glyph-name="apps" data-tags="apps" d="M682 84.667v172h172v-172h-172zM682 340.667v172h172v-172h-172zM426 596.667v172h172v-172h-172zM682 768.667h172v-172h-172v172zM426 340.667v172h172v-172h-172zM170 340.667v172h172v-172h-172zM170 84.667v172h172v-172h-172zM426 84.667v172h172v-172h-172zM170 596.667v172h172v-172h-172z" />
<glyph unicode="&#xe20c;" glyph-name="fullscreen" data-tags="fullscreen" d="M598 724.667h212v-212h-84v128h-128v84zM726 212.667v128h84v-212h-212v84h128zM214 512.667v212h212v-84h-128v-128h-84zM298 340.667v-128h128v-84h-212v212h84z" />
<glyph unicode="&#xe20d;" glyph-name="fullscreen_exit" data-tags="fullscreen_exit" d="M682 596.667h128v-84h-212v212h84v-128zM598 128.667v212h212v-84h-128v-128h-84zM342 596.667v128h84v-212h-212v84h128zM214 256.667v84h212v-212h-84v128h-128z" />
<glyph unicode="&#xe311;" glyph-name="zoom_in" data-tags="zoom_in" d="M512 512.667h-86v-86h-42v86h-86v42h86v86h42v-86h86v-42zM406 340.667q80 0 136 56t56 136-56 136-136 56-136-56-56-136 56-136 136-56zM662 340.667l212-212-64-64-212 212v34l-12 12q-76-66-180-66-116 0-197 80t-81 196 81 197 197 81 196-81 80-197q0-104-66-180l12-12h34z" />
<glyph unicode="&#xe312;" glyph-name="zoom_out" data-tags="zoom_out" d="M298 554.667h214v-42h-214v42zM406 340.667q80 0 136 56t56 136-56 136-136 56-136-56-56-136 56-136 136-56zM662 340.667l212-212-64-64-212 212v34l-12 12q-76-66-180-66-116 0-197 80t-81 196 81 197 197 81 196-81 80-197q0-104-66-180l12-12h34z" />
<glyph unicode="&#xe80d;" glyph-name="share" data-tags="share" d="M768 252.667c68 0 124-56 124-124s-56-126-124-126-124 58-124 126c0 10 0 20 2 28l-302 176c-24-22-54-34-88-34-70 0-128 58-128 128s58 128 128 128c34 0 64-12 88-34l300 174c-2 10-4 20-4 30 0 70 58 128 128 128s128-58 128-128-58-128-128-128c-34 0-64 14-88 36l-300-176c2-10 4-20 4-30s-2-20-4-30l304-176c22 20 52 32 84 32z" />
<glyph unicode="&#xe901;" glyph-name="facebook-with-circle" data-tags="facebook-with-circle" d="M512 952.32c-271.462 0-491.52-220.058-491.52-491.52s220.058-491.52 491.52-491.52 491.52 220.058 491.52 491.52-220.058 491.52-491.52 491.52zM628.429 612.659h-73.882c-8.755 0-18.483-11.52-18.483-26.829v-53.35h92.416l-13.978-76.083h-78.438v-228.403h-87.194v228.403h-79.104v76.083h79.104v44.749c0 64.205 44.544 116.378 105.677 116.378h73.882v-80.947z" />
<glyph unicode="&#xe902;" glyph-name="google-with-circle" data-tags="google+-with-circle" d="M512 952.32c-271.462 0-491.52-220.058-491.52-491.52s220.058-491.52 491.52-491.52 491.52 220.058 491.52 491.52-220.058 491.52-491.52 491.52zM483.686 249.805c-30.874-15.002-64.102-16.589-76.954-16.589-2.458 0-3.84 0-3.84 0s-1.178 0-2.765 0c-20.070 0-119.962 4.608-119.962 95.59 0 89.395 108.8 96.41 142.131 96.41h0.87c-19.251 25.702-15.258 51.61-15.258 51.61-1.69-0.102-4.147-0.205-7.168-0.205-12.544 0-36.762 1.997-57.549 15.411-25.498 16.384-38.4 44.288-38.4 82.893 0 109.107 119.142 113.51 120.32 113.613h118.989v-2.611c0-13.312-23.91-15.923-40.192-18.125-5.53-0.819-16.64-1.894-19.763-3.482 30.157-16.128 35.021-41.421 35.021-79.104 0-42.906-16.794-65.587-34.611-81.51-11.059-9.882-19.712-17.613-19.712-28.006 0-10.189 11.878-20.582 25.702-32.717 22.579-19.917 53.555-47.002 53.555-92.723 0-47.258-20.326-81.050-60.416-100.454zM742.4 460.8h-76.8v-76.8h-51.2v76.8h-76.8v51.2h76.8v76.8h51.2v-76.8h76.8v-51.2zM421.018 401.92c-2.662 0-5.325-0.102-8.038-0.307-22.733-1.69-43.725-10.189-58.88-24.013-15.053-13.619-22.733-30.822-21.658-48.179 2.304-36.403 41.37-57.702 88.832-54.323 46.694 3.379 77.824 30.31 75.571 66.714-2.15 34.202-31.898 60.109-75.827 60.109zM465.766 599.808c-12.39 43.52-32.358 56.422-63.386 56.422-3.328 0-6.707-0.512-9.933-1.382-13.466-3.84-24.166-15.053-30.106-31.744-6.093-16.896-6.451-34.509-1.229-54.579 9.472-35.891 34.97-61.901 60.672-61.901 3.379 0 6.758 0.41 9.933 1.382 28.109 7.885 45.722 50.79 34.048 91.802z" />
<glyph unicode="&#xe903;" glyph-name="pinterest-with-circle" data-tags="pinterest-with-circle" d="M512 952.32c-271.462 0-491.52-220.058-491.52-491.52s220.058-491.52 491.52-491.52 491.52 220.058 491.52 491.52-220.058 491.52-491.52 491.52zM545.638 344.32c-31.539 2.406-44.749 18.022-69.427 32.973-13.568-71.219-30.157-139.52-79.309-175.206-15.206 107.725 22.221 188.518 39.629 274.381-29.645 49.92 3.533 150.323 66.099 125.645 76.954-30.515-66.662-185.6 29.747-205.005 100.659-20.173 141.773 174.694 79.36 237.978-90.214 91.494-262.502 2.099-241.306-128.87 5.12-32 38.246-41.728 13.21-85.914-57.702 12.8-74.957 58.317-72.704 118.989 3.533 99.328 89.242 168.909 175.155 178.483 108.698 12.083 210.688-39.885 224.819-142.182 15.821-115.405-49.101-240.282-165.274-231.27z" />
<glyph unicode="&#xe904;" glyph-name="twitter-with-circle" data-tags="twitter-with-circle" d="M512 952.32c-271.462 0-491.52-220.058-491.52-491.52s220.058-491.52 491.52-491.52 491.52 220.058 491.52 491.52-220.058 491.52-491.52 491.52zM711.936 549.683c0.205-4.198 0.256-8.397 0.256-12.493 0-128-97.331-275.507-275.405-275.507-54.682 0-105.574 15.974-148.378 43.52 7.526-0.922 15.258-1.28 23.091-1.28 45.363 0 87.091 15.411 120.218 41.421-42.342 0.819-78.080 28.774-90.419 67.174 5.888-1.075 11.93-1.69 18.176-1.69 8.806 0 17.408 1.178 25.498 3.379-44.288 8.909-77.67 48.026-77.67 94.925v1.178c13.056-7.219 28.006-11.622 43.878-12.134-26.010 17.408-43.059 47.002-43.059 80.64 0 17.715 4.762 34.406 13.107 48.691 47.77-58.573 119.040-97.075 199.526-101.222-1.69 7.117-2.509 14.49-2.509 22.118 0 53.402 43.315 96.819 96.819 96.819 27.802 0 52.992-11.776 70.656-30.618 22.067 4.403 42.752 12.39 61.44 23.501-7.219-22.579-22.528-41.574-42.547-53.606 19.61 2.406 38.246 7.578 55.603 15.309-12.954-19.405-29.389-36.506-48.282-50.125z" />
</font></defs></svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Some files were not shown because too many files have changed in this diff Show More