diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..acc3baa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.venv +**/__pycache__/ +*.py[cod] +db.sqlite3 +.env +htmlcov/ +.pytest_cache/ +.mypy_cache/ +*.log +staticfiles/ +proposal/ +template/ +docs/ +*.pptx +.~lock.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e3542b --- /dev/null +++ b/.env.example @@ -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 "" +# 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" +# Use bare address (SITE_NAME is wrapped) OR one quoted string: +# DEFAULT_FROM_EMAIL="Your Company " +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 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..15a84c6 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,92 @@ +# Secret env files for server-infra deploy. Never commit. +# Copy on the control node: +# ~/Documents/secrets/_site/_site_prod.env +# ~/Documents/secrets/_site/_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 — -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2ff9c9c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/deploy-beta.yml b/.gitea/workflows/deploy-beta.yml new file mode 100644 index 0000000..7c05d3d --- /dev/null +++ b/.gitea/workflows/deploy-beta.yml @@ -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 }}" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..dbd6825 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore index 36b13f1..aced4c1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1e9a41 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d5b672b..467bc8a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ # web_django_template -Template repository for web design products \ No newline at end of file +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. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2edbeda --- /dev/null +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..33df0bd --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a893dfc --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/scripts/bootstrap-client.sh b/scripts/bootstrap-client.sh new file mode 100755 index 0000000..eb07263 --- /dev/null +++ b/scripts/bootstrap-client.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Seed a client repo from this template. +# Usage: scripts/bootstrap-client.sh "" +# Example: scripts/bootstrap-client.sh acme_site "Acme HVAC" acmehvac.com +set -euo pipefail + +SLUG="${1:?Usage: $0 \"\" }" +SITE_NAME="${2:?Usage: $0 \"\" }" +DOMAIN="${3:?Usage: $0 \"\" }" + +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." diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..39621e7 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -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}" diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh new file mode 100755 index 0000000..89235f6 --- /dev/null +++ b/scripts/validate-env.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:?Usage: $0 }" + +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/_site/_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)." diff --git a/scripts/worker-entrypoint.sh b/scripts/worker-entrypoint.sh new file mode 100755 index 0000000..0a00a0b --- /dev/null +++ b/scripts/worker-entrypoint.sh @@ -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 diff --git a/site/accounts/__init__.py b/site/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/accounts/admin.py b/site/accounts/admin.py new file mode 100644 index 0000000..139290f --- /dev/null +++ b/site/accounts/admin.py @@ -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") diff --git a/site/accounts/apps.py b/site/accounts/apps.py new file mode 100644 index 0000000..0cb51e6 --- /dev/null +++ b/site/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "accounts" diff --git a/site/accounts/migrations/0001_initial.py b/site/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..7281451 --- /dev/null +++ b/site/accounts/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/site/accounts/migrations/__init__.py b/site/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/accounts/models.py b/site/accounts/models.py new file mode 100644 index 0000000..5143b5f --- /dev/null +++ b/site/accounts/models.py @@ -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() diff --git a/site/accounts/templates/accounts/login.html b/site/accounts/templates/accounts/login.html new file mode 100644 index 0000000..bd5b499 --- /dev/null +++ b/site/accounts/templates/accounts/login.html @@ -0,0 +1,42 @@ +{% load static %} + + + + + + Sign in · Portal · {{ SITE_NAME }} + + + + + + + + diff --git a/site/accounts/urls.py b/site/accounts/urls.py new file mode 100644 index 0000000..bee2c7f --- /dev/null +++ b/site/accounts/urls.py @@ -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", + ), +] diff --git a/site/analytics/__init__.py b/site/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/analytics/admin.py b/site/analytics/admin.py new file mode 100644 index 0000000..b794d3d --- /dev/null +++ b/site/analytics/admin.py @@ -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") diff --git a/site/analytics/apps.py b/site/analytics/apps.py new file mode 100644 index 0000000..9453479 --- /dev/null +++ b/site/analytics/apps.py @@ -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, + ) diff --git a/site/analytics/middleware.py b/site/analytics/middleware.py new file mode 100644 index 0000000..8ab4e4f --- /dev/null +++ b/site/analytics/middleware.py @@ -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 "", + ) diff --git a/site/analytics/migrations/0001_initial.py b/site/analytics/migrations/0001_initial.py new file mode 100644 index 0000000..332e2ec --- /dev/null +++ b/site/analytics/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/site/analytics/migrations/__init__.py b/site/analytics/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/analytics/models.py b/site/analytics/models.py new file mode 100644 index 0000000..440bb71 --- /dev/null +++ b/site/analytics/models.py @@ -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'}" diff --git a/site/analytics/services.py b/site/analytics/services.py new file mode 100644 index 0000000..725cb76 --- /dev/null +++ b/site/analytics/services.py @@ -0,0 +1,3 @@ +"""Analytics services.""" + +from analytics.middleware import attribute_lead_from_request # noqa: F401 diff --git a/site/analytics/templates/analytics/report.html b/site/analytics/templates/analytics/report.html new file mode 100644 index 0000000..afe57f8 --- /dev/null +++ b/site/analytics/templates/analytics/report.html @@ -0,0 +1,150 @@ +{% extends "portal_base.html" %} +{% block title %}Analytics · Portal{% endblock %} +{% block topbar_title %}Analytics{% endblock %} +{% block portal_content %} +
+
+
Views (last 30 days)
+
{{ pageviews_last_30_days }}
+
+
+

Top pages

Public visits, last 30 days
+
+ + + + + + + + + {% for row in top_pages %} + + + + + {% empty %} + + {% endfor %} + +
PageVisits
{{ row.path }}{{ row.count }}
No public page views in the last 30 days.
+
+
+
+ +
+
+
UTM landings
+
{{ total_visits }}
+
+
+
Attributed leads
+
{{ total_attributed }}
+
+
+
Top source
+
{{ top_source|default:"—" }}
+
+
+
Top campaign
+
{{ top_campaign|default:"—" }}
+
+
+ +
+

Landing volume by source

+
+
+ {% for row in visits_by_source|slice:":8" %} +
+ {% empty %} +
+ {% endfor %} +
+
+
+ +
+

UTM landings

From ?utm_* hits
+
+ + + + + + + + + + + {% for row in visits_by_combo %} + + + + + + + {% empty %} + + {% endfor %} + +
SourceMediumCampaignLandings
{{ row.utm_source|default:"(direct)" }}{{ row.utm_medium|default:"—" }}{{ row.utm_campaign|default:"—" }}{{ row.count }}
No UTM landings yet. Open a public URL with ?utm_source=…
+
+
+ +
+

Recent landings

+
+ + + + + + + + + + + {% for visit in recent_visits %} + + + + + + + {% empty %} + + {% endfor %} + +
WhenPathSourceCampaign
{{ visit.created_at|date:"M j, g:i A" }}{{ visit.path }}{{ visit.utm_source|default:"—" }}{{ visit.utm_campaign|default:"—" }}
No visits recorded.
+
+
+ +
+

Lead attribution

After contact-form submit
+
+ + + + + + + + + + + {% for row in leads_by_combo %} + + + + + + + {% empty %} + + {% endfor %} + +
SourceMediumCampaignLeads
{{ row.utm_source|default:"(direct)" }}{{ row.utm_medium|default:"—" }}{{ row.utm_campaign|default:"—" }}{{ row.count }}
No attributed leads yet — submit the contact form after a UTM landing.
+
+
+{% endblock %} diff --git a/site/analytics/tests.py b/site/analytics/tests.py new file mode 100644 index 0000000..7667416 --- /dev/null +++ b/site/analytics/tests.py @@ -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/") diff --git a/site/analytics/urls.py b/site/analytics/urls.py new file mode 100644 index 0000000..168cc2b --- /dev/null +++ b/site/analytics/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from analytics import views + +app_name = "analytics" + +urlpatterns = [ + path("", views.report, name="report"), +] diff --git a/site/analytics/views.py b/site/analytics/views.py new file mode 100644 index 0000000..0d7d160 --- /dev/null +++ b/site/analytics/views.py @@ -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, + }, + ) diff --git a/site/blog/__init__.py b/site/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/blog/admin.py b/site/blog/admin.py new file mode 100644 index 0000000..7cf5521 --- /dev/null +++ b/site/blog/admin.py @@ -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") diff --git a/site/blog/apps.py b/site/blog/apps.py new file mode 100644 index 0000000..3885664 --- /dev/null +++ b/site/blog/apps.py @@ -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() diff --git a/site/blog/hooks.py b/site/blog/hooks.py new file mode 100644 index 0000000..6daf9d0 --- /dev/null +++ b/site/blog/hooks.py @@ -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, + ) diff --git a/site/blog/migrations/0001_initial.py b/site/blog/migrations/0001_initial.py new file mode 100644 index 0000000..6f05a8c --- /dev/null +++ b/site/blog/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/site/blog/migrations/__init__.py b/site/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/blog/models.py b/site/blog/models.py new file mode 100644 index 0000000..817306b --- /dev/null +++ b/site/blog/models.py @@ -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) diff --git a/site/blog/portal_urls.py b/site/blog/portal_urls.py new file mode 100644 index 0000000..4844e99 --- /dev/null +++ b/site/blog/portal_urls.py @@ -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("/", views.portal_edit, name="portal_edit"), + path("/delete/", views.portal_delete, name="portal_delete"), +] diff --git a/site/blog/public_urls.py b/site/blog/public_urls.py new file mode 100644 index 0000000..c19ff02 --- /dev/null +++ b/site/blog/public_urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from blog import views + +app_name = "blog" + +urlpatterns = [ + path("", views.post_list, name="list"), + path("/", views.post_detail, name="detail"), +] diff --git a/site/blog/templates/blog/detail.html b/site/blog/templates/blog/detail.html new file mode 100644 index 0000000..45c92cb --- /dev/null +++ b/site/blog/templates/blog/detail.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}{{ post.title }} · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

← Blog

+

{{ post.title }}

+

{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}

+
{{ post.body|linebreaks }}
+
+
+{% endblock %} diff --git a/site/blog/templates/blog/list.html b/site/blog/templates/blog/list.html new file mode 100644 index 0000000..eb5a738 --- /dev/null +++ b/site/blog/templates/blog/list.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Blog · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Blog

+ {% for post in posts %} +
+

{{ post.title }}

+

{% if post.published_at %}{{ post.published_at|date:"F j, Y" }}{% endif %}

+

{{ post.excerpt|default:post.body|truncatewords:40 }}

+
+ {% empty %} +

No posts yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/site/blog/templates/blog/portal/edit.html b/site/blog/templates/blog/portal/edit.html new file mode 100644 index 0000000..9e66b07 --- /dev/null +++ b/site/blog/templates/blog/portal/edit.html @@ -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 %} +
+ {% csrf_token %} +
+
+
+
+ + +
+{% if post %} +
+ {% csrf_token %} + +
+{% endif %} +{% endblock %} diff --git a/site/blog/templates/blog/portal/list.html b/site/blog/templates/blog/portal/list.html new file mode 100644 index 0000000..88465be --- /dev/null +++ b/site/blog/templates/blog/portal/list.html @@ -0,0 +1,20 @@ +{% extends "portal_base.html" %} +{% block title %}Blog posts · Portal{% endblock %} +{% block topbar_title %}Blog posts{% endblock %} +{% block portal_content %} +

New post

+ + + + {% for post in posts %} + + + + + + {% empty %} + + {% endfor %} + +
TitleStatus
{{ post.title }}{% if post.is_published %}Published{% else %}Draft{% endif %}View
No posts yet.
+{% endblock %} diff --git a/site/blog/tests.py b/site/blog/tests.py new file mode 100644 index 0000000..d13dbb8 --- /dev/null +++ b/site/blog/tests.py @@ -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") diff --git a/site/blog/views.py b/site/blog/views.py new file mode 100644 index 0000000..771b5ae --- /dev/null +++ b/site/blog/views.py @@ -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") diff --git a/site/client_site/__init__.py b/site/client_site/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/client_site/__init__.py @@ -0,0 +1 @@ + diff --git a/site/client_site/asgi.py b/site/client_site/asgi.py new file mode 100644 index 0000000..2094100 --- /dev/null +++ b/site/client_site/asgi.py @@ -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() diff --git a/site/client_site/settings/__init__.py b/site/client_site/settings/__init__.py new file mode 100644 index 0000000..fc73b65 --- /dev/null +++ b/site/client_site/settings/__init__.py @@ -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 diff --git a/site/client_site/settings/base.py b/site/client_site/settings/base.py new file mode 100644 index 0000000..4e6794b --- /dev/null +++ b/site/client_site/settings/base.py @@ -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" `` (RFC 5322). + + Docker Compose ``.env`` cannot parse split quotes like + ``"Name" `` — use a bare address (this helper adds SITE_NAME) + or one pair of quotes around the whole value: + ``DEFAULT_FROM_EMAIL="Your Company "``. + """ + 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", + }, +} diff --git a/site/client_site/settings/beta.py b/site/client_site/settings/beta.py new file mode 100644 index 0000000..6676c8b --- /dev/null +++ b/site/client_site/settings/beta.py @@ -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") diff --git a/site/client_site/settings/dev.py b/site/client_site/settings/dev.py new file mode 100644 index 0000000..696eb2d --- /dev/null +++ b/site/client_site/settings/dev.py @@ -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") diff --git a/site/client_site/settings/logging.py b/site/client_site/settings/logging.py new file mode 100644 index 0000000..4c31b4c --- /dev/null +++ b/site/client_site/settings/logging.py @@ -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" diff --git a/site/client_site/settings/prod.py b/site/client_site/settings/prod.py new file mode 100644 index 0000000..fd13870 --- /dev/null +++ b/site/client_site/settings/prod.py @@ -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") diff --git a/site/client_site/static/brand/apple-touch-icon.png b/site/client_site/static/brand/apple-touch-icon.png new file mode 100644 index 0000000..27bb80c Binary files /dev/null and b/site/client_site/static/brand/apple-touch-icon.png differ diff --git a/site/client_site/static/brand/exit_logo.png b/site/client_site/static/brand/exit_logo.png new file mode 100644 index 0000000..4fea4c5 Binary files /dev/null and b/site/client_site/static/brand/exit_logo.png differ diff --git a/site/client_site/static/brand/favicon-32.png b/site/client_site/static/brand/favicon-32.png new file mode 100644 index 0000000..9fad4f1 Binary files /dev/null and b/site/client_site/static/brand/favicon-32.png differ diff --git a/site/client_site/static/brand/logo.png b/site/client_site/static/brand/logo.png new file mode 100644 index 0000000..4fea4c5 Binary files /dev/null and b/site/client_site/static/brand/logo.png differ diff --git a/site/client_site/static/css/address-autocomplete.css b/site/client_site/static/css/address-autocomplete.css new file mode 100644 index 0000000..643a30d --- /dev/null +++ b/site/client_site/static/css/address-autocomplete.css @@ -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; +} diff --git a/site/client_site/static/css/bootstrap.css b/site/client_site/static/css/bootstrap.css new file mode 100755 index 0000000..59e8743 --- /dev/null +++ b/site/client_site/static/css/bootstrap.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v4.0.0 (https://getbootstrap.com) + * Copyright 2011-2018 The Bootstrap Authors + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ :root { --blue: #007bff; --indigo: #6610f2; --purple: #6f42c1; --pink: #e83e8c; --red: #dc3545; --orange: #fd7e14; --yellow: #ffc107; --green: #28a745; --teal: #20c997; --cyan: #17a2b8; --white: #fff; --gray: #6c757d; --gray-dark: #343a40; --breakpoint-xs: 0; --breakpoint-sm: 576px; --breakpoint-md: 768px; --breakpoint-lg: 992px; --breakpoint-xl: 1200px; --breakpoint-xxl: 1600px; --font-family-sans-serif: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-family-monospace: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } *, *::before, *::after { box-sizing: border-box; } html { font-family: sans-serif; line-height: 1.15; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; -ms-overflow-style: scrollbar; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } @-ms-viewport { width: device-width; } article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section { display: block; } body { margin: 0; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 16px; font-weight: 300; line-height: 1.5; color: #74757f; text-align: left; background-color: #fff; } [tabindex="-1"]:focus { outline: 0 !important; } hr { box-sizing: content-box; height: 0; overflow: visible; } h1, h2, h3, h4, h5, h6 { margin-top: 0; margin-bottom: 0.5rem; } p { margin-top: 0; margin-bottom: 1rem; } abbr[title], abbr[data-original-title] { text-decoration: underline; text-decoration: underline dotted; cursor: help; border-bottom: 0; } address { margin-bottom: 1rem; font-style: normal; line-height: inherit; } ol, ul, dl { margin-top: 0; margin-bottom: 1rem; } ol ol, ul ul, ol ul, ul ol { margin-bottom: 0; } dt { font-weight: inherit; } dd { margin-bottom: .5rem; margin-left: 0; } blockquote { margin: 0 0 1rem; } dfn { font-style: italic; } b, strong { font-weight: bolder; } small { font-size: 80%; } sub, sup { position: relative; font-size: 75%; line-height: 0; vertical-align: baseline; } sub { bottom: -.25em; } sup { top: -.5em; } a { color: #0045b6; text-decoration: none; background-color: transparent; -webkit-text-decoration-skip: objects; } a:hover { color: #00286a; text-decoration: underline; } a:not([href]):not([tabindex]) { text-decoration: none; } a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus { text-decoration: none; } a:not([href]):not([tabindex]):focus { outline: 0; } pre, code, kbd, samp { font-family: monospace, monospace; font-size: 1em; } pre { margin-top: 0; margin-bottom: 1rem; overflow: auto; -ms-overflow-style: scrollbar; } figure { margin: 0 0 1rem; } img { vertical-align: middle; border-style: none; } svg:not(:root) { overflow: hidden; } table { border-collapse: collapse; } caption { padding-top: 0.75rem; padding-bottom: 0.75rem; color: #6c757d; text-align: left; caption-side: bottom; } th { text-align: inherit; } label { display: inline-block; margin-bottom: .5rem; } button { border-radius: 0; } button:focus { outline: 1px dotted; outline: 5px auto -webkit-focus-ring-color; } input, button, select, optgroup, textarea { margin: 0; font-family: inherit; font-size: inherit; line-height: inherit; } button, input { overflow: visible; } button, select { text-transform: none; } button, html [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } button::-moz-focus-inner, [type="button"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner, [type="submit"]::-moz-focus-inner { padding: 0; border-style: none; } input[type="radio"], input[type="checkbox"] { box-sizing: border-box; padding: 0; } input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"] { -webkit-appearance: listbox; } textarea { overflow: auto; resize: vertical; } fieldset { min-width: 0; padding: 0; margin: 0; border: 0; } legend { display: block; width: 100%; max-width: 100%; padding: 0; margin-bottom: .5rem; font-size: 1.5rem; line-height: inherit; color: inherit; white-space: normal; } progress { vertical-align: baseline; } [type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-outer-spin-button { height: auto; } [type="search"] { outline-offset: -2px; -webkit-appearance: none; } [type="search"]::-webkit-search-cancel-button, [type="search"]::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-file-upload-button { font: inherit; -webkit-appearance: button; } output { display: inline-block; } summary { display: list-item; cursor: pointer; } template { display: none; } [hidden] { display: none !important; } h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { margin-bottom: 0.5rem; font-family: "Poppins", sans-serif; font-weight: 400; line-height: 1.1; color: #151515; } h1, h2, h3, .h1, .h2, .h3 { font-weight: 700; } h1, .h1 { font-size: 90px; } h2, .h2 { font-size: 50px; } h3, .h3 { font-size: 38px; } h4, .h4 { font-size: 24px; } h5, .h5 { font-size: 22px; } h6, .h6 { font-size: 16px; } h4, .h4 { font-weight: 300; } .lead { font-size: 24px; font-weight: 300; } .display-1 { font-size: 6rem; font-weight: 300; line-height: 1.2; } .display-2 { font-size: 5.5rem; font-weight: 300; line-height: 1.2; } .display-3 { font-size: 4.5rem; font-weight: 300; line-height: 1.2; } .display-4 { font-size: 3.5rem; font-weight: 300; line-height: 1.2; } hr { margin-top: 1rem; margin-bottom: 1rem; border: 0; border-top: 1px solid #e8e9ee; } small, .small { font-size: 80%; font-weight: 400; } mark, .mark { padding: 5px 10px; background-color: #0045b6; } .list-unstyled { padding-left: 0; list-style: none; } .list-inline { padding-left: 0; list-style: none; } .list-inline-item { display: inline-block; } .list-inline-item:not(:last-child) { margin-right: 5px; } .initialism { font-size: 90%; text-transform: uppercase; } .blockquote { margin-bottom: 1rem; font-size: 1.25rem; } .blockquote-footer { display: block; font-size: 80%; color: #6c757d; } .blockquote-footer::before { content: "\2014 \00A0"; } .img-fluid { max-width: 100%; height: auto; } .img-thumbnail { padding: 0.25rem; background-color: #fff; border: 1px solid #dee2e6; border-radius: 0.25rem; max-width: 100%; height: auto; } .figure { display: inline-block; } .figure-img { margin-bottom: 0.5rem; line-height: 1; } .figure-caption { font-size: 90%; color: #6c757d; } code, kbd, pre, samp { font-family: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } code { font-size: 90%; color: #111111; word-break: break-word; } a > code { color: inherit; } kbd { padding: 0.2rem 0.4rem; font-size: 87.5%; color: #fff; background-color: #212529; border-radius: 0.2rem; } kbd kbd { padding: 0; font-size: 100%; font-weight: 700; } pre { display: block; font-size: 90%; color: #212529; } pre code { font-size: inherit; color: inherit; word-break: normal; } .pre-scrollable { max-height: 340px; overflow-y: scroll; } .container { width: 100%; padding-right: 15px; padding-left: 15px; margin-right: auto; margin-left: auto; } @media (min-width: 576px) { .container { max-width: 540px; } } @media (min-width: 768px) { .container { max-width: 720px; } } @media (min-width: 992px) { .container { max-width: 960px; } } @media (min-width: 1200px) { .container { max-width: 1200px; } } .container-fluid { width: 100%; padding-right: 15px; padding-left: 15px; margin-right: auto; margin-left: auto; } .row { display: flex; flex-wrap: wrap; margin-right: -15px; margin-left: -15px; } .no-gutters { margin-right: 0; margin-left: 0; } .no-gutters > .col, .no-gutters > [class*="col-"] { padding-right: 0; padding-left: 0; } .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, .col, .col-auto, .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm, .col-sm-auto, .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12, .col-md, .col-md-auto, .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg, .col-lg-auto, .col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl, .col-xl-auto, .col-xxl-1, .col-xxl-2, .col-xxl-3, .col-xxl-4, .col-xxl-5, .col-xxl-6, .col-xxl-7, .col-xxl-8, .col-xxl-9, .col-xxl-10, .col-xxl-11, .col-xxl-12, .col-xxl, .col-xxl-auto { position: relative; width: 100%; min-height: 1px; padding-right: 15px; padding-left: 15px; } .col { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-3 { flex: 0 0 25%; max-width: 25%; } .col-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-6 { flex: 0 0 50%; max-width: 50%; } .col-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-9 { flex: 0 0 75%; max-width: 75%; } .col-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-12 { flex: 0 0 100%; max-width: 100%; } .order-first { order: -1; } .order-last { order: 13; } .order-0 { order: 0; } .order-1 { order: 1; } .order-2 { order: 2; } .order-3 { order: 3; } .order-4 { order: 4; } .order-5 { order: 5; } .order-6 { order: 6; } .order-7 { order: 7; } .order-8 { order: 8; } .order-9 { order: 9; } .order-10 { order: 10; } .order-11 { order: 11; } .order-12 { order: 12; } .offset-1 { margin-left: 8.33333%; } .offset-2 { margin-left: 16.66667%; } .offset-3 { margin-left: 25%; } .offset-4 { margin-left: 33.33333%; } .offset-5 { margin-left: 41.66667%; } .offset-6 { margin-left: 50%; } .offset-7 { margin-left: 58.33333%; } .offset-8 { margin-left: 66.66667%; } .offset-9 { margin-left: 75%; } .offset-10 { margin-left: 83.33333%; } .offset-11 { margin-left: 91.66667%; } @media (min-width: 576px) { .col-sm { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-sm-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-sm-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-sm-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-sm-3 { flex: 0 0 25%; max-width: 25%; } .col-sm-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-sm-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-sm-6 { flex: 0 0 50%; max-width: 50%; } .col-sm-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-sm-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-sm-9 { flex: 0 0 75%; max-width: 75%; } .col-sm-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-sm-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-sm-12 { flex: 0 0 100%; max-width: 100%; } .order-sm-first { order: -1; } .order-sm-last { order: 13; } .order-sm-0 { order: 0; } .order-sm-1 { order: 1; } .order-sm-2 { order: 2; } .order-sm-3 { order: 3; } .order-sm-4 { order: 4; } .order-sm-5 { order: 5; } .order-sm-6 { order: 6; } .order-sm-7 { order: 7; } .order-sm-8 { order: 8; } .order-sm-9 { order: 9; } .order-sm-10 { order: 10; } .order-sm-11 { order: 11; } .order-sm-12 { order: 12; } .offset-sm-0 { margin-left: 0; } .offset-sm-1 { margin-left: 8.33333%; } .offset-sm-2 { margin-left: 16.66667%; } .offset-sm-3 { margin-left: 25%; } .offset-sm-4 { margin-left: 33.33333%; } .offset-sm-5 { margin-left: 41.66667%; } .offset-sm-6 { margin-left: 50%; } .offset-sm-7 { margin-left: 58.33333%; } .offset-sm-8 { margin-left: 66.66667%; } .offset-sm-9 { margin-left: 75%; } .offset-sm-10 { margin-left: 83.33333%; } .offset-sm-11 { margin-left: 91.66667%; } } @media (min-width: 768px) { .col-md { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-md-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-md-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-md-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-md-3 { flex: 0 0 25%; max-width: 25%; } .col-md-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-md-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-md-6 { flex: 0 0 50%; max-width: 50%; } .col-md-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-md-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-md-9 { flex: 0 0 75%; max-width: 75%; } .col-md-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-md-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-md-12 { flex: 0 0 100%; max-width: 100%; } .order-md-first { order: -1; } .order-md-last { order: 13; } .order-md-0 { order: 0; } .order-md-1 { order: 1; } .order-md-2 { order: 2; } .order-md-3 { order: 3; } .order-md-4 { order: 4; } .order-md-5 { order: 5; } .order-md-6 { order: 6; } .order-md-7 { order: 7; } .order-md-8 { order: 8; } .order-md-9 { order: 9; } .order-md-10 { order: 10; } .order-md-11 { order: 11; } .order-md-12 { order: 12; } .offset-md-0 { margin-left: 0; } .offset-md-1 { margin-left: 8.33333%; } .offset-md-2 { margin-left: 16.66667%; } .offset-md-3 { margin-left: 25%; } .offset-md-4 { margin-left: 33.33333%; } .offset-md-5 { margin-left: 41.66667%; } .offset-md-6 { margin-left: 50%; } .offset-md-7 { margin-left: 58.33333%; } .offset-md-8 { margin-left: 66.66667%; } .offset-md-9 { margin-left: 75%; } .offset-md-10 { margin-left: 83.33333%; } .offset-md-11 { margin-left: 91.66667%; } } @media (min-width: 992px) { .col-lg { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-lg-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-lg-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-lg-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-lg-3 { flex: 0 0 25%; max-width: 25%; } .col-lg-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-lg-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-lg-6 { flex: 0 0 50%; max-width: 50%; } .col-lg-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-lg-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-lg-9 { flex: 0 0 75%; max-width: 75%; } .col-lg-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-lg-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-lg-12 { flex: 0 0 100%; max-width: 100%; } .order-lg-first { order: -1; } .order-lg-last { order: 13; } .order-lg-0 { order: 0; } .order-lg-1 { order: 1; } .order-lg-2 { order: 2; } .order-lg-3 { order: 3; } .order-lg-4 { order: 4; } .order-lg-5 { order: 5; } .order-lg-6 { order: 6; } .order-lg-7 { order: 7; } .order-lg-8 { order: 8; } .order-lg-9 { order: 9; } .order-lg-10 { order: 10; } .order-lg-11 { order: 11; } .order-lg-12 { order: 12; } .offset-lg-0 { margin-left: 0; } .offset-lg-1 { margin-left: 8.33333%; } .offset-lg-2 { margin-left: 16.66667%; } .offset-lg-3 { margin-left: 25%; } .offset-lg-4 { margin-left: 33.33333%; } .offset-lg-5 { margin-left: 41.66667%; } .offset-lg-6 { margin-left: 50%; } .offset-lg-7 { margin-left: 58.33333%; } .offset-lg-8 { margin-left: 66.66667%; } .offset-lg-9 { margin-left: 75%; } .offset-lg-10 { margin-left: 83.33333%; } .offset-lg-11 { margin-left: 91.66667%; } } @media (min-width: 1200px) { .col-xl { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-xl-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-xl-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-xl-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-xl-3 { flex: 0 0 25%; max-width: 25%; } .col-xl-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-xl-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-xl-6 { flex: 0 0 50%; max-width: 50%; } .col-xl-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-xl-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-xl-9 { flex: 0 0 75%; max-width: 75%; } .col-xl-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-xl-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-xl-12 { flex: 0 0 100%; max-width: 100%; } .order-xl-first { order: -1; } .order-xl-last { order: 13; } .order-xl-0 { order: 0; } .order-xl-1 { order: 1; } .order-xl-2 { order: 2; } .order-xl-3 { order: 3; } .order-xl-4 { order: 4; } .order-xl-5 { order: 5; } .order-xl-6 { order: 6; } .order-xl-7 { order: 7; } .order-xl-8 { order: 8; } .order-xl-9 { order: 9; } .order-xl-10 { order: 10; } .order-xl-11 { order: 11; } .order-xl-12 { order: 12; } .offset-xl-0 { margin-left: 0; } .offset-xl-1 { margin-left: 8.33333%; } .offset-xl-2 { margin-left: 16.66667%; } .offset-xl-3 { margin-left: 25%; } .offset-xl-4 { margin-left: 33.33333%; } .offset-xl-5 { margin-left: 41.66667%; } .offset-xl-6 { margin-left: 50%; } .offset-xl-7 { margin-left: 58.33333%; } .offset-xl-8 { margin-left: 66.66667%; } .offset-xl-9 { margin-left: 75%; } .offset-xl-10 { margin-left: 83.33333%; } .offset-xl-11 { margin-left: 91.66667%; } } @media (min-width: 1600px) { .col-xxl { flex-basis: 0; flex-grow: 1; max-width: 100%; } .col-xxl-auto { flex: 0 0 auto; width: auto; max-width: none; } .col-xxl-1 { flex: 0 0 8.33333%; max-width: 8.33333%; } .col-xxl-2 { flex: 0 0 16.66667%; max-width: 16.66667%; } .col-xxl-3 { flex: 0 0 25%; max-width: 25%; } .col-xxl-4 { flex: 0 0 33.33333%; max-width: 33.33333%; } .col-xxl-5 { flex: 0 0 41.66667%; max-width: 41.66667%; } .col-xxl-6 { flex: 0 0 50%; max-width: 50%; } .col-xxl-7 { flex: 0 0 58.33333%; max-width: 58.33333%; } .col-xxl-8 { flex: 0 0 66.66667%; max-width: 66.66667%; } .col-xxl-9 { flex: 0 0 75%; max-width: 75%; } .col-xxl-10 { flex: 0 0 83.33333%; max-width: 83.33333%; } .col-xxl-11 { flex: 0 0 91.66667%; max-width: 91.66667%; } .col-xxl-12 { flex: 0 0 100%; max-width: 100%; } .order-xxl-first { order: -1; } .order-xxl-last { order: 13; } .order-xxl-0 { order: 0; } .order-xxl-1 { order: 1; } .order-xxl-2 { order: 2; } .order-xxl-3 { order: 3; } .order-xxl-4 { order: 4; } .order-xxl-5 { order: 5; } .order-xxl-6 { order: 6; } .order-xxl-7 { order: 7; } .order-xxl-8 { order: 8; } .order-xxl-9 { order: 9; } .order-xxl-10 { order: 10; } .order-xxl-11 { order: 11; } .order-xxl-12 { order: 12; } .offset-xxl-0 { margin-left: 0; } .offset-xxl-1 { margin-left: 8.33333%; } .offset-xxl-2 { margin-left: 16.66667%; } .offset-xxl-3 { margin-left: 25%; } .offset-xxl-4 { margin-left: 33.33333%; } .offset-xxl-5 { margin-left: 41.66667%; } .offset-xxl-6 { margin-left: 50%; } .offset-xxl-7 { margin-left: 58.33333%; } .offset-xxl-8 { margin-left: 66.66667%; } .offset-xxl-9 { margin-left: 75%; } .offset-xxl-10 { margin-left: 83.33333%; } .offset-xxl-11 { margin-left: 91.66667%; } } .table { width: 100%; max-width: 100%; margin-bottom: 1rem; background-color: transparent; } .table th, .table td { padding: 0.75rem; vertical-align: top; border-top: 1px solid #dee2e6; } .table thead th { vertical-align: bottom; border-bottom: 2px solid #dee2e6; } .table tbody + tbody { border-top: 2px solid #dee2e6; } .table .table { background-color: #fff; } .table-sm th, .table-sm td { padding: 0.3rem; } .table-bordered { border: 1px solid #dee2e6; } .table-bordered th, .table-bordered td { border: 1px solid #dee2e6; } .table-bordered thead th, .table-bordered thead td { border-bottom-width: 2px; } .table-striped tbody tr:nth-of-type(odd) { background-color: rgba(0, 0, 0, 0.05); } .table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.075); } .table-active, .table-active > th, .table-active > td { background-color: rgba(0, 0, 0, 0.075); } .table-hover .table-active:hover { background-color: rgba(0, 0, 0, 0.075); } .table-hover .table-active:hover > td, .table-hover .table-active:hover > th { background-color: rgba(0, 0, 0, 0.075); } .table .thead-dark th { color: #fff; background-color: #212529; border-color: #32383e; } .table .thead-light th { color: #495057; background-color: #e9ecef; border-color: #dee2e6; } .table-dark { color: #fff; background-color: #212529; } .table-dark th, .table-dark td, .table-dark thead th { border-color: #32383e; } .table-dark.table-bordered { border: 0; } .table-dark.table-striped tbody tr:nth-of-type(odd) { background-color: rgba(255, 255, 255, 0.05); } .table-dark.table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.075); } @media (max-width: 575.98px) { .table-responsive-sm { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive-sm > .table-bordered { border: 0; } } @media (max-width: 767.98px) { .table-responsive-md { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive-md > .table-bordered { border: 0; } } @media (max-width: 991.98px) { .table-responsive-lg { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive-lg > .table-bordered { border: 0; } } @media (max-width: 1199.98px) { .table-responsive-xl { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive-xl > .table-bordered { border: 0; } } @media (max-width: 1599.98px) { .table-responsive-xxl { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive-xxl > .table-bordered { border: 0; } } .table-responsive { display: block; width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } .table-responsive > .table-bordered { border: 0; } .form-control { display: block; width: 100%; padding: 0.375rem 0.75rem; font-size: 16px; line-height: 1.5; color: #495057; background-color: #fff; background-clip: padding-box; border: 1px solid #ced4da; border-radius: 0.25rem; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } .form-control::-ms-expand { background-color: transparent; border: 0; } .form-control:focus { color: #495057; background-color: #fff; border-color: #3783ff; outline: 0; box-shadow: 0 0 0 0.2rem rgba(0, 69, 182, 0.25); } .form-control::placeholder { color: #6c757d; opacity: 1; } .form-control:disabled, .form-control[readonly] { background-color: #e9ecef; opacity: 1; } select.form-control:not([size]):not([multiple]) { height: calc(2.25rem + 2px); } select.form-control:focus::-ms-value { color: #495057; background-color: #fff; } .form-control-file, .form-control-range { display: block; width: 100%; } .col-form-label { padding-top: calc(0.375rem + 1px); padding-bottom: calc(0.375rem + 1px); margin-bottom: 0; font-size: inherit; line-height: 1.5; } .col-form-label-lg { padding-top: calc(0.5rem + 1px); padding-bottom: calc(0.5rem + 1px); font-size: 20px; line-height: 1.5; } .col-form-label-sm { padding-top: calc(0.25rem + 1px); padding-bottom: calc(0.25rem + 1px); font-size: 12px; line-height: 1.5; } .form-control-plaintext { display: block; width: 100%; padding-top: 0.375rem; padding-bottom: 0.375rem; margin-bottom: 0; line-height: 1.5; background-color: transparent; border: solid transparent; border-width: 1px 0; } .form-control-plaintext.form-control-sm, .input-group-sm > .form-control-plaintext.form-control, .input-group-sm > .input-group-prepend > .form-control-plaintext.input-group-text, .input-group-sm > .input-group-append > .form-control-plaintext.input-group-text, .input-group-sm > .input-group-prepend > .form-control-plaintext.btn, .input-group-sm > .input-group-append > .form-control-plaintext.btn, .form-control-plaintext.form-control-lg, .input-group-lg > .form-control-plaintext.form-control, .input-group-lg > .input-group-prepend > .form-control-plaintext.input-group-text, .input-group-lg > .input-group-append > .form-control-plaintext.input-group-text, .input-group-lg > .input-group-prepend > .form-control-plaintext.btn, .input-group-lg > .input-group-append > .form-control-plaintext.btn { padding-right: 0; padding-left: 0; } .form-control-sm, .input-group-sm > .form-control, .input-group-sm > .input-group-prepend > .input-group-text, .input-group-sm > .input-group-append > .input-group-text, .input-group-sm > .input-group-prepend > .btn, .input-group-sm > .input-group-append > .btn { padding: 0.25rem 0.5rem; font-size: 12px; line-height: 1.5; border-radius: 0.2rem; } select.form-control-sm:not([size]):not([multiple]), .input-group-sm > select.form-control:not([size]):not([multiple]), .input-group-sm > .input-group-prepend > select.input-group-text:not([size]):not([multiple]), .input-group-sm > .input-group-append > select.input-group-text:not([size]):not([multiple]), .input-group-sm > .input-group-prepend > select.btn:not([size]):not([multiple]), .input-group-sm > .input-group-append > select.btn:not([size]):not([multiple]) { height: calc(1.8125rem + 2px); } .form-control-lg, .input-group-lg > .form-control, .input-group-lg > .input-group-prepend > .input-group-text, .input-group-lg > .input-group-append > .input-group-text, .input-group-lg > .input-group-prepend > .btn, .input-group-lg > .input-group-append > .btn { padding: 0.5rem 1rem; font-size: 20px; line-height: 1.5; border-radius: 0.3rem; } select.form-control-lg:not([size]):not([multiple]), .input-group-lg > select.form-control:not([size]):not([multiple]), .input-group-lg > .input-group-prepend > select.input-group-text:not([size]):not([multiple]), .input-group-lg > .input-group-append > select.input-group-text:not([size]):not([multiple]), .input-group-lg > .input-group-prepend > select.btn:not([size]):not([multiple]), .input-group-lg > .input-group-append > select.btn:not([size]):not([multiple]) { height: calc(2.875rem + 2px); } .form-group { margin-bottom: 1rem; } .form-text { display: block; margin-top: 0.25rem; } .form-row { display: flex; flex-wrap: wrap; margin-right: -5px; margin-left: -5px; } .form-row > .col, .form-row > [class*="col-"] { padding-right: 5px; padding-left: 5px; } .form-check { position: relative; display: block; padding-left: 1.25rem; } .form-check-input { position: absolute; margin-top: 0.3rem; margin-left: -1.25rem; } .form-check-input:disabled ~ .form-check-label { color: #6c757d; } .form-check-label { margin-bottom: 0; } .form-check-inline { display: inline-flex; align-items: center; padding-left: 0; margin-right: 0.75rem; } .form-check-inline .form-check-input { position: static; margin-top: 0; margin-right: 0.3125rem; margin-left: 0; } .valid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 80%; color: #98bf44; } .valid-tooltip { position: absolute; top: 100%; z-index: 5; display: none; max-width: 100%; padding: .5rem; margin-top: .1rem; font-size: .875rem; line-height: 1; color: #fff; background-color: rgba(152, 191, 68, 0.8); border-radius: .2rem; } .was-validated .form-control:valid, .form-control.is-valid, .was-validated .custom-select:valid, .custom-select.is-valid { border-color: #98bf44; } .was-validated .form-control:valid:focus, .form-control.is-valid:focus, .was-validated .custom-select:valid:focus, .custom-select.is-valid:focus { border-color: #98bf44; box-shadow: 0 0 0 0.2rem rgba(152, 191, 68, 0.25); } .was-validated .form-control:valid ~ .valid-feedback, .was-validated .form-control:valid ~ .valid-tooltip, .form-control.is-valid ~ .valid-feedback, .form-control.is-valid ~ .valid-tooltip, .was-validated .custom-select:valid ~ .valid-feedback, .was-validated .custom-select:valid ~ .valid-tooltip, .custom-select.is-valid ~ .valid-feedback, .custom-select.is-valid ~ .valid-tooltip { display: block; } .was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { color: #98bf44; } .was-validated .form-check-input:valid ~ .valid-feedback, .was-validated .form-check-input:valid ~ .valid-tooltip, .form-check-input.is-valid ~ .valid-feedback, .form-check-input.is-valid ~ .valid-tooltip { display: block; } .was-validated .custom-control-input:valid ~ .custom-control-label, .custom-control-input.is-valid ~ .custom-control-label { color: #98bf44; } .was-validated .custom-control-input:valid ~ .custom-control-label::before, .custom-control-input.is-valid ~ .custom-control-label::before { background-color: #cce0a3; } .was-validated .custom-control-input:valid ~ .valid-feedback, .was-validated .custom-control-input:valid ~ .valid-tooltip, .custom-control-input.is-valid ~ .valid-feedback, .custom-control-input.is-valid ~ .valid-tooltip { display: block; } .was-validated .custom-control-input:valid:checked ~ .custom-control-label::before, .custom-control-input.is-valid:checked ~ .custom-control-label::before { background-color: #adcc6a; } .was-validated .custom-control-input:valid:focus ~ .custom-control-label::before, .custom-control-input.is-valid:focus ~ .custom-control-label::before { box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(152, 191, 68, 0.25); } .was-validated .custom-file-input:valid ~ .custom-file-label, .custom-file-input.is-valid ~ .custom-file-label { border-color: #98bf44; } .was-validated .custom-file-input:valid ~ .custom-file-label::before, .custom-file-input.is-valid ~ .custom-file-label::before { border-color: inherit; } .was-validated .custom-file-input:valid ~ .valid-feedback, .was-validated .custom-file-input:valid ~ .valid-tooltip, .custom-file-input.is-valid ~ .valid-feedback, .custom-file-input.is-valid ~ .valid-tooltip { display: block; } .was-validated .custom-file-input:valid:focus ~ .custom-file-label, .custom-file-input.is-valid:focus ~ .custom-file-label { box-shadow: 0 0 0 0.2rem rgba(152, 191, 68, 0.25); } .invalid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 80%; color: #dc0000; } .invalid-tooltip { position: absolute; top: 100%; z-index: 5; display: none; max-width: 100%; padding: .5rem; margin-top: .1rem; font-size: .875rem; line-height: 1; color: #fff; background-color: rgba(220, 0, 0, 0.8); border-radius: .2rem; } .was-validated .form-control:invalid, .form-control.is-invalid, .was-validated .custom-select:invalid, .custom-select.is-invalid { border-color: #dc0000; } .was-validated .form-control:invalid:focus, .form-control.is-invalid:focus, .was-validated .custom-select:invalid:focus, .custom-select.is-invalid:focus { border-color: #dc0000; box-shadow: 0 0 0 0.2rem rgba(220, 0, 0, 0.25); } .was-validated .form-control:invalid ~ .invalid-feedback, .was-validated .form-control:invalid ~ .invalid-tooltip, .form-control.is-invalid ~ .invalid-feedback, .form-control.is-invalid ~ .invalid-tooltip, .was-validated .custom-select:invalid ~ .invalid-feedback, .was-validated .custom-select:invalid ~ .invalid-tooltip, .custom-select.is-invalid ~ .invalid-feedback, .custom-select.is-invalid ~ .invalid-tooltip { display: block; } .was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { color: #dc0000; } .was-validated .form-check-input:invalid ~ .invalid-feedback, .was-validated .form-check-input:invalid ~ .invalid-tooltip, .form-check-input.is-invalid ~ .invalid-feedback, .form-check-input.is-invalid ~ .invalid-tooltip { display: block; } .was-validated .custom-control-input:invalid ~ .custom-control-label, .custom-control-input.is-invalid ~ .custom-control-label { color: #dc0000; } .was-validated .custom-control-input:invalid ~ .custom-control-label::before, .custom-control-input.is-invalid ~ .custom-control-label::before { background-color: #ff5d5d; } .was-validated .custom-control-input:invalid ~ .invalid-feedback, .was-validated .custom-control-input:invalid ~ .invalid-tooltip, .custom-control-input.is-invalid ~ .invalid-feedback, .custom-control-input.is-invalid ~ .invalid-tooltip { display: block; } .was-validated .custom-control-input:invalid:checked ~ .custom-control-label::before, .custom-control-input.is-invalid:checked ~ .custom-control-label::before { background-color: #ff1010; } .was-validated .custom-control-input:invalid:focus ~ .custom-control-label::before, .custom-control-input.is-invalid:focus ~ .custom-control-label::before { box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(220, 0, 0, 0.25); } .was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label { border-color: #dc0000; } .was-validated .custom-file-input:invalid ~ .custom-file-label::before, .custom-file-input.is-invalid ~ .custom-file-label::before { border-color: inherit; } .was-validated .custom-file-input:invalid ~ .invalid-feedback, .was-validated .custom-file-input:invalid ~ .invalid-tooltip, .custom-file-input.is-invalid ~ .invalid-feedback, .custom-file-input.is-invalid ~ .invalid-tooltip { display: block; } .was-validated .custom-file-input:invalid:focus ~ .custom-file-label, .custom-file-input.is-invalid:focus ~ .custom-file-label { box-shadow: 0 0 0 0.2rem rgba(220, 0, 0, 0.25); } .form-inline { display: flex; flex-flow: row wrap; align-items: center; } .form-inline .form-check { width: 100%; } @media (min-width: 576px) { .form-inline label { display: flex; align-items: center; justify-content: center; margin-bottom: 0; } .form-inline .form-group { display: flex; flex: 0 0 auto; flex-flow: row wrap; align-items: center; margin-bottom: 0; } .form-inline .form-control { display: inline-block; width: auto; vertical-align: middle; } .form-inline .form-control-plaintext { display: inline-block; } .form-inline .input-group { width: auto; } .form-inline .form-check { display: flex; align-items: center; justify-content: center; width: auto; padding-left: 0; } .form-inline .form-check-input { position: relative; margin-top: 0; margin-right: 0.25rem; margin-left: 0; } .form-inline .custom-control { align-items: center; justify-content: center; } .form-inline .custom-control-label { margin-bottom: 0; } } .btn { display: inline-block; font-weight: 500; text-align: center; white-space: nowrap; vertical-align: middle; user-select: none; border: 1px solid transparent; padding: 0.375rem 0.75rem; font-size: 16px; line-height: 1.5; border-radius: 6px; transition: all 0.15s ease-in-out; } .btn:hover, .btn:focus { text-decoration: none; } .btn:focus, .btn.focus { outline: 0; box-shadow: 0; } .btn.disabled, .btn:disabled { opacity: 0.65; } .btn:not(:disabled):not(.disabled) { cursor: pointer; } .btn:not(:disabled):not(.disabled):active, .btn:not(:disabled):not(.disabled).active { background-image: none; } a.btn.disabled, fieldset:disabled a.btn { pointer-events: none; } .btn-link { font-weight: 400; color: #0045b6; background-color: transparent; } .btn-link:hover { color: #00286a; text-decoration: underline; background-color: transparent; border-color: transparent; } .btn-link:focus, .btn-link.focus { text-decoration: underline; border-color: transparent; box-shadow: none; } .btn-link:disabled, .btn-link.disabled { color: #868e96; } .btn-lg, .btn-group-lg > .btn { padding: 0.5rem 1rem; font-size: 20px; line-height: 1.5; border-radius: 6px; } .btn-sm, .btn-group-sm > .btn { padding: 0.25rem 0.5rem; font-size: 12px; line-height: 1.5; border-radius: 0.2rem; } .btn-block { display: block; width: 100%; } .btn-block + .btn-block { margin-top: 0.5rem; } input[type="submit"].btn-block, input[type="reset"].btn-block, input[type="button"].btn-block { width: 100%; } .fade { opacity: 0; transition: opacity 0.15s linear; } .fade.show { opacity: 1; } .collapse { display: none; } .collapse.show { display: block; } tr.collapse.show { display: table-row; } tbody.collapse.show { display: table-row-group; } .collapsing { position: relative; height: 0; overflow: hidden; transition: height 0.35s ease; } .dropup, .dropdown { position: relative; } .dropdown-toggle::after { display: inline-block; width: 0; height: 0; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid; border-right: 0.3em solid transparent; border-bottom: 0; border-left: 0.3em solid transparent; } .dropdown-toggle:empty::after { margin-left: 0; } .dropdown-menu { position: absolute; top: 100%; left: 0; z-index: 1000; display: none; float: left; min-width: 10rem; padding: 0.5rem 0; margin: 0.125rem 0 0; font-size: 16px; color: #74757f; text-align: left; list-style: none; background-color: #fff; background-clip: padding-box; border: 1px solid rgba(0, 0, 0, 0.15); border-radius: 0.25rem; } .dropup .dropdown-menu { margin-top: 0; margin-bottom: 0.125rem; } .dropup .dropdown-toggle::after { display: inline-block; width: 0; height: 0; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0; border-right: 0.3em solid transparent; border-bottom: 0.3em solid; border-left: 0.3em solid transparent; } .dropup .dropdown-toggle:empty::after { margin-left: 0; } .dropright .dropdown-menu { margin-top: 0; margin-left: 0.125rem; } .dropright .dropdown-toggle::after { display: inline-block; width: 0; height: 0; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid transparent; border-bottom: 0.3em solid transparent; border-left: 0.3em solid; } .dropright .dropdown-toggle:empty::after { margin-left: 0; } .dropright .dropdown-toggle::after { vertical-align: 0; } .dropleft .dropdown-menu { margin-top: 0; margin-right: 0.125rem; } .dropleft .dropdown-toggle::after { display: inline-block; width: 0; height: 0; margin-left: 0.255em; vertical-align: 0.255em; content: ""; } .dropleft .dropdown-toggle::after { display: none; } .dropleft .dropdown-toggle::before { display: inline-block; width: 0; height: 0; margin-right: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid transparent; border-right: 0.3em solid; border-bottom: 0.3em solid transparent; } .dropleft .dropdown-toggle:empty::after { margin-left: 0; } .dropleft .dropdown-toggle::before { vertical-align: 0; } .dropdown-divider { height: 0; margin: 0.5rem 0; overflow: hidden; border-top: 1px solid #e9ecef; } .dropdown-item { display: block; width: 100%; padding: 0.25rem 1.5rem; clear: both; font-weight: 400; color: #212529; text-align: inherit; white-space: nowrap; background-color: transparent; border: 0; } .dropdown-item:hover, .dropdown-item:focus { color: #16181b; text-decoration: none; background-color: #f8f9fa; } .dropdown-item.active, .dropdown-item:active { color: #fff; text-decoration: none; background-color: #0045b6; } .dropdown-item.disabled, .dropdown-item:disabled { color: #6c757d; background-color: transparent; } .dropdown-menu.show { display: block; } .dropdown-header { display: block; padding: 0.5rem 1.5rem; margin-bottom: 0; font-size: 12px; color: #6c757d; white-space: nowrap; } .btn-group, .btn-group-vertical { position: relative; display: inline-flex; vertical-align: middle; } .btn-group > .btn, .btn-group-vertical > .btn { position: relative; flex: 0 1 auto; } .btn-group > .btn:hover, .btn-group-vertical > .btn:hover { z-index: 1; } .btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active, .btn-group-vertical > .btn:focus, .btn-group-vertical > .btn:active, .btn-group-vertical > .btn.active { z-index: 1; } .btn-group .btn + .btn, .btn-group .btn + .btn-group, .btn-group .btn-group + .btn, .btn-group .btn-group + .btn-group, .btn-group-vertical .btn + .btn, .btn-group-vertical .btn + .btn-group, .btn-group-vertical .btn-group + .btn, .btn-group-vertical .btn-group + .btn-group { margin-left: -1px; } .btn-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-start; } .btn-toolbar .input-group { width: auto; } .btn-group > .btn:first-child { margin-left: 0; } .btn-group > .btn:not(:last-child):not(.dropdown-toggle), .btn-group > .btn-group:not(:last-child) > .btn { border-top-right-radius: 0; border-bottom-right-radius: 0; } .btn-group > .btn:not(:first-child), .btn-group > .btn-group:not(:first-child) > .btn { border-top-left-radius: 0; border-bottom-left-radius: 0; } .dropdown-toggle-split { padding-right: 0.5625rem; padding-left: 0.5625rem; } .dropdown-toggle-split::after { margin-left: 0; } .btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { padding-right: 0.375rem; padding-left: 0.375rem; } .btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { padding-right: 0.75rem; padding-left: 0.75rem; } .btn-group-vertical { flex-direction: column; align-items: flex-start; justify-content: center; } .btn-group-vertical .btn, .btn-group-vertical .btn-group { width: 100%; } .btn-group-vertical > .btn + .btn, .btn-group-vertical > .btn + .btn-group, .btn-group-vertical > .btn-group + .btn, .btn-group-vertical > .btn-group + .btn-group { margin-top: -1px; margin-left: 0; } .btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .btn-group-vertical > .btn-group:not(:last-child) > .btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .btn-group-vertical > .btn:not(:first-child), .btn-group-vertical > .btn-group:not(:first-child) > .btn { border-top-left-radius: 0; border-top-right-radius: 0; } .btn-group-toggle > .btn, .btn-group-toggle > .btn-group > .btn { margin-bottom: 0; } .btn-group-toggle > .btn input[type="radio"], .btn-group-toggle > .btn input[type="checkbox"], .btn-group-toggle > .btn-group > .btn input[type="radio"], .btn-group-toggle > .btn-group > .btn input[type="checkbox"] { position: absolute; clip: rect(0, 0, 0, 0); pointer-events: none; } .input-group { position: relative; display: flex; flex-wrap: wrap; align-items: stretch; width: 100%; } .input-group > .form-control, .input-group > .custom-select, .input-group > .custom-file { position: relative; flex: 1 1 auto; width: 1%; margin-bottom: 0; } .input-group > .form-control:focus, .input-group > .custom-select:focus, .input-group > .custom-file:focus { z-index: 3; } .input-group > .form-control + .form-control, .input-group > .form-control + .custom-select, .input-group > .form-control + .custom-file, .input-group > .custom-select + .form-control, .input-group > .custom-select + .custom-select, .input-group > .custom-select + .custom-file, .input-group > .custom-file + .form-control, .input-group > .custom-file + .custom-select, .input-group > .custom-file + .custom-file { margin-left: -1px; } .input-group > .form-control:not(:last-child), .input-group > .custom-select:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } .input-group > .form-control:not(:first-child), .input-group > .custom-select:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } .input-group > .custom-file { display: flex; align-items: center; } .input-group > .custom-file:not(:last-child) .custom-file-label, .input-group > .custom-file:not(:last-child) .custom-file-label::before { border-top-right-radius: 0; border-bottom-right-radius: 0; } .input-group > .custom-file:not(:first-child) .custom-file-label, .input-group > .custom-file:not(:first-child) .custom-file-label::before { border-top-left-radius: 0; border-bottom-left-radius: 0; } .input-group-prepend, .input-group-append { display: flex; } .input-group-prepend .btn, .input-group-append .btn { position: relative; z-index: 2; } .input-group-prepend .btn + .btn, .input-group-prepend .btn + .input-group-text, .input-group-prepend .input-group-text + .input-group-text, .input-group-prepend .input-group-text + .btn, .input-group-append .btn + .btn, .input-group-append .btn + .input-group-text, .input-group-append .input-group-text + .input-group-text, .input-group-append .input-group-text + .btn { margin-left: -1px; } .input-group-prepend { margin-right: -1px; } .input-group-append { margin-left: -1px; } .input-group-text { display: flex; align-items: center; padding: 0.375rem 0.75rem; margin-bottom: 0; font-size: 16px; font-weight: 400; line-height: 1.5; color: #495057; text-align: center; white-space: nowrap; background-color: #e9ecef; border: 1px solid #ced4da; border-radius: 0.25rem; } .input-group-text input[type="radio"], .input-group-text input[type="checkbox"] { margin-top: 0; } .input-group > .input-group-prepend > .btn, .input-group > .input-group-prepend > .input-group-text, .input-group > .input-group-append:not(:last-child) > .btn, .input-group > .input-group-append:not(:last-child) > .input-group-text, .input-group > .input-group-append:last-child > .btn:not(:last-child):not(.dropdown-toggle), .input-group > .input-group-append:last-child > .input-group-text:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } .input-group > .input-group-append > .btn, .input-group > .input-group-append > .input-group-text, .input-group > .input-group-prepend:not(:first-child) > .btn, .input-group > .input-group-prepend:not(:first-child) > .input-group-text, .input-group > .input-group-prepend:first-child > .btn:not(:first-child), .input-group > .input-group-prepend:first-child > .input-group-text:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } .custom-control { position: relative; display: block; min-height: 1.5rem; padding-left: 1.5rem; } .custom-control-inline { display: inline-flex; margin-right: 1rem; } .custom-control-input { position: absolute; z-index: -1; opacity: 0; } .custom-control-input:checked ~ .custom-control-label::before { color: #fff; background-color: #0045b6; } .custom-control-input:focus ~ .custom-control-label::before { box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(0, 69, 182, 0.25); } .custom-control-input:active ~ .custom-control-label::before { color: #fff; background-color: #6aa2ff; } .custom-control-input:disabled ~ .custom-control-label { color: #6c757d; } .custom-control-input:disabled ~ .custom-control-label::before { background-color: #e9ecef; } .custom-control-label { margin-bottom: 0; } .custom-control-label::before { position: absolute; top: 0.25rem; left: 0; display: block; width: 1rem; height: 1rem; pointer-events: none; content: ""; user-select: none; background-color: #dee2e6; } .custom-control-label::after { position: absolute; top: 0.25rem; left: 0; display: block; width: 1rem; height: 1rem; content: ""; background-repeat: no-repeat; background-position: center center; background-size: 50% 50%; } .custom-checkbox .custom-control-label::before { border-radius: 0.25rem; } .custom-checkbox .custom-control-input:checked ~ .custom-control-label::before { background-color: #0045b6; } .custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"); } .custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::before { background-color: #0045b6; } .custom-checkbox .custom-control-input:indeterminate ~ .custom-control-label::after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='%23fff' d='M0 2h4'/%3E%3C/svg%3E"); } .custom-checkbox .custom-control-input:disabled:checked ~ .custom-control-label::before { background-color: rgba(0, 69, 182, 0.5); } .custom-checkbox .custom-control-input:disabled:indeterminate ~ .custom-control-label::before { background-color: rgba(0, 69, 182, 0.5); } .custom-radio .custom-control-label::before { border-radius: 50%; } .custom-radio .custom-control-input:checked ~ .custom-control-label::before { background-color: #0045b6; } .custom-radio .custom-control-input:checked ~ .custom-control-label::after { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='%23fff'/%3E%3C/svg%3E"); } .custom-radio .custom-control-input:disabled:checked ~ .custom-control-label::before { background-color: rgba(0, 69, 182, 0.5); } .custom-select { display: inline-block; width: 100%; height: calc(2.25rem + 2px); padding: 0.375rem 1.75rem 0.375rem 0.75rem; line-height: 1.5; color: #495057; vertical-align: middle; background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E") no-repeat right 0.75rem center; background-size: 8px 10px; border: 1px solid #ced4da; border-radius: 0.25rem; appearance: none; } .custom-select:focus { border-color: #3783ff; outline: 0; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px rgba(55, 131, 255, 0.5); } .custom-select:focus::-ms-value { color: #495057; background-color: #fff; } .custom-select[multiple], .custom-select[size]:not([size="1"]) { height: auto; padding-right: 0.75rem; background-image: none; } .custom-select:disabled { color: #6c757d; background-color: #e9ecef; } .custom-select::-ms-expand { opacity: 0; } .custom-select-sm { height: calc(1.8125rem + 2px); padding-top: 0.375rem; padding-bottom: 0.375rem; font-size: 75%; } .custom-select-lg { height: calc(2.875rem + 2px); padding-top: 0.375rem; padding-bottom: 0.375rem; font-size: 125%; } .custom-file { position: relative; display: inline-block; width: 100%; height: calc(2.25rem + 2px); margin-bottom: 0; } .custom-file-input { position: relative; z-index: 2; width: 100%; height: calc(2.25rem + 2px); margin: 0; opacity: 0; } .custom-file-input:focus ~ .custom-file-control { border-color: #3783ff; box-shadow: 0 0 0 0.2rem rgba(0, 69, 182, 0.25); } .custom-file-input:focus ~ .custom-file-control::before { border-color: #3783ff; } .custom-file-input:lang(en) ~ .custom-file-label::after { content: "Browse"; } .custom-file-label { position: absolute; top: 0; right: 0; left: 0; z-index: 1; height: calc(2.25rem + 2px); padding: 0.375rem 0.75rem; line-height: 1.5; color: #495057; background-color: #fff; border: 1px solid #ced4da; border-radius: 0.25rem; } .custom-file-label::after { position: absolute; top: 0; right: 0; bottom: 0; z-index: 3; display: block; height: calc(calc(2.25rem + 2px) - 1px * 2); padding: 0.375rem 0.75rem; line-height: 1.5; color: #495057; content: "Browse"; background-color: #e9ecef; border-left: 1px solid #ced4da; border-radius: 0 0.25rem 0.25rem 0; } .nav { display: flex; flex-wrap: wrap; padding-left: 0; margin-bottom: 0; list-style: none; } .nav-link { display: block; padding: 0.5rem 1rem; } .nav-link:hover, .nav-link:focus { text-decoration: none; } .nav-link.disabled { color: #6c757d; } .nav-tabs { border-bottom: 1px solid #dee2e6; } .nav-tabs .nav-item { margin-bottom: -1px; } .nav-tabs .nav-link { border: 1px solid transparent; border-top-left-radius: 0.25rem; border-top-right-radius: 0.25rem; } .nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { border-color: #e9ecef #e9ecef #dee2e6; } .nav-tabs .nav-link.disabled { color: #6c757d; background-color: transparent; border-color: transparent; } .nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { color: #495057; background-color: #fff; border-color: #dee2e6 #dee2e6 #fff; } .nav-tabs .dropdown-menu { margin-top: -1px; border-top-left-radius: 0; border-top-right-radius: 0; } .nav-pills .nav-link { border-radius: 0.25rem; } .nav-pills .nav-link.active, .nav-pills .show > .nav-link { color: #fff; background-color: #0045b6; } .nav-fill .nav-item { flex: 1 1 auto; text-align: center; } .nav-justified .nav-item { flex-basis: 0; flex-grow: 1; text-align: center; } .tab-content > .tab-pane { display: none; } .tab-content > .active { display: block; } .navbar { position: relative; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; } .navbar > .container, .navbar > .container-fluid { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; } .navbar-brand { display: inline-block; padding-top: 0.3125rem; padding-bottom: 0.3125rem; margin-right: 1rem; font-size: 1.25rem; line-height: inherit; white-space: nowrap; } .navbar-brand:hover, .navbar-brand:focus { text-decoration: none; } .navbar-nav { display: flex; flex-direction: column; padding-left: 0; margin-bottom: 0; list-style: none; } .navbar-nav .nav-link { padding-right: 0; padding-left: 0; } .navbar-nav .dropdown-menu { position: static; float: none; } .navbar-text { display: inline-block; padding-top: 0.5rem; padding-bottom: 0.5rem; } .navbar-collapse { flex-basis: 100%; flex-grow: 1; align-items: center; } .navbar-toggler { padding: 0.25rem 0.75rem; font-size: 1.25rem; line-height: 1; background-color: transparent; border: 1px solid transparent; border-radius: 0.25rem; } .navbar-toggler:hover, .navbar-toggler:focus { text-decoration: none; } .navbar-toggler:not(:disabled):not(.disabled) { cursor: pointer; } .navbar-toggler-icon { display: inline-block; width: 1.5em; height: 1.5em; vertical-align: middle; content: ""; background: no-repeat center center; background-size: 100% 100%; } @media (max-width: 575.98px) { .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { padding-right: 0; padding-left: 0; } } @media (min-width: 576px) { .navbar-expand-sm { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand-sm .navbar-nav { flex-direction: row; } .navbar-expand-sm .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-sm .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand-sm .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand-sm > .container, .navbar-expand-sm > .container-fluid { flex-wrap: nowrap; } .navbar-expand-sm .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-sm .navbar-toggler { display: none; } .navbar-expand-sm .dropup .dropdown-menu { top: auto; bottom: 100%; } } @media (max-width: 767.98px) { .navbar-expand-md > .container, .navbar-expand-md > .container-fluid { padding-right: 0; padding-left: 0; } } @media (min-width: 768px) { .navbar-expand-md { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand-md .navbar-nav { flex-direction: row; } .navbar-expand-md .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-md .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand-md .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand-md > .container, .navbar-expand-md > .container-fluid { flex-wrap: nowrap; } .navbar-expand-md .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-md .navbar-toggler { display: none; } .navbar-expand-md .dropup .dropdown-menu { top: auto; bottom: 100%; } } @media (max-width: 991.98px) { .navbar-expand-lg > .container, .navbar-expand-lg > .container-fluid { padding-right: 0; padding-left: 0; } } @media (min-width: 992px) { .navbar-expand-lg { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand-lg .navbar-nav { flex-direction: row; } .navbar-expand-lg .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-lg .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand-lg .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand-lg > .container, .navbar-expand-lg > .container-fluid { flex-wrap: nowrap; } .navbar-expand-lg .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-lg .navbar-toggler { display: none; } .navbar-expand-lg .dropup .dropdown-menu { top: auto; bottom: 100%; } } @media (max-width: 1199.98px) { .navbar-expand-xl > .container, .navbar-expand-xl > .container-fluid { padding-right: 0; padding-left: 0; } } @media (min-width: 1200px) { .navbar-expand-xl { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand-xl .navbar-nav { flex-direction: row; } .navbar-expand-xl .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-xl .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand-xl .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand-xl > .container, .navbar-expand-xl > .container-fluid { flex-wrap: nowrap; } .navbar-expand-xl .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-xl .navbar-toggler { display: none; } .navbar-expand-xl .dropup .dropdown-menu { top: auto; bottom: 100%; } } @media (max-width: 1599.98px) { .navbar-expand-xxl > .container, .navbar-expand-xxl > .container-fluid { padding-right: 0; padding-left: 0; } } @media (min-width: 1600px) { .navbar-expand-xxl { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand-xxl .navbar-nav { flex-direction: row; } .navbar-expand-xxl .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-xxl .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand-xxl .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand-xxl > .container, .navbar-expand-xxl > .container-fluid { flex-wrap: nowrap; } .navbar-expand-xxl .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-xxl .navbar-toggler { display: none; } .navbar-expand-xxl .dropup .dropdown-menu { top: auto; bottom: 100%; } } .navbar-expand { flex-flow: row nowrap; justify-content: flex-start; } .navbar-expand > .container, .navbar-expand > .container-fluid { padding-right: 0; padding-left: 0; } .navbar-expand .navbar-nav { flex-direction: row; } .navbar-expand .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand .navbar-nav .dropdown-menu-right { right: 0; left: auto; } .navbar-expand .navbar-nav .nav-link { padding-right: 0.5rem; padding-left: 0.5rem; } .navbar-expand > .container, .navbar-expand > .container-fluid { flex-wrap: nowrap; } .navbar-expand .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand .navbar-toggler { display: none; } .navbar-expand .dropup .dropdown-menu { top: auto; bottom: 100%; } .navbar-light .navbar-brand { color: rgba(0, 0, 0, 0.9); } .navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:focus { color: rgba(0, 0, 0, 0.9); } .navbar-light .navbar-nav .nav-link { color: rgba(0, 0, 0, 0.5); } .navbar-light .navbar-nav .nav-link:hover, .navbar-light .navbar-nav .nav-link:focus { color: rgba(0, 0, 0, 0.7); } .navbar-light .navbar-nav .nav-link.disabled { color: rgba(0, 0, 0, 0.3); } .navbar-light .navbar-nav .show > .nav-link, .navbar-light .navbar-nav .active > .nav-link, .navbar-light .navbar-nav .nav-link.show, .navbar-light .navbar-nav .nav-link.active { color: rgba(0, 0, 0, 0.9); } .navbar-light .navbar-toggler { color: rgba(0, 0, 0, 0.5); border-color: rgba(0, 0, 0, 0.1); } .navbar-light .navbar-toggler-icon { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); } .navbar-light .navbar-text { color: rgba(0, 0, 0, 0.5); } .navbar-light .navbar-text a { color: rgba(0, 0, 0, 0.9); } .navbar-light .navbar-text a:hover, .navbar-light .navbar-text a:focus { color: rgba(0, 0, 0, 0.9); } .navbar-dark .navbar-brand { color: #fff; } .navbar-dark .navbar-brand:hover, .navbar-dark .navbar-brand:focus { color: #fff; } .navbar-dark .navbar-nav .nav-link { color: rgba(255, 255, 255, 0.5); } .navbar-dark .navbar-nav .nav-link:hover, .navbar-dark .navbar-nav .nav-link:focus { color: rgba(255, 255, 255, 0.75); } .navbar-dark .navbar-nav .nav-link.disabled { color: rgba(255, 255, 255, 0.25); } .navbar-dark .navbar-nav .show > .nav-link, .navbar-dark .navbar-nav .active > .nav-link, .navbar-dark .navbar-nav .nav-link.show, .navbar-dark .navbar-nav .nav-link.active { color: #fff; } .navbar-dark .navbar-toggler { color: rgba(255, 255, 255, 0.5); border-color: rgba(255, 255, 255, 0.1); } .navbar-dark .navbar-toggler-icon { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); } .navbar-dark .navbar-text { color: rgba(255, 255, 255, 0.5); } .navbar-dark .navbar-text a { color: #fff; } .navbar-dark .navbar-text a:hover, .navbar-dark .navbar-text a:focus { color: #fff; } .card { position: relative; display: flex; flex-direction: column; min-width: 0; word-wrap: break-word; background-color: #fff; background-clip: border-box; border: 1px solid rgba(0, 0, 0, 0.125); border-radius: 0.25rem; } .card > hr { margin-right: 0; margin-left: 0; } .card > .list-group:first-child .list-group-item:first-child { border-top-left-radius: 0.25rem; border-top-right-radius: 0.25rem; } .card > .list-group:last-child .list-group-item:last-child { border-bottom-right-radius: 0.25rem; border-bottom-left-radius: 0.25rem; } .card-body { flex: 1 1 auto; padding: 1.25rem; } .card-title { margin-bottom: 0.75rem; } .card-subtitle { margin-top: -0.375rem; margin-bottom: 0; } .card-text:last-child { margin-bottom: 0; } .card-link:hover { text-decoration: none; } .card-link + .card-link { margin-left: 1.25rem; } .card-header { padding: 0.75rem 1.25rem; margin-bottom: 0; background-color: rgba(0, 0, 0, 0.03); border-bottom: 1px solid rgba(0, 0, 0, 0.125); } .card-header:first-child { border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0; } .card-header + .list-group .list-group-item:first-child { border-top: 0; } .card-footer { padding: 0.75rem 1.25rem; background-color: rgba(0, 0, 0, 0.03); border-top: 1px solid rgba(0, 0, 0, 0.125); } .card-footer:last-child { border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px); } .card-header-tabs { margin-right: -0.625rem; margin-bottom: -0.75rem; margin-left: -0.625rem; border-bottom: 0; } .card-header-pills { margin-right: -0.625rem; margin-left: -0.625rem; } .card-img-overlay { position: absolute; top: 0; right: 0; bottom: 0; left: 0; padding: 1.25rem; } .card-img { width: 100%; border-radius: calc(0.25rem - 1px); } .card-img-top { width: 100%; border-top-left-radius: calc(0.25rem - 1px); border-top-right-radius: calc(0.25rem - 1px); } .card-img-bottom { width: 100%; border-bottom-right-radius: calc(0.25rem - 1px); border-bottom-left-radius: calc(0.25rem - 1px); } .card-deck { display: flex; flex-direction: column; } .card-deck .card { margin-bottom: 15px; } @media (min-width: 576px) { .card-deck { flex-flow: row wrap; margin-right: -15px; margin-left: -15px; } .card-deck .card { display: flex; flex: 1 0 0%; flex-direction: column; margin-right: 15px; margin-bottom: 0; margin-left: 15px; } } .card-group { display: flex; flex-direction: column; } .card-group > .card { margin-bottom: 15px; } @media (min-width: 576px) { .card-group { flex-flow: row wrap; } .card-group > .card { flex: 1 0 0%; margin-bottom: 0; } .card-group > .card + .card { margin-left: 0; border-left: 0; } .card-group > .card:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; } .card-group > .card:first-child .card-img-top, .card-group > .card:first-child .card-header { border-top-right-radius: 0; } .card-group > .card:first-child .card-img-bottom, .card-group > .card:first-child .card-footer { border-bottom-right-radius: 0; } .card-group > .card:last-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } .card-group > .card:last-child .card-img-top, .card-group > .card:last-child .card-header { border-top-left-radius: 0; } .card-group > .card:last-child .card-img-bottom, .card-group > .card:last-child .card-footer { border-bottom-left-radius: 0; } .card-group > .card:only-child { border-radius: 0.25rem; } .card-group > .card:only-child .card-img-top, .card-group > .card:only-child .card-header { border-top-left-radius: 0.25rem; border-top-right-radius: 0.25rem; } .card-group > .card:only-child .card-img-bottom, .card-group > .card:only-child .card-footer { border-bottom-right-radius: 0.25rem; border-bottom-left-radius: 0.25rem; } .card-group > .card:not(:first-child):not(:last-child):not(:only-child) { border-radius: 0; } .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-top, .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-img-bottom, .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-header, .card-group > .card:not(:first-child):not(:last-child):not(:only-child) .card-footer { border-radius: 0; } } .card-columns .card { margin-bottom: 0.75rem; } @media (min-width: 576px) { .card-columns { column-count: 3; column-gap: 1.25rem; } .card-columns .card { display: inline-block; width: 100%; } } .breadcrumb { display: flex; flex-wrap: wrap; padding: 0.75rem 1rem; margin-bottom: 1rem; list-style: none; background-color: #e9ecef; border-radius: 5; } .breadcrumb-item + .breadcrumb-item::before { display: inline-block; padding-right: 0.5rem; padding-left: 0.5rem; color: #6c757d; content: "/"; } .breadcrumb-item + .breadcrumb-item:hover::before { text-decoration: underline; } .breadcrumb-item + .breadcrumb-item:hover::before { text-decoration: none; } .breadcrumb-item.active { color: #6c757d; } .pagination { display: flex; padding-left: 0; list-style: none; border-radius: 5; } .page-link { position: relative; display: block; padding: 10px 10px; margin-left: 0; line-height: 24px; color: #fff; background-color: #0045b6; border: 0 solid #e8e9ee; } .page-link:hover { color: #fff; text-decoration: none; background-color: #343db0; border-color: #0045b6; } .page-link:focus { z-index: 2; outline: 0; } .page-link:not(:disabled):not(.disabled) { cursor: pointer; } .page-item:first-child .page-link { margin-left: 0; border-top-left-radius: 5; border-bottom-left-radius: 5; } .page-item:last-child .page-link { border-top-right-radius: 5; border-bottom-right-radius: 5; } .page-item.active .page-link { z-index: 1; color: #fff; background-color: #343db0; border-color: #0045b6; } .page-item.disabled .page-link { color: #74757f; pointer-events: none; cursor: auto; background-color: #e8e9ee; border-color: #e8e9ee; } .pagination-lg .page-link { padding: 0.75rem 1.5rem; font-size: 20px; line-height: 1.6; } .pagination-lg .page-item:first-child .page-link { border-top-left-radius: 6px; border-bottom-left-radius: 6px; } .pagination-lg .page-item:last-child .page-link { border-top-right-radius: 6px; border-bottom-right-radius: 6px; } .pagination-sm .page-link { padding: 0.25rem 0.5rem; font-size: 12px; line-height: 1.5; } .pagination-sm .page-item:first-child .page-link { border-top-left-radius: 0.2rem; border-bottom-left-radius: 0.2rem; } .pagination-sm .page-item:last-child .page-link { border-top-right-radius: 0.2rem; border-bottom-right-radius: 0.2rem; } .badge { display: inline-block; padding: 0.25em 0.4em; font-size: 75%; font-weight: 700; line-height: 1; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25rem; } .badge:empty { display: none; } .btn .badge { position: relative; top: -1px; } .badge-pill { padding-right: 0.6em; padding-left: 0.6em; border-radius: 10rem; } .jumbotron { padding: 2rem 1rem; margin-bottom: 2rem; background-color: #e9ecef; border-radius: 6px; } @media (min-width: 576px) { .jumbotron { padding: 4rem 2rem; } } .jumbotron-fluid { padding-right: 0; padding-left: 0; border-radius: 0; } .alert { position: relative; padding: 0.75rem 1.25rem; margin-bottom: 1rem; border: 1px solid transparent; border-radius: 0.25rem; } .alert-heading { color: inherit; } .alert-link { font-weight: 700; } .alert-dismissible { padding-right: 4rem; } .alert-dismissible .close { position: absolute; top: 0; right: 0; padding: 0.75rem 1.25rem; color: inherit; } @keyframes progress-bar-stripes { from { background-position: 1rem 0; } to { background-position: 0 0; } } .progress { display: flex; height: 1rem; overflow: hidden; font-size: 0.75rem; background-color: #e9ecef; border-radius: 0.25rem; } .progress-bar { display: flex; flex-direction: column; justify-content: center; color: #fff; text-align: center; background-color: #0045b6; transition: width 0.6s ease; } .progress-bar-striped { background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-size: 1rem 1rem; } .progress-bar-animated { animation: progress-bar-stripes 1s linear infinite; } .media { display: flex; align-items: flex-start; } .media-body { flex: 1; } .list-group { display: flex; flex-direction: column; padding-left: 0; margin-bottom: 0; } .list-group-item-action { width: 100%; color: #495057; text-align: inherit; } .list-group-item-action:hover, .list-group-item-action:focus { color: #495057; text-decoration: none; background-color: #f8f9fa; } .list-group-item-action:active { color: #212529; background-color: #e9ecef; } .list-group-item { position: relative; display: block; padding: 0.75rem 1.25rem; margin-bottom: -1px; background-color: #fff; border: 1px solid rgba(0, 0, 0, 0.125); } .list-group-item:first-child { border-top-left-radius: 0.25rem; border-top-right-radius: 0.25rem; } .list-group-item:last-child { margin-bottom: 0; border-bottom-right-radius: 0.25rem; border-bottom-left-radius: 0.25rem; } .list-group-item:hover, .list-group-item:focus { z-index: 1; text-decoration: none; } .list-group-item.disabled, .list-group-item:disabled { color: #6c757d; background-color: #fff; } .list-group-item.active { z-index: 2; color: #fff; background-color: #0045b6; border-color: #0045b6; } .list-group-flush .list-group-item { border-right: 0; border-left: 0; border-radius: 0; } .list-group-flush:first-child .list-group-item:first-child { border-top: 0; } .list-group-flush:last-child .list-group-item:last-child { border-bottom: 0; } .close { float: right; font-size: 1.5rem; font-weight: 700; line-height: 1; color: #000; text-shadow: 0 1px 0 #fff; opacity: .5; } .close:hover, .close:focus { color: #000; text-decoration: none; opacity: .75; } .close:not(:disabled):not(.disabled) { cursor: pointer; } button.close { padding: 0; background-color: transparent; border: 0; -webkit-appearance: none; } .modal-open { overflow: hidden; } .modal { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 1150; display: none; overflow: hidden; outline: 0; } .modal-open .modal { overflow-x: hidden; overflow-y: auto; } .modal-dialog { position: relative; width: auto; margin: 0.5rem; pointer-events: none; } .modal.fade .modal-dialog { transition: transform 0.3s ease-out; transform: translate(0, -25%); } .modal.show .modal-dialog { transform: translate(0, 0); } .modal-dialog-centered { display: flex; align-items: center; min-height: calc(100% - (0.5rem * 2)); } .modal-content { position: relative; display: flex; flex-direction: column; width: 100%; pointer-events: auto; background-color: #fff; background-clip: padding-box; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 6px; outline: 0; } .modal-backdrop { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 1140; background-color: #000; } .modal-backdrop.fade { opacity: 0; } .modal-backdrop.show { opacity: 0.5; } .modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #e9ecef; border-top-left-radius: 6px; border-top-right-radius: 6px; } .modal-header .close { padding: 1rem; margin: -1rem -1rem -1rem auto; } .modal-title { margin-bottom: 0; line-height: 1.5; } .modal-body { position: relative; flex: 1 1 auto; padding: 1rem; } .modal-footer { display: flex; align-items: center; justify-content: flex-end; padding: 1rem; border-top: 1px solid #e9ecef; } .modal-footer > :not(:first-child) { margin-left: .25rem; } .modal-footer > :not(:last-child) { margin-right: .25rem; } .modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; } @media (min-width: 576px) { .modal-dialog { max-width: 500px; margin: 1.75rem auto; } .modal-dialog-centered { min-height: calc(100% - (1.75rem * 2)); } .modal-sm { max-width: 300px; } } @media (min-width: 992px) { .modal-lg { max-width: 800px; } } .tooltip { position: absolute; z-index: 1070; display: block; margin: 0; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-style: normal; font-weight: 400; line-height: 1.5; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; letter-spacing: normal; word-break: normal; word-spacing: normal; white-space: normal; line-break: auto; font-size: 0.875rem; word-wrap: break-word; opacity: 0; } .tooltip.show { opacity: 1; } .tooltip .arrow { position: absolute; display: block; width: 14px; height: 7px; } .tooltip .arrow::before { position: absolute; content: ""; border-color: transparent; border-style: solid; } .bs-tooltip-top, .bs-tooltip-auto[x-placement^="top"] { padding: 7px 0; } .bs-tooltip-top .arrow, .bs-tooltip-auto[x-placement^="top"] .arrow { bottom: 0; } .bs-tooltip-top .arrow::before, .bs-tooltip-auto[x-placement^="top"] .arrow::before { top: 0; border-width: 7px 7px 0; border-top-color: #0045b6; } .bs-tooltip-right, .bs-tooltip-auto[x-placement^="right"] { padding: 0 7px; } .bs-tooltip-right .arrow, .bs-tooltip-auto[x-placement^="right"] .arrow { left: 0; width: 7px; height: 14px; } .bs-tooltip-right .arrow::before, .bs-tooltip-auto[x-placement^="right"] .arrow::before { right: 0; border-width: 7px 7px 7px 0; border-right-color: #0045b6; } .bs-tooltip-bottom, .bs-tooltip-auto[x-placement^="bottom"] { padding: 7px 0; } .bs-tooltip-bottom .arrow, .bs-tooltip-auto[x-placement^="bottom"] .arrow { top: 0; } .bs-tooltip-bottom .arrow::before, .bs-tooltip-auto[x-placement^="bottom"] .arrow::before { bottom: 0; border-width: 0 7px 7px; border-bottom-color: #0045b6; } .bs-tooltip-left, .bs-tooltip-auto[x-placement^="left"] { padding: 0 7px; } .bs-tooltip-left .arrow, .bs-tooltip-auto[x-placement^="left"] .arrow { right: 0; width: 7px; height: 14px; } .bs-tooltip-left .arrow::before, .bs-tooltip-auto[x-placement^="left"] .arrow::before { left: 0; border-width: 7px 0 7px 7px; border-left-color: #0045b6; } .tooltip-inner { max-width: 200px; padding: 6px 10px; color: #fff; text-align: center; background-color: #0045b6; border-radius: 0.25rem; } .popover { position: absolute; top: 0; left: 0; z-index: 1060; display: block; max-width: 276px; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-style: normal; font-weight: 400; line-height: 1.5; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; letter-spacing: normal; word-break: normal; word-spacing: normal; white-space: normal; line-break: auto; font-size: 0.875rem; word-wrap: break-word; background-color: #fff; background-clip: padding-box; border: 1px solid rgba(0, 0, 0, 0.2); border-radius: 0.3rem; } .popover .arrow { position: absolute; display: block; width: 1rem; height: 0.5rem; margin: 0 6px; } .popover .arrow::before, .popover .arrow::after { position: absolute; display: block; content: ""; border-color: transparent; border-style: solid; } .bs-popover-top, .bs-popover-auto[x-placement^="top"] { margin-bottom: 0.5rem; } .bs-popover-top .arrow, .bs-popover-auto[x-placement^="top"] .arrow { bottom: calc((0.5rem + 1px) * -1); } .bs-popover-top .arrow::before, .bs-popover-auto[x-placement^="top"] .arrow::before, .bs-popover-top .arrow::after, .bs-popover-auto[x-placement^="top"] .arrow::after { border-width: 0.5rem 0.5rem 0; } .bs-popover-top .arrow::before, .bs-popover-auto[x-placement^="top"] .arrow::before { bottom: 0; border-top-color: rgba(0, 0, 0, 0.25); } .bs-popover-top .arrow::after, .bs-popover-auto[x-placement^="top"] .arrow::after { bottom: 1px; border-top-color: #fff; } .bs-popover-right, .bs-popover-auto[x-placement^="right"] { margin-left: 0.5rem; } .bs-popover-right .arrow, .bs-popover-auto[x-placement^="right"] .arrow { left: calc((0.5rem + 1px) * -1); width: 0.5rem; height: 1rem; margin: 6px 0; } .bs-popover-right .arrow::before, .bs-popover-auto[x-placement^="right"] .arrow::before, .bs-popover-right .arrow::after, .bs-popover-auto[x-placement^="right"] .arrow::after { border-width: 0.5rem 0.5rem 0.5rem 0; } .bs-popover-right .arrow::before, .bs-popover-auto[x-placement^="right"] .arrow::before { left: 0; border-right-color: rgba(0, 0, 0, 0.25); } .bs-popover-right .arrow::after, .bs-popover-auto[x-placement^="right"] .arrow::after { left: 1px; border-right-color: #fff; } .bs-popover-bottom, .bs-popover-auto[x-placement^="bottom"] { margin-top: 0.5rem; } .bs-popover-bottom .arrow, .bs-popover-auto[x-placement^="bottom"] .arrow { top: calc((0.5rem + 1px) * -1); } .bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^="bottom"] .arrow::before, .bs-popover-bottom .arrow::after, .bs-popover-auto[x-placement^="bottom"] .arrow::after { border-width: 0 0.5rem 0.5rem 0.5rem; } .bs-popover-bottom .arrow::before, .bs-popover-auto[x-placement^="bottom"] .arrow::before { top: 0; border-bottom-color: rgba(0, 0, 0, 0.25); } .bs-popover-bottom .arrow::after, .bs-popover-auto[x-placement^="bottom"] .arrow::after { top: 1px; border-bottom-color: #fff; } .bs-popover-bottom .popover-header::before, .bs-popover-auto[x-placement^="bottom"] .popover-header::before { position: absolute; top: 0; left: 50%; display: block; width: 1rem; margin-left: -0.5rem; content: ""; border-bottom: 1px solid #f7f7f7; } .bs-popover-left, .bs-popover-auto[x-placement^="left"] { margin-right: 0.5rem; } .bs-popover-left .arrow, .bs-popover-auto[x-placement^="left"] .arrow { right: calc((0.5rem + 1px) * -1); width: 0.5rem; height: 1rem; margin: 6px 0; } .bs-popover-left .arrow::before, .bs-popover-auto[x-placement^="left"] .arrow::before, .bs-popover-left .arrow::after, .bs-popover-auto[x-placement^="left"] .arrow::after { border-width: 0.5rem 0 0.5rem 0.5rem; } .bs-popover-left .arrow::before, .bs-popover-auto[x-placement^="left"] .arrow::before { right: 0; border-left-color: rgba(0, 0, 0, 0.25); } .bs-popover-left .arrow::after, .bs-popover-auto[x-placement^="left"] .arrow::after { right: 1px; border-left-color: #fff; } .popover-header { padding: 0.5rem 0.75rem; margin-bottom: 0; font-size: 16px; color: #343a40; background-color: #f7f7f7; border-bottom: 1px solid #ebebeb; border-top-left-radius: calc(6px - 1px); border-top-right-radius: calc(6px - 1px); } .popover-header:empty { display: none; } .popover-body { padding: 0.5rem 0.75rem; color: #212529; } .carousel { position: relative; } .carousel-inner { position: relative; width: 100%; overflow: hidden; } .carousel-item { position: relative; display: none; align-items: center; width: 100%; transition: transform 0.6s ease; backface-visibility: hidden; perspective: 1000px; } .carousel-item.active, .carousel-item-next, .carousel-item-prev { display: block; } .carousel-item-next, .carousel-item-prev { position: absolute; top: 0; } .carousel-item-next.carousel-item-left, .carousel-item-prev.carousel-item-right { transform: translateX(0); } @supports (transform-style: preserve-3d) { .carousel-item-next.carousel-item-left, .carousel-item-prev.carousel-item-right { transform: translate3d(0, 0, 0); } } .carousel-item-next, .active.carousel-item-right { transform: translateX(100%); } @supports (transform-style: preserve-3d) { .carousel-item-next, .active.carousel-item-right { transform: translate3d(100%, 0, 0); } } .carousel-item-prev, .active.carousel-item-left { transform: translateX(-100%); } @supports (transform-style: preserve-3d) { .carousel-item-prev, .active.carousel-item-left { transform: translate3d(-100%, 0, 0); } } .carousel-control-prev, .carousel-control-next { position: absolute; top: 0; bottom: 0; display: flex; align-items: center; justify-content: center; width: 15%; color: #fff; text-align: center; opacity: 0.5; } .carousel-control-prev:hover, .carousel-control-prev:focus, .carousel-control-next:hover, .carousel-control-next:focus { color: #fff; text-decoration: none; outline: 0; opacity: .9; } .carousel-control-prev { left: 0; } .carousel-control-next { right: 0; } .carousel-control-prev-icon, .carousel-control-next-icon { display: inline-block; width: 20px; height: 20px; background: transparent no-repeat center center; background-size: 100% 100%; } .carousel-control-prev-icon { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E"); } .carousel-control-next-icon { background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3E%3Cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E"); } .carousel-indicators { position: absolute; right: 0; bottom: 10px; left: 0; z-index: 15; display: flex; justify-content: center; padding-left: 0; margin-right: 15%; margin-left: 15%; list-style: none; } .carousel-indicators li { position: relative; flex: 0 1 auto; width: 30px; height: 3px; margin-right: 3px; margin-left: 3px; text-indent: -999px; background-color: rgba(255, 255, 255, 0.5); } .carousel-indicators li::before { position: absolute; top: -10px; left: 0; display: inline-block; width: 100%; height: 10px; content: ""; } .carousel-indicators li::after { position: absolute; bottom: -10px; left: 0; display: inline-block; width: 100%; height: 10px; content: ""; } .carousel-indicators .active { background-color: #fff; } .carousel-caption { position: absolute; right: 15%; bottom: 20px; left: 15%; z-index: 10; padding-top: 20px; padding-bottom: 20px; color: #fff; text-align: center; } .align-baseline { vertical-align: baseline !important; } .align-top { vertical-align: top !important; } .align-middle { vertical-align: middle !important; } .align-bottom { vertical-align: bottom !important; } .align-text-bottom { vertical-align: text-bottom !important; } .align-text-top { vertical-align: text-top !important; } .bg-white { background-color: #fff !important; } .bg-transparent { background-color: transparent !important; } .border { border: 1px solid #dee2e6 !important; } .border-top { border-top: 1px solid #dee2e6 !important; } .border-right { border-right: 1px solid #dee2e6 !important; } .border-bottom { border-bottom: 1px solid #dee2e6 !important; } .border-left { border-left: 1px solid #dee2e6 !important; } .border-0 { border: 0 !important; } .border-top-0 { border-top: 0 !important; } .border-right-0 { border-right: 0 !important; } .border-bottom-0 { border-bottom: 0 !important; } .border-left-0 { border-left: 0 !important; } .border-white { border-color: #fff !important; } .rounded { border-radius: 5 !important; } .rounded-top { border-top-left-radius: 5 !important; border-top-right-radius: 5 !important; } .rounded-right { border-top-right-radius: 5 !important; border-bottom-right-radius: 5 !important; } .rounded-bottom { border-bottom-right-radius: 5 !important; border-bottom-left-radius: 5 !important; } .rounded-left { border-top-left-radius: 5 !important; border-bottom-left-radius: 5 !important; } .rounded-circle { border-radius: 50% !important; } .rounded-0 { border-radius: 0 !important; } .clearfix::after { display: block; clear: both; content: ""; } .d-none { display: none !important; } .d-inline { display: inline !important; } .d-inline-block { display: inline-block !important; } .d-block { display: block !important; } .d-table { display: table !important; } .d-table-row { display: table-row !important; } .d-table-cell { display: table-cell !important; } .d-flex { display: flex !important; } .d-inline-flex { display: inline-flex !important; } @media (min-width: 576px) { .d-sm-none { display: none !important; } .d-sm-inline { display: inline !important; } .d-sm-inline-block { display: inline-block !important; } .d-sm-block { display: block !important; } .d-sm-table { display: table !important; } .d-sm-table-row { display: table-row !important; } .d-sm-table-cell { display: table-cell !important; } .d-sm-flex { display: flex !important; } .d-sm-inline-flex { display: inline-flex !important; } } @media (min-width: 768px) { .d-md-none { display: none !important; } .d-md-inline { display: inline !important; } .d-md-inline-block { display: inline-block !important; } .d-md-block { display: block !important; } .d-md-table { display: table !important; } .d-md-table-row { display: table-row !important; } .d-md-table-cell { display: table-cell !important; } .d-md-flex { display: flex !important; } .d-md-inline-flex { display: inline-flex !important; } } @media (min-width: 992px) { .d-lg-none { display: none !important; } .d-lg-inline { display: inline !important; } .d-lg-inline-block { display: inline-block !important; } .d-lg-block { display: block !important; } .d-lg-table { display: table !important; } .d-lg-table-row { display: table-row !important; } .d-lg-table-cell { display: table-cell !important; } .d-lg-flex { display: flex !important; } .d-lg-inline-flex { display: inline-flex !important; } } @media (min-width: 1200px) { .d-xl-none { display: none !important; } .d-xl-inline { display: inline !important; } .d-xl-inline-block { display: inline-block !important; } .d-xl-block { display: block !important; } .d-xl-table { display: table !important; } .d-xl-table-row { display: table-row !important; } .d-xl-table-cell { display: table-cell !important; } .d-xl-flex { display: flex !important; } .d-xl-inline-flex { display: inline-flex !important; } } @media (min-width: 1600px) { .d-xxl-none { display: none !important; } .d-xxl-inline { display: inline !important; } .d-xxl-inline-block { display: inline-block !important; } .d-xxl-block { display: block !important; } .d-xxl-table { display: table !important; } .d-xxl-table-row { display: table-row !important; } .d-xxl-table-cell { display: table-cell !important; } .d-xxl-flex { display: flex !important; } .d-xxl-inline-flex { display: inline-flex !important; } } @media print { .d-print-none { display: none !important; } .d-print-inline { display: inline !important; } .d-print-inline-block { display: inline-block !important; } .d-print-block { display: block !important; } .d-print-table { display: table !important; } .d-print-table-row { display: table-row !important; } .d-print-table-cell { display: table-cell !important; } .d-print-flex { display: flex !important; } .d-print-inline-flex { display: inline-flex !important; } } .embed-responsive { position: relative; display: block; width: 100%; padding: 0; overflow: hidden; } .embed-responsive::before { display: block; content: ""; } .embed-responsive .embed-responsive-item, .embed-responsive iframe, .embed-responsive embed, .embed-responsive object, .embed-responsive video { position: absolute; top: 0; bottom: 0; left: 0; width: 100%; height: 100%; border: 0; } .embed-responsive-21by9::before { padding-top: 42.85714%; } .embed-responsive-16by9::before { padding-top: 56.25%; } .embed-responsive-4by3::before { padding-top: 75%; } .embed-responsive-1by1::before { padding-top: 100%; } .flex-row { flex-direction: row !important; } .flex-column { flex-direction: column !important; } .flex-row-reverse { flex-direction: row-reverse !important; } .flex-column-reverse { flex-direction: column-reverse !important; } .flex-wrap { flex-wrap: wrap !important; } .flex-nowrap { flex-wrap: nowrap !important; } .flex-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-start { justify-content: flex-start !important; } .justify-content-end { justify-content: flex-end !important; } .justify-content-center { justify-content: center !important; } .justify-content-between { justify-content: space-between !important; } .justify-content-around { justify-content: space-around !important; } .align-items-start { align-items: flex-start !important; } .align-items-end { align-items: flex-end !important; } .align-items-center { align-items: center !important; } .align-items-baseline { align-items: baseline !important; } .align-items-stretch { align-items: stretch !important; } .align-content-start { align-content: flex-start !important; } .align-content-end { align-content: flex-end !important; } .align-content-center { align-content: center !important; } .align-content-between { align-content: space-between !important; } .align-content-around { align-content: space-around !important; } .align-content-stretch { align-content: stretch !important; } .align-self-auto { align-self: auto !important; } .align-self-start { align-self: flex-start !important; } .align-self-end { align-self: flex-end !important; } .align-self-center { align-self: center !important; } .align-self-baseline { align-self: baseline !important; } .align-self-stretch { align-self: stretch !important; } @media (min-width: 576px) { .flex-sm-row { flex-direction: row !important; } .flex-sm-column { flex-direction: column !important; } .flex-sm-row-reverse { flex-direction: row-reverse !important; } .flex-sm-column-reverse { flex-direction: column-reverse !important; } .flex-sm-wrap { flex-wrap: wrap !important; } .flex-sm-nowrap { flex-wrap: nowrap !important; } .flex-sm-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-sm-start { justify-content: flex-start !important; } .justify-content-sm-end { justify-content: flex-end !important; } .justify-content-sm-center { justify-content: center !important; } .justify-content-sm-between { justify-content: space-between !important; } .justify-content-sm-around { justify-content: space-around !important; } .align-items-sm-start { align-items: flex-start !important; } .align-items-sm-end { align-items: flex-end !important; } .align-items-sm-center { align-items: center !important; } .align-items-sm-baseline { align-items: baseline !important; } .align-items-sm-stretch { align-items: stretch !important; } .align-content-sm-start { align-content: flex-start !important; } .align-content-sm-end { align-content: flex-end !important; } .align-content-sm-center { align-content: center !important; } .align-content-sm-between { align-content: space-between !important; } .align-content-sm-around { align-content: space-around !important; } .align-content-sm-stretch { align-content: stretch !important; } .align-self-sm-auto { align-self: auto !important; } .align-self-sm-start { align-self: flex-start !important; } .align-self-sm-end { align-self: flex-end !important; } .align-self-sm-center { align-self: center !important; } .align-self-sm-baseline { align-self: baseline !important; } .align-self-sm-stretch { align-self: stretch !important; } } @media (min-width: 768px) { .flex-md-row { flex-direction: row !important; } .flex-md-column { flex-direction: column !important; } .flex-md-row-reverse { flex-direction: row-reverse !important; } .flex-md-column-reverse { flex-direction: column-reverse !important; } .flex-md-wrap { flex-wrap: wrap !important; } .flex-md-nowrap { flex-wrap: nowrap !important; } .flex-md-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-md-start { justify-content: flex-start !important; } .justify-content-md-end { justify-content: flex-end !important; } .justify-content-md-center { justify-content: center !important; } .justify-content-md-between { justify-content: space-between !important; } .justify-content-md-around { justify-content: space-around !important; } .align-items-md-start { align-items: flex-start !important; } .align-items-md-end { align-items: flex-end !important; } .align-items-md-center { align-items: center !important; } .align-items-md-baseline { align-items: baseline !important; } .align-items-md-stretch { align-items: stretch !important; } .align-content-md-start { align-content: flex-start !important; } .align-content-md-end { align-content: flex-end !important; } .align-content-md-center { align-content: center !important; } .align-content-md-between { align-content: space-between !important; } .align-content-md-around { align-content: space-around !important; } .align-content-md-stretch { align-content: stretch !important; } .align-self-md-auto { align-self: auto !important; } .align-self-md-start { align-self: flex-start !important; } .align-self-md-end { align-self: flex-end !important; } .align-self-md-center { align-self: center !important; } .align-self-md-baseline { align-self: baseline !important; } .align-self-md-stretch { align-self: stretch !important; } } @media (min-width: 992px) { .flex-lg-row { flex-direction: row !important; } .flex-lg-column { flex-direction: column !important; } .flex-lg-row-reverse { flex-direction: row-reverse !important; } .flex-lg-column-reverse { flex-direction: column-reverse !important; } .flex-lg-wrap { flex-wrap: wrap !important; } .flex-lg-nowrap { flex-wrap: nowrap !important; } .flex-lg-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-lg-start { justify-content: flex-start !important; } .justify-content-lg-end { justify-content: flex-end !important; } .justify-content-lg-center { justify-content: center !important; } .justify-content-lg-between { justify-content: space-between !important; } .justify-content-lg-around { justify-content: space-around !important; } .align-items-lg-start { align-items: flex-start !important; } .align-items-lg-end { align-items: flex-end !important; } .align-items-lg-center { align-items: center !important; } .align-items-lg-baseline { align-items: baseline !important; } .align-items-lg-stretch { align-items: stretch !important; } .align-content-lg-start { align-content: flex-start !important; } .align-content-lg-end { align-content: flex-end !important; } .align-content-lg-center { align-content: center !important; } .align-content-lg-between { align-content: space-between !important; } .align-content-lg-around { align-content: space-around !important; } .align-content-lg-stretch { align-content: stretch !important; } .align-self-lg-auto { align-self: auto !important; } .align-self-lg-start { align-self: flex-start !important; } .align-self-lg-end { align-self: flex-end !important; } .align-self-lg-center { align-self: center !important; } .align-self-lg-baseline { align-self: baseline !important; } .align-self-lg-stretch { align-self: stretch !important; } } @media (min-width: 1200px) { .flex-xl-row { flex-direction: row !important; } .flex-xl-column { flex-direction: column !important; } .flex-xl-row-reverse { flex-direction: row-reverse !important; } .flex-xl-column-reverse { flex-direction: column-reverse !important; } .flex-xl-wrap { flex-wrap: wrap !important; } .flex-xl-nowrap { flex-wrap: nowrap !important; } .flex-xl-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-xl-start { justify-content: flex-start !important; } .justify-content-xl-end { justify-content: flex-end !important; } .justify-content-xl-center { justify-content: center !important; } .justify-content-xl-between { justify-content: space-between !important; } .justify-content-xl-around { justify-content: space-around !important; } .align-items-xl-start { align-items: flex-start !important; } .align-items-xl-end { align-items: flex-end !important; } .align-items-xl-center { align-items: center !important; } .align-items-xl-baseline { align-items: baseline !important; } .align-items-xl-stretch { align-items: stretch !important; } .align-content-xl-start { align-content: flex-start !important; } .align-content-xl-end { align-content: flex-end !important; } .align-content-xl-center { align-content: center !important; } .align-content-xl-between { align-content: space-between !important; } .align-content-xl-around { align-content: space-around !important; } .align-content-xl-stretch { align-content: stretch !important; } .align-self-xl-auto { align-self: auto !important; } .align-self-xl-start { align-self: flex-start !important; } .align-self-xl-end { align-self: flex-end !important; } .align-self-xl-center { align-self: center !important; } .align-self-xl-baseline { align-self: baseline !important; } .align-self-xl-stretch { align-self: stretch !important; } } @media (min-width: 1600px) { .flex-xxl-row { flex-direction: row !important; } .flex-xxl-column { flex-direction: column !important; } .flex-xxl-row-reverse { flex-direction: row-reverse !important; } .flex-xxl-column-reverse { flex-direction: column-reverse !important; } .flex-xxl-wrap { flex-wrap: wrap !important; } .flex-xxl-nowrap { flex-wrap: nowrap !important; } .flex-xxl-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-xxl-start { justify-content: flex-start !important; } .justify-content-xxl-end { justify-content: flex-end !important; } .justify-content-xxl-center { justify-content: center !important; } .justify-content-xxl-between { justify-content: space-between !important; } .justify-content-xxl-around { justify-content: space-around !important; } .align-items-xxl-start { align-items: flex-start !important; } .align-items-xxl-end { align-items: flex-end !important; } .align-items-xxl-center { align-items: center !important; } .align-items-xxl-baseline { align-items: baseline !important; } .align-items-xxl-stretch { align-items: stretch !important; } .align-content-xxl-start { align-content: flex-start !important; } .align-content-xxl-end { align-content: flex-end !important; } .align-content-xxl-center { align-content: center !important; } .align-content-xxl-between { align-content: space-between !important; } .align-content-xxl-around { align-content: space-around !important; } .align-content-xxl-stretch { align-content: stretch !important; } .align-self-xxl-auto { align-self: auto !important; } .align-self-xxl-start { align-self: flex-start !important; } .align-self-xxl-end { align-self: flex-end !important; } .align-self-xxl-center { align-self: center !important; } .align-self-xxl-baseline { align-self: baseline !important; } .align-self-xxl-stretch { align-self: stretch !important; } } .float-left { float: left !important; } .float-right { float: right !important; } .float-none { float: none !important; } @media (min-width: 576px) { .float-sm-left { float: left !important; } .float-sm-right { float: right !important; } .float-sm-none { float: none !important; } } @media (min-width: 768px) { .float-md-left { float: left !important; } .float-md-right { float: right !important; } .float-md-none { float: none !important; } } @media (min-width: 992px) { .float-lg-left { float: left !important; } .float-lg-right { float: right !important; } .float-lg-none { float: none !important; } } @media (min-width: 1200px) { .float-xl-left { float: left !important; } .float-xl-right { float: right !important; } .float-xl-none { float: none !important; } } @media (min-width: 1600px) { .float-xxl-left { float: left !important; } .float-xxl-right { float: right !important; } .float-xxl-none { float: none !important; } } .position-static { position: static !important; } .position-relative { position: relative !important; } .position-absolute { position: absolute !important; } .position-fixed { position: fixed !important; } .position-sticky { position: sticky !important; } .fixed-top { position: fixed; top: 0; right: 0; left: 0; z-index: 1030; } .fixed-bottom { position: fixed; right: 0; bottom: 0; left: 0; z-index: 1030; } @supports (position: sticky) { .sticky-top { position: sticky; top: 0; z-index: 1020; } } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; } .sr-only-focusable:active, .sr-only-focusable:focus { position: static; width: auto; height: auto; overflow: visible; clip: auto; white-space: normal; clip-path: none; } .w-25 { width: 25% !important; } .w-50 { width: 50% !important; } .w-75 { width: 75% !important; } .w-100 { width: 100% !important; } .h-25 { height: 25% !important; } .h-50 { height: 50% !important; } .h-75 { height: 75% !important; } .h-100 { height: 100% !important; } .mw-100 { max-width: 100% !important; } .mh-100 { max-height: 100% !important; } .m-0 { margin: 0 !important; } .mt-0, .my-0 { margin-top: 0 !important; } .mr-0, .mx-0 { margin-right: 0 !important; } .mb-0, .my-0 { margin-bottom: 0 !important; } .ml-0, .mx-0 { margin-left: 0 !important; } .m-1 { margin: 0.25rem !important; } .mt-1, .my-1 { margin-top: 0.25rem !important; } .mr-1, .mx-1 { margin-right: 0.25rem !important; } .mb-1, .my-1 { margin-bottom: 0.25rem !important; } .ml-1, .mx-1 { margin-left: 0.25rem !important; } .m-2 { margin: 0.5rem !important; } .mt-2, .my-2 { margin-top: 0.5rem !important; } .mr-2, .mx-2 { margin-right: 0.5rem !important; } .mb-2, .my-2 { margin-bottom: 0.5rem !important; } .ml-2, .mx-2 { margin-left: 0.5rem !important; } .m-3 { margin: 1rem !important; } .mt-3, .my-3 { margin-top: 1rem !important; } .mr-3, .mx-3 { margin-right: 1rem !important; } .mb-3, .my-3 { margin-bottom: 1rem !important; } .ml-3, .mx-3 { margin-left: 1rem !important; } .m-4 { margin: 1.5rem !important; } .mt-4, .my-4 { margin-top: 1.5rem !important; } .mr-4, .mx-4 { margin-right: 1.5rem !important; } .mb-4, .my-4 { margin-bottom: 1.5rem !important; } .ml-4, .mx-4 { margin-left: 1.5rem !important; } .m-5 { margin: 3rem !important; } .mt-5, .my-5 { margin-top: 3rem !important; } .mr-5, .mx-5 { margin-right: 3rem !important; } .mb-5, .my-5 { margin-bottom: 3rem !important; } .ml-5, .mx-5 { margin-left: 3rem !important; } .p-0 { padding: 0 !important; } .pt-0, .py-0 { padding-top: 0 !important; } .pr-0, .px-0 { padding-right: 0 !important; } .pb-0, .py-0 { padding-bottom: 0 !important; } .pl-0, .px-0 { padding-left: 0 !important; } .p-1 { padding: 0.25rem !important; } .pt-1, .py-1 { padding-top: 0.25rem !important; } .pr-1, .px-1 { padding-right: 0.25rem !important; } .pb-1, .py-1 { padding-bottom: 0.25rem !important; } .pl-1, .px-1 { padding-left: 0.25rem !important; } .p-2 { padding: 0.5rem !important; } .pt-2, .py-2 { padding-top: 0.5rem !important; } .pr-2, .px-2 { padding-right: 0.5rem !important; } .pb-2, .py-2 { padding-bottom: 0.5rem !important; } .pl-2, .px-2 { padding-left: 0.5rem !important; } .p-3 { padding: 1rem !important; } .pt-3, .py-3 { padding-top: 1rem !important; } .pr-3, .px-3 { padding-right: 1rem !important; } .pb-3, .py-3 { padding-bottom: 1rem !important; } .pl-3, .px-3 { padding-left: 1rem !important; } .p-4 { padding: 1.5rem !important; } .pt-4, .py-4 { padding-top: 1.5rem !important; } .pr-4, .px-4 { padding-right: 1.5rem !important; } .pb-4, .py-4 { padding-bottom: 1.5rem !important; } .pl-4, .px-4 { padding-left: 1.5rem !important; } .p-5 { padding: 3rem !important; } .pt-5, .py-5 { padding-top: 3rem !important; } .pr-5, .px-5 { padding-right: 3rem !important; } .pb-5, .py-5 { padding-bottom: 3rem !important; } .pl-5, .px-5 { padding-left: 3rem !important; } .m-auto { margin: auto !important; } .mt-auto, .my-auto { margin-top: auto !important; } .mr-auto, .mx-auto { margin-right: auto !important; } .mb-auto, .my-auto { margin-bottom: auto !important; } .ml-auto, .mx-auto { margin-left: auto !important; } @media (min-width: 576px) { .m-sm-0 { margin: 0 !important; } .mt-sm-0, .my-sm-0 { margin-top: 0 !important; } .mr-sm-0, .mx-sm-0 { margin-right: 0 !important; } .mb-sm-0, .my-sm-0 { margin-bottom: 0 !important; } .ml-sm-0, .mx-sm-0 { margin-left: 0 !important; } .m-sm-1 { margin: 0.25rem !important; } .mt-sm-1, .my-sm-1 { margin-top: 0.25rem !important; } .mr-sm-1, .mx-sm-1 { margin-right: 0.25rem !important; } .mb-sm-1, .my-sm-1 { margin-bottom: 0.25rem !important; } .ml-sm-1, .mx-sm-1 { margin-left: 0.25rem !important; } .m-sm-2 { margin: 0.5rem !important; } .mt-sm-2, .my-sm-2 { margin-top: 0.5rem !important; } .mr-sm-2, .mx-sm-2 { margin-right: 0.5rem !important; } .mb-sm-2, .my-sm-2 { margin-bottom: 0.5rem !important; } .ml-sm-2, .mx-sm-2 { margin-left: 0.5rem !important; } .m-sm-3 { margin: 1rem !important; } .mt-sm-3, .my-sm-3 { margin-top: 1rem !important; } .mr-sm-3, .mx-sm-3 { margin-right: 1rem !important; } .mb-sm-3, .my-sm-3 { margin-bottom: 1rem !important; } .ml-sm-3, .mx-sm-3 { margin-left: 1rem !important; } .m-sm-4 { margin: 1.5rem !important; } .mt-sm-4, .my-sm-4 { margin-top: 1.5rem !important; } .mr-sm-4, .mx-sm-4 { margin-right: 1.5rem !important; } .mb-sm-4, .my-sm-4 { margin-bottom: 1.5rem !important; } .ml-sm-4, .mx-sm-4 { margin-left: 1.5rem !important; } .m-sm-5 { margin: 3rem !important; } .mt-sm-5, .my-sm-5 { margin-top: 3rem !important; } .mr-sm-5, .mx-sm-5 { margin-right: 3rem !important; } .mb-sm-5, .my-sm-5 { margin-bottom: 3rem !important; } .ml-sm-5, .mx-sm-5 { margin-left: 3rem !important; } .p-sm-0 { padding: 0 !important; } .pt-sm-0, .py-sm-0 { padding-top: 0 !important; } .pr-sm-0, .px-sm-0 { padding-right: 0 !important; } .pb-sm-0, .py-sm-0 { padding-bottom: 0 !important; } .pl-sm-0, .px-sm-0 { padding-left: 0 !important; } .p-sm-1 { padding: 0.25rem !important; } .pt-sm-1, .py-sm-1 { padding-top: 0.25rem !important; } .pr-sm-1, .px-sm-1 { padding-right: 0.25rem !important; } .pb-sm-1, .py-sm-1 { padding-bottom: 0.25rem !important; } .pl-sm-1, .px-sm-1 { padding-left: 0.25rem !important; } .p-sm-2 { padding: 0.5rem !important; } .pt-sm-2, .py-sm-2 { padding-top: 0.5rem !important; } .pr-sm-2, .px-sm-2 { padding-right: 0.5rem !important; } .pb-sm-2, .py-sm-2 { padding-bottom: 0.5rem !important; } .pl-sm-2, .px-sm-2 { padding-left: 0.5rem !important; } .p-sm-3 { padding: 1rem !important; } .pt-sm-3, .py-sm-3 { padding-top: 1rem !important; } .pr-sm-3, .px-sm-3 { padding-right: 1rem !important; } .pb-sm-3, .py-sm-3 { padding-bottom: 1rem !important; } .pl-sm-3, .px-sm-3 { padding-left: 1rem !important; } .p-sm-4 { padding: 1.5rem !important; } .pt-sm-4, .py-sm-4 { padding-top: 1.5rem !important; } .pr-sm-4, .px-sm-4 { padding-right: 1.5rem !important; } .pb-sm-4, .py-sm-4 { padding-bottom: 1.5rem !important; } .pl-sm-4, .px-sm-4 { padding-left: 1.5rem !important; } .p-sm-5 { padding: 3rem !important; } .pt-sm-5, .py-sm-5 { padding-top: 3rem !important; } .pr-sm-5, .px-sm-5 { padding-right: 3rem !important; } .pb-sm-5, .py-sm-5 { padding-bottom: 3rem !important; } .pl-sm-5, .px-sm-5 { padding-left: 3rem !important; } .m-sm-auto { margin: auto !important; } .mt-sm-auto, .my-sm-auto { margin-top: auto !important; } .mr-sm-auto, .mx-sm-auto { margin-right: auto !important; } .mb-sm-auto, .my-sm-auto { margin-bottom: auto !important; } .ml-sm-auto, .mx-sm-auto { margin-left: auto !important; } } @media (min-width: 768px) { .m-md-0 { margin: 0 !important; } .mt-md-0, .my-md-0 { margin-top: 0 !important; } .mr-md-0, .mx-md-0 { margin-right: 0 !important; } .mb-md-0, .my-md-0 { margin-bottom: 0 !important; } .ml-md-0, .mx-md-0 { margin-left: 0 !important; } .m-md-1 { margin: 0.25rem !important; } .mt-md-1, .my-md-1 { margin-top: 0.25rem !important; } .mr-md-1, .mx-md-1 { margin-right: 0.25rem !important; } .mb-md-1, .my-md-1 { margin-bottom: 0.25rem !important; } .ml-md-1, .mx-md-1 { margin-left: 0.25rem !important; } .m-md-2 { margin: 0.5rem !important; } .mt-md-2, .my-md-2 { margin-top: 0.5rem !important; } .mr-md-2, .mx-md-2 { margin-right: 0.5rem !important; } .mb-md-2, .my-md-2 { margin-bottom: 0.5rem !important; } .ml-md-2, .mx-md-2 { margin-left: 0.5rem !important; } .m-md-3 { margin: 1rem !important; } .mt-md-3, .my-md-3 { margin-top: 1rem !important; } .mr-md-3, .mx-md-3 { margin-right: 1rem !important; } .mb-md-3, .my-md-3 { margin-bottom: 1rem !important; } .ml-md-3, .mx-md-3 { margin-left: 1rem !important; } .m-md-4 { margin: 1.5rem !important; } .mt-md-4, .my-md-4 { margin-top: 1.5rem !important; } .mr-md-4, .mx-md-4 { margin-right: 1.5rem !important; } .mb-md-4, .my-md-4 { margin-bottom: 1.5rem !important; } .ml-md-4, .mx-md-4 { margin-left: 1.5rem !important; } .m-md-5 { margin: 3rem !important; } .mt-md-5, .my-md-5 { margin-top: 3rem !important; } .mr-md-5, .mx-md-5 { margin-right: 3rem !important; } .mb-md-5, .my-md-5 { margin-bottom: 3rem !important; } .ml-md-5, .mx-md-5 { margin-left: 3rem !important; } .p-md-0 { padding: 0 !important; } .pt-md-0, .py-md-0 { padding-top: 0 !important; } .pr-md-0, .px-md-0 { padding-right: 0 !important; } .pb-md-0, .py-md-0 { padding-bottom: 0 !important; } .pl-md-0, .px-md-0 { padding-left: 0 !important; } .p-md-1 { padding: 0.25rem !important; } .pt-md-1, .py-md-1 { padding-top: 0.25rem !important; } .pr-md-1, .px-md-1 { padding-right: 0.25rem !important; } .pb-md-1, .py-md-1 { padding-bottom: 0.25rem !important; } .pl-md-1, .px-md-1 { padding-left: 0.25rem !important; } .p-md-2 { padding: 0.5rem !important; } .pt-md-2, .py-md-2 { padding-top: 0.5rem !important; } .pr-md-2, .px-md-2 { padding-right: 0.5rem !important; } .pb-md-2, .py-md-2 { padding-bottom: 0.5rem !important; } .pl-md-2, .px-md-2 { padding-left: 0.5rem !important; } .p-md-3 { padding: 1rem !important; } .pt-md-3, .py-md-3 { padding-top: 1rem !important; } .pr-md-3, .px-md-3 { padding-right: 1rem !important; } .pb-md-3, .py-md-3 { padding-bottom: 1rem !important; } .pl-md-3, .px-md-3 { padding-left: 1rem !important; } .p-md-4 { padding: 1.5rem !important; } .pt-md-4, .py-md-4 { padding-top: 1.5rem !important; } .pr-md-4, .px-md-4 { padding-right: 1.5rem !important; } .pb-md-4, .py-md-4 { padding-bottom: 1.5rem !important; } .pl-md-4, .px-md-4 { padding-left: 1.5rem !important; } .p-md-5 { padding: 3rem !important; } .pt-md-5, .py-md-5 { padding-top: 3rem !important; } .pr-md-5, .px-md-5 { padding-right: 3rem !important; } .pb-md-5, .py-md-5 { padding-bottom: 3rem !important; } .pl-md-5, .px-md-5 { padding-left: 3rem !important; } .m-md-auto { margin: auto !important; } .mt-md-auto, .my-md-auto { margin-top: auto !important; } .mr-md-auto, .mx-md-auto { margin-right: auto !important; } .mb-md-auto, .my-md-auto { margin-bottom: auto !important; } .ml-md-auto, .mx-md-auto { margin-left: auto !important; } } @media (min-width: 992px) { .m-lg-0 { margin: 0 !important; } .mt-lg-0, .my-lg-0 { margin-top: 0 !important; } .mr-lg-0, .mx-lg-0 { margin-right: 0 !important; } .mb-lg-0, .my-lg-0 { margin-bottom: 0 !important; } .ml-lg-0, .mx-lg-0 { margin-left: 0 !important; } .m-lg-1 { margin: 0.25rem !important; } .mt-lg-1, .my-lg-1 { margin-top: 0.25rem !important; } .mr-lg-1, .mx-lg-1 { margin-right: 0.25rem !important; } .mb-lg-1, .my-lg-1 { margin-bottom: 0.25rem !important; } .ml-lg-1, .mx-lg-1 { margin-left: 0.25rem !important; } .m-lg-2 { margin: 0.5rem !important; } .mt-lg-2, .my-lg-2 { margin-top: 0.5rem !important; } .mr-lg-2, .mx-lg-2 { margin-right: 0.5rem !important; } .mb-lg-2, .my-lg-2 { margin-bottom: 0.5rem !important; } .ml-lg-2, .mx-lg-2 { margin-left: 0.5rem !important; } .m-lg-3 { margin: 1rem !important; } .mt-lg-3, .my-lg-3 { margin-top: 1rem !important; } .mr-lg-3, .mx-lg-3 { margin-right: 1rem !important; } .mb-lg-3, .my-lg-3 { margin-bottom: 1rem !important; } .ml-lg-3, .mx-lg-3 { margin-left: 1rem !important; } .m-lg-4 { margin: 1.5rem !important; } .mt-lg-4, .my-lg-4 { margin-top: 1.5rem !important; } .mr-lg-4, .mx-lg-4 { margin-right: 1.5rem !important; } .mb-lg-4, .my-lg-4 { margin-bottom: 1.5rem !important; } .ml-lg-4, .mx-lg-4 { margin-left: 1.5rem !important; } .m-lg-5 { margin: 3rem !important; } .mt-lg-5, .my-lg-5 { margin-top: 3rem !important; } .mr-lg-5, .mx-lg-5 { margin-right: 3rem !important; } .mb-lg-5, .my-lg-5 { margin-bottom: 3rem !important; } .ml-lg-5, .mx-lg-5 { margin-left: 3rem !important; } .p-lg-0 { padding: 0 !important; } .pt-lg-0, .py-lg-0 { padding-top: 0 !important; } .pr-lg-0, .px-lg-0 { padding-right: 0 !important; } .pb-lg-0, .py-lg-0 { padding-bottom: 0 !important; } .pl-lg-0, .px-lg-0 { padding-left: 0 !important; } .p-lg-1 { padding: 0.25rem !important; } .pt-lg-1, .py-lg-1 { padding-top: 0.25rem !important; } .pr-lg-1, .px-lg-1 { padding-right: 0.25rem !important; } .pb-lg-1, .py-lg-1 { padding-bottom: 0.25rem !important; } .pl-lg-1, .px-lg-1 { padding-left: 0.25rem !important; } .p-lg-2 { padding: 0.5rem !important; } .pt-lg-2, .py-lg-2 { padding-top: 0.5rem !important; } .pr-lg-2, .px-lg-2 { padding-right: 0.5rem !important; } .pb-lg-2, .py-lg-2 { padding-bottom: 0.5rem !important; } .pl-lg-2, .px-lg-2 { padding-left: 0.5rem !important; } .p-lg-3 { padding: 1rem !important; } .pt-lg-3, .py-lg-3 { padding-top: 1rem !important; } .pr-lg-3, .px-lg-3 { padding-right: 1rem !important; } .pb-lg-3, .py-lg-3 { padding-bottom: 1rem !important; } .pl-lg-3, .px-lg-3 { padding-left: 1rem !important; } .p-lg-4 { padding: 1.5rem !important; } .pt-lg-4, .py-lg-4 { padding-top: 1.5rem !important; } .pr-lg-4, .px-lg-4 { padding-right: 1.5rem !important; } .pb-lg-4, .py-lg-4 { padding-bottom: 1.5rem !important; } .pl-lg-4, .px-lg-4 { padding-left: 1.5rem !important; } .p-lg-5 { padding: 3rem !important; } .pt-lg-5, .py-lg-5 { padding-top: 3rem !important; } .pr-lg-5, .px-lg-5 { padding-right: 3rem !important; } .pb-lg-5, .py-lg-5 { padding-bottom: 3rem !important; } .pl-lg-5, .px-lg-5 { padding-left: 3rem !important; } .m-lg-auto { margin: auto !important; } .mt-lg-auto, .my-lg-auto { margin-top: auto !important; } .mr-lg-auto, .mx-lg-auto { margin-right: auto !important; } .mb-lg-auto, .my-lg-auto { margin-bottom: auto !important; } .ml-lg-auto, .mx-lg-auto { margin-left: auto !important; } } @media (min-width: 1200px) { .m-xl-0 { margin: 0 !important; } .mt-xl-0, .my-xl-0 { margin-top: 0 !important; } .mr-xl-0, .mx-xl-0 { margin-right: 0 !important; } .mb-xl-0, .my-xl-0 { margin-bottom: 0 !important; } .ml-xl-0, .mx-xl-0 { margin-left: 0 !important; } .m-xl-1 { margin: 0.25rem !important; } .mt-xl-1, .my-xl-1 { margin-top: 0.25rem !important; } .mr-xl-1, .mx-xl-1 { margin-right: 0.25rem !important; } .mb-xl-1, .my-xl-1 { margin-bottom: 0.25rem !important; } .ml-xl-1, .mx-xl-1 { margin-left: 0.25rem !important; } .m-xl-2 { margin: 0.5rem !important; } .mt-xl-2, .my-xl-2 { margin-top: 0.5rem !important; } .mr-xl-2, .mx-xl-2 { margin-right: 0.5rem !important; } .mb-xl-2, .my-xl-2 { margin-bottom: 0.5rem !important; } .ml-xl-2, .mx-xl-2 { margin-left: 0.5rem !important; } .m-xl-3 { margin: 1rem !important; } .mt-xl-3, .my-xl-3 { margin-top: 1rem !important; } .mr-xl-3, .mx-xl-3 { margin-right: 1rem !important; } .mb-xl-3, .my-xl-3 { margin-bottom: 1rem !important; } .ml-xl-3, .mx-xl-3 { margin-left: 1rem !important; } .m-xl-4 { margin: 1.5rem !important; } .mt-xl-4, .my-xl-4 { margin-top: 1.5rem !important; } .mr-xl-4, .mx-xl-4 { margin-right: 1.5rem !important; } .mb-xl-4, .my-xl-4 { margin-bottom: 1.5rem !important; } .ml-xl-4, .mx-xl-4 { margin-left: 1.5rem !important; } .m-xl-5 { margin: 3rem !important; } .mt-xl-5, .my-xl-5 { margin-top: 3rem !important; } .mr-xl-5, .mx-xl-5 { margin-right: 3rem !important; } .mb-xl-5, .my-xl-5 { margin-bottom: 3rem !important; } .ml-xl-5, .mx-xl-5 { margin-left: 3rem !important; } .p-xl-0 { padding: 0 !important; } .pt-xl-0, .py-xl-0 { padding-top: 0 !important; } .pr-xl-0, .px-xl-0 { padding-right: 0 !important; } .pb-xl-0, .py-xl-0 { padding-bottom: 0 !important; } .pl-xl-0, .px-xl-0 { padding-left: 0 !important; } .p-xl-1 { padding: 0.25rem !important; } .pt-xl-1, .py-xl-1 { padding-top: 0.25rem !important; } .pr-xl-1, .px-xl-1 { padding-right: 0.25rem !important; } .pb-xl-1, .py-xl-1 { padding-bottom: 0.25rem !important; } .pl-xl-1, .px-xl-1 { padding-left: 0.25rem !important; } .p-xl-2 { padding: 0.5rem !important; } .pt-xl-2, .py-xl-2 { padding-top: 0.5rem !important; } .pr-xl-2, .px-xl-2 { padding-right: 0.5rem !important; } .pb-xl-2, .py-xl-2 { padding-bottom: 0.5rem !important; } .pl-xl-2, .px-xl-2 { padding-left: 0.5rem !important; } .p-xl-3 { padding: 1rem !important; } .pt-xl-3, .py-xl-3 { padding-top: 1rem !important; } .pr-xl-3, .px-xl-3 { padding-right: 1rem !important; } .pb-xl-3, .py-xl-3 { padding-bottom: 1rem !important; } .pl-xl-3, .px-xl-3 { padding-left: 1rem !important; } .p-xl-4 { padding: 1.5rem !important; } .pt-xl-4, .py-xl-4 { padding-top: 1.5rem !important; } .pr-xl-4, .px-xl-4 { padding-right: 1.5rem !important; } .pb-xl-4, .py-xl-4 { padding-bottom: 1.5rem !important; } .pl-xl-4, .px-xl-4 { padding-left: 1.5rem !important; } .p-xl-5 { padding: 3rem !important; } .pt-xl-5, .py-xl-5 { padding-top: 3rem !important; } .pr-xl-5, .px-xl-5 { padding-right: 3rem !important; } .pb-xl-5, .py-xl-5 { padding-bottom: 3rem !important; } .pl-xl-5, .px-xl-5 { padding-left: 3rem !important; } .m-xl-auto { margin: auto !important; } .mt-xl-auto, .my-xl-auto { margin-top: auto !important; } .mr-xl-auto, .mx-xl-auto { margin-right: auto !important; } .mb-xl-auto, .my-xl-auto { margin-bottom: auto !important; } .ml-xl-auto, .mx-xl-auto { margin-left: auto !important; } } @media (min-width: 1600px) { .m-xxl-0 { margin: 0 !important; } .mt-xxl-0, .my-xxl-0 { margin-top: 0 !important; } .mr-xxl-0, .mx-xxl-0 { margin-right: 0 !important; } .mb-xxl-0, .my-xxl-0 { margin-bottom: 0 !important; } .ml-xxl-0, .mx-xxl-0 { margin-left: 0 !important; } .m-xxl-1 { margin: 0.25rem !important; } .mt-xxl-1, .my-xxl-1 { margin-top: 0.25rem !important; } .mr-xxl-1, .mx-xxl-1 { margin-right: 0.25rem !important; } .mb-xxl-1, .my-xxl-1 { margin-bottom: 0.25rem !important; } .ml-xxl-1, .mx-xxl-1 { margin-left: 0.25rem !important; } .m-xxl-2 { margin: 0.5rem !important; } .mt-xxl-2, .my-xxl-2 { margin-top: 0.5rem !important; } .mr-xxl-2, .mx-xxl-2 { margin-right: 0.5rem !important; } .mb-xxl-2, .my-xxl-2 { margin-bottom: 0.5rem !important; } .ml-xxl-2, .mx-xxl-2 { margin-left: 0.5rem !important; } .m-xxl-3 { margin: 1rem !important; } .mt-xxl-3, .my-xxl-3 { margin-top: 1rem !important; } .mr-xxl-3, .mx-xxl-3 { margin-right: 1rem !important; } .mb-xxl-3, .my-xxl-3 { margin-bottom: 1rem !important; } .ml-xxl-3, .mx-xxl-3 { margin-left: 1rem !important; } .m-xxl-4 { margin: 1.5rem !important; } .mt-xxl-4, .my-xxl-4 { margin-top: 1.5rem !important; } .mr-xxl-4, .mx-xxl-4 { margin-right: 1.5rem !important; } .mb-xxl-4, .my-xxl-4 { margin-bottom: 1.5rem !important; } .ml-xxl-4, .mx-xxl-4 { margin-left: 1.5rem !important; } .m-xxl-5 { margin: 3rem !important; } .mt-xxl-5, .my-xxl-5 { margin-top: 3rem !important; } .mr-xxl-5, .mx-xxl-5 { margin-right: 3rem !important; } .mb-xxl-5, .my-xxl-5 { margin-bottom: 3rem !important; } .ml-xxl-5, .mx-xxl-5 { margin-left: 3rem !important; } .p-xxl-0 { padding: 0 !important; } .pt-xxl-0, .py-xxl-0 { padding-top: 0 !important; } .pr-xxl-0, .px-xxl-0 { padding-right: 0 !important; } .pb-xxl-0, .py-xxl-0 { padding-bottom: 0 !important; } .pl-xxl-0, .px-xxl-0 { padding-left: 0 !important; } .p-xxl-1 { padding: 0.25rem !important; } .pt-xxl-1, .py-xxl-1 { padding-top: 0.25rem !important; } .pr-xxl-1, .px-xxl-1 { padding-right: 0.25rem !important; } .pb-xxl-1, .py-xxl-1 { padding-bottom: 0.25rem !important; } .pl-xxl-1, .px-xxl-1 { padding-left: 0.25rem !important; } .p-xxl-2 { padding: 0.5rem !important; } .pt-xxl-2, .py-xxl-2 { padding-top: 0.5rem !important; } .pr-xxl-2, .px-xxl-2 { padding-right: 0.5rem !important; } .pb-xxl-2, .py-xxl-2 { padding-bottom: 0.5rem !important; } .pl-xxl-2, .px-xxl-2 { padding-left: 0.5rem !important; } .p-xxl-3 { padding: 1rem !important; } .pt-xxl-3, .py-xxl-3 { padding-top: 1rem !important; } .pr-xxl-3, .px-xxl-3 { padding-right: 1rem !important; } .pb-xxl-3, .py-xxl-3 { padding-bottom: 1rem !important; } .pl-xxl-3, .px-xxl-3 { padding-left: 1rem !important; } .p-xxl-4 { padding: 1.5rem !important; } .pt-xxl-4, .py-xxl-4 { padding-top: 1.5rem !important; } .pr-xxl-4, .px-xxl-4 { padding-right: 1.5rem !important; } .pb-xxl-4, .py-xxl-4 { padding-bottom: 1.5rem !important; } .pl-xxl-4, .px-xxl-4 { padding-left: 1.5rem !important; } .p-xxl-5 { padding: 3rem !important; } .pt-xxl-5, .py-xxl-5 { padding-top: 3rem !important; } .pr-xxl-5, .px-xxl-5 { padding-right: 3rem !important; } .pb-xxl-5, .py-xxl-5 { padding-bottom: 3rem !important; } .pl-xxl-5, .px-xxl-5 { padding-left: 3rem !important; } .m-xxl-auto { margin: auto !important; } .mt-xxl-auto, .my-xxl-auto { margin-top: auto !important; } .mr-xxl-auto, .mx-xxl-auto { margin-right: auto !important; } .mb-xxl-auto, .my-xxl-auto { margin-bottom: auto !important; } .ml-xxl-auto, .mx-xxl-auto { margin-left: auto !important; } } .text-justify { text-align: justify !important; } .text-nowrap { white-space: nowrap !important; } .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .text-left { text-align: left !important; } .text-right { text-align: right !important; } .text-center { text-align: center !important; } @media (min-width: 576px) { .text-sm-left { text-align: left !important; } .text-sm-right { text-align: right !important; } .text-sm-center { text-align: center !important; } } @media (min-width: 768px) { .text-md-left { text-align: left !important; } .text-md-right { text-align: right !important; } .text-md-center { text-align: center !important; } } @media (min-width: 992px) { .text-lg-left { text-align: left !important; } .text-lg-right { text-align: right !important; } .text-lg-center { text-align: center !important; } } @media (min-width: 1200px) { .text-xl-left { text-align: left !important; } .text-xl-right { text-align: right !important; } .text-xl-center { text-align: center !important; } } @media (min-width: 1600px) { .text-xxl-left { text-align: left !important; } .text-xxl-right { text-align: right !important; } .text-xxl-center { text-align: center !important; } } .text-lowercase { text-transform: lowercase !important; } .text-uppercase { text-transform: uppercase !important; } .text-capitalize { text-transform: capitalize !important; } .font-weight-light { font-weight: 300 !important; } .font-weight-normal { font-weight: 400 !important; } .font-weight-bold { font-weight: 700 !important; } .font-italic { font-style: italic !important; } .text-white { color: #fff !important; } .text-black { color: #333 !important; } .text-muted { color: #6c757d !important; } .text-hide { font: 0/0 a; color: transparent; text-shadow: none; background-color: transparent; border: 0; } .visible { visibility: visible !important; } .invisible { visibility: hidden !important; } @media print { *, *::before, *::after { text-shadow: none !important; box-shadow: none !important; } a:not(.btn) { text-decoration: underline; } abbr[title]::after { content: " (" attr(title) ")"; } pre { white-space: pre-wrap !important; } pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } thead { display: table-header-group; } tr, img { page-break-inside: avoid; } p, h2, h3 { orphans: 3; widows: 3; } h2, h3 { page-break-after: avoid; } @page { size: a3; } body { min-width: 992px !important; } .container { min-width: 992px !important; } .navbar { display: none; } .badge { border: 1px solid #000; } .table { border-collapse: collapse !important; } .table td, .table th { background-color: #fff !important; } .table-bordered th, .table-bordered td { border: 1px solid #ddd !important; } } \ No newline at end of file diff --git a/site/client_site/static/css/fonts.css b/site/client_site/static/css/fonts.css new file mode 100755 index 0000000..5bc19d3 --- /dev/null +++ b/site/client_site/static/css/fonts.css @@ -0,0 +1,18 @@ +/* +* +* Fonts +*/ /* +* +* Font Awesome Icons 4.7.0 +* -------------------------------------------------- +*/ @font-face { font-family: 'FontAwesome'; src: url("../fonts/fontawesome-webfont.eot?v=4.7.0"); src: url("../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0") format("embedded-opentype"), url("../fonts/fontawesome-webfont.woff2?v=4.7.0") format("woff2"), url("../fonts/fontawesome-webfont.woff?v=4.7.0") format("woff"), url("../fonts/fontawesome-webfont.ttf?v=4.7.0") format("truetype"), url("../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular") format("svg"); font-weight: normal; font-style: normal; } .fa { display: inline-block; font-family: 'FontAwesome'; font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } [class*='fa-']:before { font-weight: 400; font-family: 'FontAwesome'; } /* makes the font 33% larger relative to the icon container */ .fa-lg { font-size: 1.33333333em; line-height: 0.75em; vertical-align: -15%; } .fa-2x { font-size: 2em; } .fa-3x { font-size: 3em; } .fa-4x { font-size: 4em; } .fa-5x { font-size: 5em; } .fa-fw { width: 1.28571429em; text-align: center; } .fa-ul { padding-left: 0; margin-left: 2.14285714em; list-style-type: none; } .fa-ul > li { position: relative; } .fa-li { position: absolute; left: -2.14285714em; width: 2.14285714em; top: 0.14285714em; text-align: center; } .fa-li.fa-lg { left: -1.85714286em; } .fa-border { padding: .2em .25em .15em; border: solid 0.08em #eeeeee; border-radius: .1em; } .fa-pull-left { float: left; } .fa-pull-right { float: right; } .fa.fa-pull-left { margin-right: .3em; } .fa.fa-pull-right { margin-left: .3em; } /* Deprecated as of 4.4.0 */ .pull-right { float: right; } .pull-left { float: left; } .fa.pull-left { margin-right: .3em; } .fa.pull-right { margin-left: .3em; } .fa-spin { -webkit-animation: fa-spin 2s infinite linear; animation: fa-spin 2s infinite linear; } .fa-pulse { -webkit-animation: fa-spin 1s infinite steps(8); animation: fa-spin 1s infinite steps(8); } @-webkit-keyframes fa-spin { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(359deg); transform: rotate(359deg); } } @keyframes fa-spin { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(359deg); transform: rotate(359deg); } } .fa-rotate-90 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; -webkit-transform: rotate(90deg); -ms-transform: rotate(90deg); transform: rotate(90deg); } .fa-rotate-180 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; -webkit-transform: rotate(180deg); -ms-transform: rotate(180deg); transform: rotate(180deg); } .fa-rotate-270 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; -webkit-transform: rotate(270deg); -ms-transform: rotate(270deg); transform: rotate(270deg); } .fa-flip-horizontal { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; -webkit-transform: scale(-1, 1); -ms-transform: scale(-1, 1); transform: scale(-1, 1); } .fa-flip-vertical { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; -webkit-transform: scale(1, -1); -ms-transform: scale(1, -1); transform: scale(1, -1); } :root .fa-rotate-90, :root .fa-rotate-180, :root .fa-rotate-270, :root .fa-flip-horizontal, :root .fa-flip-vertical { filter: none; } .fa-stack { position: relative; display: inline-block; width: 2em; height: 2em; line-height: 2em; vertical-align: middle; } .fa-stack-1x, .fa-stack-2x { position: absolute; left: 0; width: 100%; text-align: center; } .fa-stack-1x { line-height: inherit; } .fa-stack-2x { font-size: 2em; } .fa-inverse { color: #ffffff; } /* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ .fa-glass:before { content: "\f000"; } .fa-music:before { content: "\f001"; } .fa-search:before { content: "\f002"; } .fa-envelope-o:before { content: "\f003"; } .fa-heart:before { content: "\f004"; } .fa-star:before { content: "\f005"; } .fa-star-o:before { content: "\f006"; } .fa-user:before { content: "\f007"; } .fa-film:before { content: "\f008"; } .fa-th-large:before { content: "\f009"; } .fa-th:before { content: "\f00a"; } .fa-th-list:before { content: "\f00b"; } .fa-check:before { content: "\f00c"; } .fa-remove:before, .fa-close:before, .fa-times:before { content: "\f00d"; } .fa-search-plus:before { content: "\f00e"; } .fa-search-minus:before { content: "\f010"; } .fa-power-off:before { content: "\f011"; } .fa-signal:before { content: "\f012"; } .fa-gear:before, .fa-cog:before { content: "\f013"; } .fa-trash-o:before { content: "\f014"; } .fa-home:before { content: "\f015"; } .fa-file-o:before { content: "\f016"; } .fa-clock-o:before { content: "\f017"; } .fa-road:before { content: "\f018"; } .fa-download:before { content: "\f019"; } .fa-arrow-circle-o-down:before { content: "\f01a"; } .fa-arrow-circle-o-up:before { content: "\f01b"; } .fa-inbox:before { content: "\f01c"; } .fa-play-circle-o:before { content: "\f01d"; } .fa-rotate-right:before, .fa-repeat:before { content: "\f01e"; } .fa-refresh:before { content: "\f021"; } .fa-list-alt:before { content: "\f022"; } .fa-lock:before { content: "\f023"; } .fa-flag:before { content: "\f024"; } .fa-headphones:before { content: "\f025"; } .fa-volume-off:before { content: "\f026"; } .fa-volume-down:before { content: "\f027"; } .fa-volume-up:before { content: "\f028"; } .fa-qrcode:before { content: "\f029"; } .fa-barcode:before { content: "\f02a"; } .fa-tag:before { content: "\f02b"; } .fa-tags:before { content: "\f02c"; } .fa-book:before { content: "\f02d"; } .fa-bookmark:before { content: "\f02e"; } .fa-print:before { content: "\f02f"; } .fa-camera:before { content: "\f030"; } .fa-font:before { content: "\f031"; } .fa-bold:before { content: "\f032"; } .fa-italic:before { content: "\f033"; } .fa-text-height:before { content: "\f034"; } .fa-text-width:before { content: "\f035"; } .fa-align-left:before { content: "\f036"; } .fa-align-center:before { content: "\f037"; } .fa-align-right:before { content: "\f038"; } .fa-align-justify:before { content: "\f039"; } .fa-list:before { content: "\f03a"; } .fa-dedent:before, .fa-outdent:before { content: "\f03b"; } .fa-indent:before { content: "\f03c"; } .fa-video-camera:before { content: "\f03d"; } .fa-photo:before, .fa-image:before, .fa-picture-o:before { content: "\f03e"; } .fa-pencil:before { content: "\f040"; } .fa-map-marker:before { content: "\f041"; } .fa-adjust:before { content: "\f042"; } .fa-tint:before { content: "\f043"; } .fa-edit:before, .fa-pencil-square-o:before { content: "\f044"; } .fa-share-square-o:before { content: "\f045"; } .fa-check-square-o:before { content: "\f046"; } .fa-arrows:before { content: "\f047"; } .fa-step-backward:before { content: "\f048"; } .fa-fast-backward:before { content: "\f049"; } .fa-backward:before { content: "\f04a"; } .fa-play:before { content: "\f04b"; } .fa-pause:before { content: "\f04c"; } .fa-stop:before { content: "\f04d"; } .fa-forward:before { content: "\f04e"; } .fa-fast-forward:before { content: "\f050"; } .fa-step-forward:before { content: "\f051"; } .fa-eject:before { content: "\f052"; } .fa-chevron-left:before { content: "\f053"; } .fa-chevron-right:before { content: "\f054"; } .fa-plus-circle:before { content: "\f055"; } .fa-minus-circle:before { content: "\f056"; } .fa-times-circle:before { content: "\f057"; } .fa-check-circle:before { content: "\f058"; } .fa-question-circle:before { content: "\f059"; } .fa-info-circle:before { content: "\f05a"; } .fa-crosshairs:before { content: "\f05b"; } .fa-times-circle-o:before { content: "\f05c"; } .fa-check-circle-o:before { content: "\f05d"; } .fa-ban:before { content: "\f05e"; } .fa-arrow-left:before { content: "\f060"; } .fa-arrow-right:before { content: "\f061"; } .fa-arrow-up:before { content: "\f062"; } .fa-arrow-down:before { content: "\f063"; } .fa-mail-forward:before, .fa-share:before { content: "\f064"; } .fa-expand:before { content: "\f065"; } .fa-compress:before { content: "\f066"; } .fa-plus:before { content: "\f067"; } .fa-minus:before { content: "\f068"; } .fa-asterisk:before { content: "\f069"; } .fa-exclamation-circle:before { content: "\f06a"; } .fa-gift:before { content: "\f06b"; } .fa-leaf:before { content: "\f06c"; } .fa-fire:before { content: "\f06d"; } .fa-eye:before { content: "\f06e"; } .fa-eye-slash:before { content: "\f070"; } .fa-warning:before, .fa-exclamation-triangle:before { content: "\f071"; } .fa-plane:before { content: "\f072"; } .fa-calendar:before { content: "\f073"; } .fa-random:before { content: "\f074"; } .fa-comment:before { content: "\f075"; } .fa-magnet:before { content: "\f076"; } .fa-chevron-up:before { content: "\f077"; } .fa-chevron-down:before { content: "\f078"; } .fa-retweet:before { content: "\f079"; } .fa-shopping-cart:before { content: "\f07a"; } .fa-folder:before { content: "\f07b"; } .fa-folder-open:before { content: "\f07c"; } .fa-arrows-v:before { content: "\f07d"; } .fa-arrows-h:before { content: "\f07e"; } .fa-bar-chart-o:before, .fa-bar-chart:before { content: "\f080"; } .fa-twitter-square:before { content: "\f081"; } .fa-facebook-square:before { content: "\f082"; } .fa-camera-retro:before { content: "\f083"; } .fa-key:before { content: "\f084"; } .fa-gears:before, .fa-cogs:before { content: "\f085"; } .fa-comments:before { content: "\f086"; } .fa-thumbs-o-up:before { content: "\f087"; } .fa-thumbs-o-down:before { content: "\f088"; } .fa-star-half:before { content: "\f089"; } .fa-heart-o:before { content: "\f08a"; } .fa-sign-out:before { content: "\f08b"; } .fa-linkedin-square:before { content: "\f08c"; } .fa-thumb-tack:before { content: "\f08d"; } .fa-external-link:before { content: "\f08e"; } .fa-sign-in:before { content: "\f090"; } .fa-trophy:before { content: "\f091"; } .fa-github-square:before { content: "\f092"; } .fa-upload:before { content: "\f093"; } .fa-lemon-o:before { content: "\f094"; } .fa-phone:before { content: "\f095"; } .fa-square-o:before { content: "\f096"; } .fa-bookmark-o:before { content: "\f097"; } .fa-phone-square:before { content: "\f098"; } .fa-twitter:before { content: "\f099"; } .fa-facebook-f:before, .fa-facebook:before { content: "\f09a"; } .fa-github:before { content: "\f09b"; } .fa-unlock:before { content: "\f09c"; } .fa-credit-card:before { content: "\f09d"; } .fa-feed:before, .fa-rss:before { content: "\f09e"; } .fa-hdd-o:before { content: "\f0a0"; } .fa-bullhorn:before { content: "\f0a1"; } .fa-bell:before { content: "\f0f3"; } .fa-certificate:before { content: "\f0a3"; } .fa-hand-o-right:before { content: "\f0a4"; } .fa-hand-o-left:before { content: "\f0a5"; } .fa-hand-o-up:before { content: "\f0a6"; } .fa-hand-o-down:before { content: "\f0a7"; } .fa-arrow-circle-left:before { content: "\f0a8"; } .fa-arrow-circle-right:before { content: "\f0a9"; } .fa-arrow-circle-up:before { content: "\f0aa"; } .fa-arrow-circle-down:before { content: "\f0ab"; } .fa-globe:before { content: "\f0ac"; } .fa-wrench:before { content: "\f0ad"; } .fa-tasks:before { content: "\f0ae"; } .fa-filter:before { content: "\f0b0"; } .fa-briefcase:before { content: "\f0b1"; } .fa-arrows-alt:before { content: "\f0b2"; } .fa-group:before, .fa-users:before { content: "\f0c0"; } .fa-chain:before, .fa-link:before { content: "\f0c1"; } .fa-cloud:before { content: "\f0c2"; } .fa-flask:before { content: "\f0c3"; } .fa-cut:before, .fa-scissors:before { content: "\f0c4"; } .fa-copy:before, .fa-files-o:before { content: "\f0c5"; } .fa-paperclip:before { content: "\f0c6"; } .fa-save:before, .fa-floppy-o:before { content: "\f0c7"; } .fa-square:before { content: "\f0c8"; } .fa-navicon:before, .fa-reorder:before, .fa-bars:before { content: "\f0c9"; } .fa-list-ul:before { content: "\f0ca"; } .fa-list-ol:before { content: "\f0cb"; } .fa-strikethrough:before { content: "\f0cc"; } .fa-underline:before { content: "\f0cd"; } .fa-table:before { content: "\f0ce"; } .fa-magic:before { content: "\f0d0"; } .fa-truck:before { content: "\f0d1"; } .fa-pinterest:before { content: "\f0d2"; } .fa-pinterest-square:before { content: "\f0d3"; } .fa-google-plus-square:before { content: "\f0d4"; } .fa-google-plus:before { content: "\f0d5"; } .fa-money:before { content: "\f0d6"; } .fa-caret-down:before { content: "\f0d7"; } .fa-caret-up:before { content: "\f0d8"; } .fa-caret-left:before { content: "\f0d9"; } .fa-caret-right:before { content: "\f0da"; } .fa-columns:before { content: "\f0db"; } .fa-unsorted:before, .fa-sort:before { content: "\f0dc"; } .fa-sort-down:before, .fa-sort-desc:before { content: "\f0dd"; } .fa-sort-up:before, .fa-sort-asc:before { content: "\f0de"; } .fa-envelope:before { content: "\f0e0"; } .fa-linkedin:before { content: "\f0e1"; } .fa-rotate-left:before, .fa-undo:before { content: "\f0e2"; } .fa-legal:before, .fa-gavel:before { content: "\f0e3"; } .fa-dashboard:before, .fa-tachometer:before { content: "\f0e4"; } .fa-comment-o:before { content: "\f0e5"; } .fa-comments-o:before { content: "\f0e6"; } .fa-flash:before, .fa-bolt:before { content: "\f0e7"; } .fa-sitemap:before { content: "\f0e8"; } .fa-umbrella:before { content: "\f0e9"; } .fa-paste:before, .fa-clipboard:before { content: "\f0ea"; } .fa-lightbulb-o:before { content: "\f0eb"; } .fa-exchange:before { content: "\f0ec"; } .fa-cloud-download:before { content: "\f0ed"; } .fa-cloud-upload:before { content: "\f0ee"; } .fa-user-md:before { content: "\f0f0"; } .fa-stethoscope:before { content: "\f0f1"; } .fa-suitcase:before { content: "\f0f2"; } .fa-bell-o:before { content: "\f0a2"; } .fa-coffee:before { content: "\f0f4"; } .fa-cutlery:before { content: "\f0f5"; } .fa-file-text-o:before { content: "\f0f6"; } .fa-building-o:before { content: "\f0f7"; } .fa-hospital-o:before { content: "\f0f8"; } .fa-ambulance:before { content: "\f0f9"; } .fa-medkit:before { content: "\f0fa"; } .fa-fighter-jet:before { content: "\f0fb"; } .fa-beer:before { content: "\f0fc"; } .fa-h-square:before { content: "\f0fd"; } .fa-plus-square:before { content: "\f0fe"; } .fa-angle-double-left:before { content: "\f100"; } .fa-angle-double-right:before { content: "\f101"; } .fa-angle-double-up:before { content: "\f102"; } .fa-angle-double-down:before { content: "\f103"; } .fa-angle-left:before { content: "\f104"; } .fa-angle-right:before { content: "\f105"; } .fa-angle-up:before { content: "\f106"; } .fa-angle-down:before { content: "\f107"; } .fa-desktop:before { content: "\f108"; } .fa-laptop:before { content: "\f109"; } .fa-tablet:before { content: "\f10a"; } .fa-mobile-phone:before, .fa-mobile:before { content: "\f10b"; } .fa-circle-o:before { content: "\f10c"; } .fa-quote-left:before { content: "\f10d"; } .fa-quote-right:before { content: "\f10e"; } .fa-spinner:before { content: "\f110"; } .fa-circle:before { content: "\f111"; } .fa-mail-reply:before, .fa-reply:before { content: "\f112"; } .fa-github-alt:before { content: "\f113"; } .fa-folder-o:before { content: "\f114"; } .fa-folder-open-o:before { content: "\f115"; } .fa-smile-o:before { content: "\f118"; } .fa-frown-o:before { content: "\f119"; } .fa-meh-o:before { content: "\f11a"; } .fa-gamepad:before { content: "\f11b"; } .fa-keyboard-o:before { content: "\f11c"; } .fa-flag-o:before { content: "\f11d"; } .fa-flag-checkered:before { content: "\f11e"; } .fa-terminal:before { content: "\f120"; } .fa-code:before { content: "\f121"; } .fa-mail-reply-all:before, .fa-reply-all:before { content: "\f122"; } .fa-star-half-empty:before, .fa-star-half-full:before, .fa-star-half-o:before { content: "\f123"; } .fa-location-arrow:before { content: "\f124"; } .fa-crop:before { content: "\f125"; } .fa-code-fork:before { content: "\f126"; } .fa-unlink:before, .fa-chain-broken:before { content: "\f127"; } .fa-question:before { content: "\f128"; } .fa-info:before { content: "\f129"; } .fa-exclamation:before { content: "\f12a"; } .fa-superscript:before { content: "\f12b"; } .fa-subscript:before { content: "\f12c"; } .fa-eraser:before { content: "\f12d"; } .fa-puzzle-piece:before { content: "\f12e"; } .fa-microphone:before { content: "\f130"; } .fa-microphone-slash:before { content: "\f131"; } .fa-shield:before { content: "\f132"; } .fa-calendar-o:before { content: "\f133"; } .fa-fire-extinguisher:before { content: "\f134"; } .fa-rocket:before { content: "\f135"; } .fa-maxcdn:before { content: "\f136"; } .fa-chevron-circle-left:before { content: "\f137"; } .fa-chevron-circle-right:before { content: "\f138"; } .fa-chevron-circle-up:before { content: "\f139"; } .fa-chevron-circle-down:before { content: "\f13a"; } .fa-html5:before { content: "\f13b"; } .fa-css3:before { content: "\f13c"; } .fa-anchor:before { content: "\f13d"; } .fa-unlock-alt:before { content: "\f13e"; } .fa-bullseye:before { content: "\f140"; } .fa-ellipsis-h:before { content: "\f141"; } .fa-ellipsis-v:before { content: "\f142"; } .fa-rss-square:before { content: "\f143"; } .fa-play-circle:before { content: "\f144"; } .fa-ticket:before { content: "\f145"; } .fa-minus-square:before { content: "\f146"; } .fa-minus-square-o:before { content: "\f147"; } .fa-level-up:before { content: "\f148"; } .fa-level-down:before { content: "\f149"; } .fa-check-square:before { content: "\f14a"; } .fa-pencil-square:before { content: "\f14b"; } .fa-external-link-square:before { content: "\f14c"; } .fa-share-square:before { content: "\f14d"; } .fa-compass:before { content: "\f14e"; } .fa-toggle-down:before, .fa-caret-square-o-down:before { content: "\f150"; } .fa-toggle-up:before, .fa-caret-square-o-up:before { content: "\f151"; } .fa-toggle-right:before, .fa-caret-square-o-right:before { content: "\f152"; } .fa-euro:before, .fa-eur:before { content: "\f153"; } .fa-gbp:before { content: "\f154"; } .fa-dollar:before, .fa-usd:before { content: "\f155"; } .fa-rupee:before, .fa-inr:before { content: "\f156"; } .fa-cny:before, .fa-rmb:before, .fa-yen:before, .fa-jpy:before { content: "\f157"; } .fa-ruble:before, .fa-rouble:before, .fa-rub:before { content: "\f158"; } .fa-won:before, .fa-krw:before { content: "\f159"; } .fa-bitcoin:before, .fa-btc:before { content: "\f15a"; } .fa-file:before { content: "\f15b"; } .fa-file-text:before { content: "\f15c"; } .fa-sort-alpha-asc:before { content: "\f15d"; } .fa-sort-alpha-desc:before { content: "\f15e"; } .fa-sort-amount-asc:before { content: "\f160"; } .fa-sort-amount-desc:before { content: "\f161"; } .fa-sort-numeric-asc:before { content: "\f162"; } .fa-sort-numeric-desc:before { content: "\f163"; } .fa-thumbs-up:before { content: "\f164"; } .fa-thumbs-down:before { content: "\f165"; } .fa-youtube-square:before { content: "\f166"; } .fa-youtube:before { content: "\f167"; } .fa-xing:before { content: "\f168"; } .fa-xing-square:before { content: "\f169"; } .fa-youtube-play:before { content: "\f16a"; } .fa-dropbox:before { content: "\f16b"; } .fa-stack-overflow:before { content: "\f16c"; } .fa-instagram:before { content: "\f16d"; } .fa-flickr:before { content: "\f16e"; } .fa-adn:before { content: "\f170"; } .fa-bitbucket:before { content: "\f171"; } .fa-bitbucket-square:before { content: "\f172"; } .fa-tumblr:before { content: "\f173"; } .fa-tumblr-square:before { content: "\f174"; } .fa-long-arrow-down:before { content: "\f175"; } .fa-long-arrow-up:before { content: "\f176"; } .fa-long-arrow-left:before { content: "\f177"; } .fa-long-arrow-right:before { content: "\f178"; } .fa-apple:before { content: "\f179"; } .fa-windows:before { content: "\f17a"; } .fa-android:before { content: "\f17b"; } .fa-linux:before { content: "\f17c"; } .fa-dribbble:before { content: "\f17d"; } .fa-skype:before { content: "\f17e"; } .fa-foursquare:before { content: "\f180"; } .fa-trello:before { content: "\f181"; } .fa-female:before { content: "\f182"; } .fa-male:before { content: "\f183"; } .fa-gittip:before, .fa-gratipay:before { content: "\f184"; } .fa-sun-o:before { content: "\f185"; } .fa-moon-o:before { content: "\f186"; } .fa-archive:before { content: "\f187"; } .fa-bug:before { content: "\f188"; } .fa-vk:before { content: "\f189"; } .fa-weibo:before { content: "\f18a"; } .fa-renren:before { content: "\f18b"; } .fa-pagelines:before { content: "\f18c"; } .fa-stack-exchange:before { content: "\f18d"; } .fa-arrow-circle-o-right:before { content: "\f18e"; } .fa-arrow-circle-o-left:before { content: "\f190"; } .fa-toggle-left:before, .fa-caret-square-o-left:before { content: "\f191"; } .fa-dot-circle-o:before { content: "\f192"; } .fa-wheelchair:before { content: "\f193"; } .fa-vimeo-square:before { content: "\f194"; } .fa-turkish-lira:before, .fa-try:before { content: "\f195"; } .fa-plus-square-o:before { content: "\f196"; } .fa-space-shuttle:before { content: "\f197"; } .fa-slack:before { content: "\f198"; } .fa-envelope-square:before { content: "\f199"; } .fa-wordpress:before { content: "\f19a"; } .fa-openid:before { content: "\f19b"; } .fa-institution:before, .fa-bank:before, .fa-university:before { content: "\f19c"; } .fa-mortar-board:before, .fa-graduation-cap:before { content: "\f19d"; } .fa-yahoo:before { content: "\f19e"; } .fa-google:before { content: "\f1a0"; } .fa-reddit:before { content: "\f1a1"; } .fa-reddit-square:before { content: "\f1a2"; } .fa-stumbleupon-circle:before { content: "\f1a3"; } .fa-stumbleupon:before { content: "\f1a4"; } .fa-delicious:before { content: "\f1a5"; } .fa-digg:before { content: "\f1a6"; } .fa-pied-piper-pp:before { content: "\f1a7"; } .fa-pied-piper-alt:before { content: "\f1a8"; } .fa-drupal:before { content: "\f1a9"; } .fa-joomla:before { content: "\f1aa"; } .fa-language:before { content: "\f1ab"; } .fa-fax:before { content: "\f1ac"; } .fa-building:before { content: "\f1ad"; } .fa-child:before { content: "\f1ae"; } .fa-paw:before { content: "\f1b0"; } .fa-spoon:before { content: "\f1b1"; } .fa-cube:before { content: "\f1b2"; } .fa-cubes:before { content: "\f1b3"; } .fa-behance:before { content: "\f1b4"; } .fa-behance-square:before { content: "\f1b5"; } .fa-steam:before { content: "\f1b6"; } .fa-steam-square:before { content: "\f1b7"; } .fa-recycle:before { content: "\f1b8"; } .fa-automobile:before, .fa-car:before { content: "\f1b9"; } .fa-cab:before, .fa-taxi:before { content: "\f1ba"; } .fa-tree:before { content: "\f1bb"; } .fa-spotify:before { content: "\f1bc"; } .fa-deviantart:before { content: "\f1bd"; } .fa-soundcloud:before { content: "\f1be"; } .fa-database:before { content: "\f1c0"; } .fa-file-pdf-o:before { content: "\f1c1"; } .fa-file-word-o:before { content: "\f1c2"; } .fa-file-excel-o:before { content: "\f1c3"; } .fa-file-powerpoint-o:before { content: "\f1c4"; } .fa-file-photo-o:before, .fa-file-picture-o:before, .fa-file-image-o:before { content: "\f1c5"; } .fa-file-zip-o:before, .fa-file-archive-o:before { content: "\f1c6"; } .fa-file-sound-o:before, .fa-file-audio-o:before { content: "\f1c7"; } .fa-file-movie-o:before, .fa-file-video-o:before { content: "\f1c8"; } .fa-file-code-o:before { content: "\f1c9"; } .fa-vine:before { content: "\f1ca"; } .fa-codepen:before { content: "\f1cb"; } .fa-jsfiddle:before { content: "\f1cc"; } .fa-life-bouy:before, .fa-life-buoy:before, .fa-life-saver:before, .fa-support:before, .fa-life-ring:before { content: "\f1cd"; } .fa-circle-o-notch:before { content: "\f1ce"; } .fa-ra:before, .fa-resistance:before, .fa-rebel:before { content: "\f1d0"; } .fa-ge:before, .fa-empire:before { content: "\f1d1"; } .fa-git-square:before { content: "\f1d2"; } .fa-git:before { content: "\f1d3"; } .fa-y-combinator-square:before, .fa-yc-square:before, .fa-hacker-news:before { content: "\f1d4"; } .fa-tencent-weibo:before { content: "\f1d5"; } .fa-qq:before { content: "\f1d6"; } .fa-wechat:before, .fa-weixin:before { content: "\f1d7"; } .fa-send:before, .fa-paper-plane:before { content: "\f1d8"; } .fa-send-o:before, .fa-paper-plane-o:before { content: "\f1d9"; } .fa-history:before { content: "\f1da"; } .fa-circle-thin:before { content: "\f1db"; } .fa-header:before { content: "\f1dc"; } .fa-paragraph:before { content: "\f1dd"; } .fa-sliders:before { content: "\f1de"; } .fa-share-alt:before { content: "\f1e0"; } .fa-share-alt-square:before { content: "\f1e1"; } .fa-bomb:before { content: "\f1e2"; } .fa-soccer-ball-o:before, .fa-futbol-o:before { content: "\f1e3"; } .fa-tty:before { content: "\f1e4"; } .fa-binoculars:before { content: "\f1e5"; } .fa-plug:before { content: "\f1e6"; } .fa-slideshare:before { content: "\f1e7"; } .fa-twitch:before { content: "\f1e8"; } .fa-yelp:before { content: "\f1e9"; } .fa-newspaper-o:before { content: "\f1ea"; } .fa-wifi:before { content: "\f1eb"; } .fa-calculator:before { content: "\f1ec"; } .fa-paypal:before { content: "\f1ed"; } .fa-google-wallet:before { content: "\f1ee"; } .fa-cc-visa:before { content: "\f1f0"; } .fa-cc-mastercard:before { content: "\f1f1"; } .fa-cc-discover:before { content: "\f1f2"; } .fa-cc-amex:before { content: "\f1f3"; } .fa-cc-paypal:before { content: "\f1f4"; } .fa-cc-stripe:before { content: "\f1f5"; } .fa-bell-slash:before { content: "\f1f6"; } .fa-bell-slash-o:before { content: "\f1f7"; } .fa-trash:before { content: "\f1f8"; } .fa-copyright:before { content: "\f1f9"; } .fa-at:before { content: "\f1fa"; } .fa-eyedropper:before { content: "\f1fb"; } .fa-paint-brush:before { content: "\f1fc"; } .fa-birthday-cake:before { content: "\f1fd"; } .fa-area-chart:before { content: "\f1fe"; } .fa-pie-chart:before { content: "\f200"; } .fa-line-chart:before { content: "\f201"; } .fa-lastfm:before { content: "\f202"; } .fa-lastfm-square:before { content: "\f203"; } .fa-toggle-off:before { content: "\f204"; } .fa-toggle-on:before { content: "\f205"; } .fa-bicycle:before { content: "\f206"; } .fa-bus:before { content: "\f207"; } .fa-ioxhost:before { content: "\f208"; } .fa-angellist:before { content: "\f209"; } .fa-cc:before { content: "\f20a"; } .fa-shekel:before, .fa-sheqel:before, .fa-ils:before { content: "\f20b"; } .fa-meanpath:before { content: "\f20c"; } .fa-buysellads:before { content: "\f20d"; } .fa-connectdevelop:before { content: "\f20e"; } .fa-dashcube:before { content: "\f210"; } .fa-forumbee:before { content: "\f211"; } .fa-leanpub:before { content: "\f212"; } .fa-sellsy:before { content: "\f213"; } .fa-shirtsinbulk:before { content: "\f214"; } .fa-simplybuilt:before { content: "\f215"; } .fa-skyatlas:before { content: "\f216"; } .fa-cart-plus:before { content: "\f217"; } .fa-cart-arrow-down:before { content: "\f218"; } .fa-diamond:before { content: "\f219"; } .fa-ship:before { content: "\f21a"; } .fa-user-secret:before { content: "\f21b"; } .fa-motorcycle:before { content: "\f21c"; } .fa-street-view:before { content: "\f21d"; } .fa-heartbeat:before { content: "\f21e"; } .fa-venus:before { content: "\f221"; } .fa-mars:before { content: "\f222"; } .fa-mercury:before { content: "\f223"; } .fa-intersex:before, .fa-transgender:before { content: "\f224"; } .fa-transgender-alt:before { content: "\f225"; } .fa-venus-double:before { content: "\f226"; } .fa-mars-double:before { content: "\f227"; } .fa-venus-mars:before { content: "\f228"; } .fa-mars-stroke:before { content: "\f229"; } .fa-mars-stroke-v:before { content: "\f22a"; } .fa-mars-stroke-h:before { content: "\f22b"; } .fa-neuter:before { content: "\f22c"; } .fa-genderless:before { content: "\f22d"; } .fa-facebook-official:before { content: "\f230"; } .fa-pinterest-p:before { content: "\f231"; } .fa-whatsapp:before { content: "\f232"; } .fa-server:before { content: "\f233"; } .fa-user-plus:before { content: "\f234"; } .fa-user-times:before { content: "\f235"; } .fa-hotel:before, .fa-bed:before { content: "\f236"; } .fa-viacoin:before { content: "\f237"; } .fa-train:before { content: "\f238"; } .fa-subway:before { content: "\f239"; } .fa-medium:before { content: "\f23a"; } .fa-yc:before, .fa-y-combinator:before { content: "\f23b"; } .fa-optin-monster:before { content: "\f23c"; } .fa-opencart:before { content: "\f23d"; } .fa-expeditedssl:before { content: "\f23e"; } .fa-battery-4:before, .fa-battery:before, .fa-battery-full:before { content: "\f240"; } .fa-battery-3:before, .fa-battery-three-quarters:before { content: "\f241"; } .fa-battery-2:before, .fa-battery-half:before { content: "\f242"; } .fa-battery-1:before, .fa-battery-quarter:before { content: "\f243"; } .fa-battery-0:before, .fa-battery-empty:before { content: "\f244"; } .fa-mouse-pointer:before { content: "\f245"; } .fa-i-cursor:before { content: "\f246"; } .fa-object-group:before { content: "\f247"; } .fa-object-ungroup:before { content: "\f248"; } .fa-sticky-note:before { content: "\f249"; } .fa-sticky-note-o:before { content: "\f24a"; } .fa-cc-jcb:before { content: "\f24b"; } .fa-cc-diners-club:before { content: "\f24c"; } .fa-clone:before { content: "\f24d"; } .fa-balance-scale:before { content: "\f24e"; } .fa-hourglass-o:before { content: "\f250"; } .fa-hourglass-1:before, .fa-hourglass-start:before { content: "\f251"; } .fa-hourglass-2:before, .fa-hourglass-half:before { content: "\f252"; } .fa-hourglass-3:before, .fa-hourglass-end:before { content: "\f253"; } .fa-hourglass:before { content: "\f254"; } .fa-hand-grab-o:before, .fa-hand-rock-o:before { content: "\f255"; } .fa-hand-stop-o:before, .fa-hand-paper-o:before { content: "\f256"; } .fa-hand-scissors-o:before { content: "\f257"; } .fa-hand-lizard-o:before { content: "\f258"; } .fa-hand-spock-o:before { content: "\f259"; } .fa-hand-pointer-o:before { content: "\f25a"; } .fa-hand-peace-o:before { content: "\f25b"; } .fa-trademark:before { content: "\f25c"; } .fa-registered:before { content: "\f25d"; } .fa-creative-commons:before { content: "\f25e"; } .fa-gg:before { content: "\f260"; } .fa-gg-circle:before { content: "\f261"; } .fa-tripadvisor:before { content: "\f262"; } .fa-odnoklassniki:before { content: "\f263"; } .fa-odnoklassniki-square:before { content: "\f264"; } .fa-get-pocket:before { content: "\f265"; } .fa-wikipedia-w:before { content: "\f266"; } .fa-safari:before { content: "\f267"; } .fa-chrome:before { content: "\f268"; } .fa-firefox:before { content: "\f269"; } .fa-opera:before { content: "\f26a"; } .fa-internet-explorer:before { content: "\f26b"; } .fa-tv:before, .fa-television:before { content: "\f26c"; } .fa-contao:before { content: "\f26d"; } .fa-500px:before { content: "\f26e"; } .fa-amazon:before { content: "\f270"; } .fa-calendar-plus-o:before { content: "\f271"; } .fa-calendar-minus-o:before { content: "\f272"; } .fa-calendar-times-o:before { content: "\f273"; } .fa-calendar-check-o:before { content: "\f274"; } .fa-industry:before { content: "\f275"; } .fa-map-pin:before { content: "\f276"; } .fa-map-signs:before { content: "\f277"; } .fa-map-o:before { content: "\f278"; } .fa-map:before { content: "\f279"; } .fa-commenting:before { content: "\f27a"; } .fa-commenting-o:before { content: "\f27b"; } .fa-houzz:before { content: "\f27c"; } .fa-vimeo:before { content: "\f27d"; } .fa-black-tie:before { content: "\f27e"; } .fa-fonticons:before { content: "\f280"; } .fa-reddit-alien:before { content: "\f281"; } .fa-edge:before { content: "\f282"; } .fa-credit-card-alt:before { content: "\f283"; } .fa-codiepie:before { content: "\f284"; } .fa-modx:before { content: "\f285"; } .fa-fort-awesome:before { content: "\f286"; } .fa-usb:before { content: "\f287"; } .fa-product-hunt:before { content: "\f288"; } .fa-mixcloud:before { content: "\f289"; } .fa-scribd:before { content: "\f28a"; } .fa-pause-circle:before { content: "\f28b"; } .fa-pause-circle-o:before { content: "\f28c"; } .fa-stop-circle:before { content: "\f28d"; } .fa-stop-circle-o:before { content: "\f28e"; } .fa-shopping-bag:before { content: "\f290"; } .fa-shopping-basket:before { content: "\f291"; } .fa-hashtag:before { content: "\f292"; } .fa-bluetooth:before { content: "\f293"; } .fa-bluetooth-b:before { content: "\f294"; } .fa-percent:before { content: "\f295"; } .fa-gitlab:before { content: "\f296"; } .fa-wpbeginner:before { content: "\f297"; } .fa-wpforms:before { content: "\f298"; } .fa-envira:before { content: "\f299"; } .fa-universal-access:before { content: "\f29a"; } .fa-wheelchair-alt:before { content: "\f29b"; } .fa-question-circle-o:before { content: "\f29c"; } .fa-blind:before { content: "\f29d"; } .fa-audio-description:before { content: "\f29e"; } .fa-volume-control-phone:before { content: "\f2a0"; } .fa-braille:before { content: "\f2a1"; } .fa-assistive-listening-systems:before { content: "\f2a2"; } .fa-asl-interpreting:before, .fa-american-sign-language-interpreting:before { content: "\f2a3"; } .fa-deafness:before, .fa-hard-of-hearing:before, .fa-deaf:before { content: "\f2a4"; } .fa-glide:before { content: "\f2a5"; } .fa-glide-g:before { content: "\f2a6"; } .fa-signing:before, .fa-sign-language:before { content: "\f2a7"; } .fa-low-vision:before { content: "\f2a8"; } .fa-viadeo:before { content: "\f2a9"; } .fa-viadeo-square:before { content: "\f2aa"; } .fa-snapchat:before { content: "\f2ab"; } .fa-snapchat-ghost:before { content: "\f2ac"; } .fa-snapchat-square:before { content: "\f2ad"; } .fa-pied-piper:before { content: "\f2ae"; } .fa-first-order:before { content: "\f2b0"; } .fa-yoast:before { content: "\f2b1"; } .fa-themeisle:before { content: "\f2b2"; } .fa-google-plus-circle:before, .fa-google-plus-official:before { content: "\f2b3"; } .fa-fa:before, .fa-font-awesome:before { content: "\f2b4"; } .fa-handshake-o:before { content: "\f2b5"; } .fa-envelope-open:before { content: "\f2b6"; } .fa-envelope-open-o:before { content: "\f2b7"; } .fa-linode:before { content: "\f2b8"; } .fa-address-book:before { content: "\f2b9"; } .fa-address-book-o:before { content: "\f2ba"; } .fa-vcard:before, .fa-address-card:before { content: "\f2bb"; } .fa-vcard-o:before, .fa-address-card-o:before { content: "\f2bc"; } .fa-user-circle:before { content: "\f2bd"; } .fa-user-circle-o:before { content: "\f2be"; } .fa-user-o:before { content: "\f2c0"; } .fa-id-badge:before { content: "\f2c1"; } .fa-drivers-license:before, .fa-id-card:before { content: "\f2c2"; } .fa-drivers-license-o:before, .fa-id-card-o:before { content: "\f2c3"; } .fa-quora:before { content: "\f2c4"; } .fa-free-code-camp:before { content: "\f2c5"; } .fa-telegram:before { content: "\f2c6"; } .fa-thermometer-4:before, .fa-thermometer:before, .fa-thermometer-full:before { content: "\f2c7"; } .fa-thermometer-3:before, .fa-thermometer-three-quarters:before { content: "\f2c8"; } .fa-thermometer-2:before, .fa-thermometer-half:before { content: "\f2c9"; } .fa-thermometer-1:before, .fa-thermometer-quarter:before { content: "\f2ca"; } .fa-thermometer-0:before, .fa-thermometer-empty:before { content: "\f2cb"; } .fa-shower:before { content: "\f2cc"; } .fa-bathtub:before, .fa-s15:before, .fa-bath:before { content: "\f2cd"; } .fa-podcast:before { content: "\f2ce"; } .fa-window-maximize:before { content: "\f2d0"; } .fa-window-minimize:before { content: "\f2d1"; } .fa-window-restore:before { content: "\f2d2"; } .fa-times-rectangle:before, .fa-window-close:before { content: "\f2d3"; } .fa-times-rectangle-o:before, .fa-window-close-o:before { content: "\f2d4"; } .fa-bandcamp:before { content: "\f2d5"; } .fa-grav:before { content: "\f2d6"; } .fa-etsy:before { content: "\f2d7"; } .fa-imdb:before { content: "\f2d8"; } .fa-ravelry:before { content: "\f2d9"; } .fa-eercast:before { content: "\f2da"; } .fa-microchip:before { content: "\f2db"; } .fa-snowflake-o:before { content: "\f2dc"; } .fa-superpowers:before { content: "\f2dd"; } .fa-wpexplorer:before { content: "\f2de"; } .fa-meetup:before { content: "\f2e0"; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } .sr-only-focusable:active, .sr-only-focusable:focus { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; } /* +* +* Material Design Icons +* -------------------------------------------------- +*/ @font-face { font-family: "Material Design Icons"; src: url("../fonts/materialdesignicons-webfont.eot?v=1.4.57"); src: url("../fonts/materialdesignicons-webfont.eot?#iefix&v=1.4.57") format("embedded-opentype"), url("../fonts/materialdesignicons-webfont.woff2?v=1.4.57") format("woff2"), url("../fonts/materialdesignicons-webfont.woff?v=1.4.57") format("woff"), url("../fonts/materialdesignicons-webfont.ttf?v=1.4.57") format("truetype"), url("../fonts/materialdesignicons-webfont.svg?v=1.4.57#materialdesigniconsregular") format("svg"); font-weight: normal; font-style: normal; } .mdi { display: inline-block; font: normal normal normal 24px/1 "Material Design Icons"; font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; transform: translate(0, 0); } .mdi-access-point:before { content: "\f101"; } .mdi-access-point-network:before { content: "\f102"; } .mdi-account:before { content: "\f103"; } .mdi-account-alert:before { content: "\f104"; } .mdi-account-box:before { content: "\f105"; } .mdi-account-box-outline:before { content: "\f106"; } .mdi-account-check:before { content: "\f107"; } .mdi-account-circle:before { content: "\f108"; } .mdi-account-convert:before { content: "\f109"; } .mdi-account-key:before { content: "\f10a"; } .mdi-account-location:before { content: "\f10b"; } .mdi-account-minus:before { content: "\f10c"; } .mdi-account-multiple:before { content: "\f10d"; } .mdi-account-multiple-outline:before { content: "\f10e"; } .mdi-account-multiple-plus:before { content: "\f10f"; } .mdi-account-network:before { content: "\f110"; } .mdi-account-off:before { content: "\f111"; } .mdi-account-outline:before { content: "\f112"; } .mdi-account-plus:before { content: "\f113"; } .mdi-account-remove:before { content: "\f114"; } .mdi-account-search:before { content: "\f115"; } .mdi-account-star:before { content: "\f116"; } .mdi-account-star-variant:before { content: "\f117"; } .mdi-account-switch:before { content: "\f118"; } .mdi-adjust:before { content: "\f119"; } .mdi-air-conditioner:before { content: "\f11a"; } .mdi-airballoon:before { content: "\f11b"; } .mdi-airplane:before { content: "\f11c"; } .mdi-airplane-off:before { content: "\f11d"; } .mdi-airplay:before { content: "\f11e"; } .mdi-alarm:before { content: "\f11f"; } .mdi-alarm-check:before { content: "\f120"; } .mdi-alarm-multiple:before { content: "\f121"; } .mdi-alarm-off:before { content: "\f122"; } .mdi-alarm-plus:before { content: "\f123"; } .mdi-album:before { content: "\f124"; } .mdi-alert:before { content: "\f125"; } .mdi-alert-box:before { content: "\f126"; } .mdi-alert-circle:before { content: "\f127"; } .mdi-alert-octagon:before { content: "\f128"; } .mdi-alert-outline:before { content: "\f129"; } .mdi-alpha:before { content: "\f12a"; } .mdi-alphabetical:before { content: "\f12b"; } .mdi-amazon:before { content: "\f12c"; } .mdi-amazon-clouddrive:before { content: "\f12d"; } .mdi-ambulance:before { content: "\f12e"; } .mdi-anchor:before { content: "\f12f"; } .mdi-android:before { content: "\f130"; } .mdi-android-debug-bridge:before { content: "\f131"; } .mdi-android-studio:before { content: "\f132"; } .mdi-apple:before { content: "\f133"; } .mdi-apple-finder:before { content: "\f134"; } .mdi-apple-ios:before { content: "\f135"; } .mdi-apple-mobileme:before { content: "\f136"; } .mdi-apple-safari:before { content: "\f137"; } .mdi-appnet:before { content: "\f138"; } .mdi-apps:before { content: "\f139"; } .mdi-archive:before { content: "\f13a"; } .mdi-arrange-bring-forward:before { content: "\f13b"; } .mdi-arrange-bring-to-front:before { content: "\f13c"; } .mdi-arrange-send-backward:before { content: "\f13d"; } .mdi-arrange-send-to-back:before { content: "\f13e"; } .mdi-arrow-all:before { content: "\f13f"; } .mdi-arrow-bottom-drop-circle:before { content: "\f140"; } .mdi-arrow-bottom-left:before { content: "\f141"; } .mdi-arrow-bottom-right:before { content: "\f142"; } .mdi-arrow-collapse:before { content: "\f143"; } .mdi-arrow-down:before { content: "\f144"; } .mdi-arrow-down-bold:before { content: "\f145"; } .mdi-arrow-down-bold-circle:before { content: "\f146"; } .mdi-arrow-down-bold-circle-outline:before { content: "\f147"; } .mdi-arrow-down-bold-hexagon-outline:before { content: "\f148"; } .mdi-arrow-expand:before { content: "\f149"; } .mdi-arrow-left:before { content: "\f14a"; } .mdi-arrow-left-bold:before { content: "\f14b"; } .mdi-arrow-left-bold-circle:before { content: "\f14c"; } .mdi-arrow-left-bold-circle-outline:before { content: "\f14d"; } .mdi-arrow-left-bold-hexagon-outline:before { content: "\f14e"; } .mdi-arrow-right:before { content: "\f14f"; } .mdi-arrow-right-bold:before { content: "\f150"; } .mdi-arrow-right-bold-circle:before { content: "\f151"; } .mdi-arrow-right-bold-circle-outline:before { content: "\f152"; } .mdi-arrow-right-bold-hexagon-outline:before { content: "\f153"; } .mdi-arrow-top-left:before { content: "\f154"; } .mdi-arrow-top-right:before { content: "\f155"; } .mdi-arrow-up:before { content: "\f156"; } .mdi-arrow-up-bold:before { content: "\f157"; } .mdi-arrow-up-bold-circle:before { content: "\f158"; } .mdi-arrow-up-bold-circle-outline:before { content: "\f159"; } .mdi-arrow-up-bold-hexagon-outline:before { content: "\f15a"; } .mdi-assistant:before { content: "\f15b"; } .mdi-at:before { content: "\f15c"; } .mdi-attachment:before { content: "\f15d"; } .mdi-audiobook:before { content: "\f15e"; } .mdi-auto-fix:before { content: "\f15f"; } .mdi-auto-upload:before { content: "\f160"; } .mdi-autorenew:before { content: "\f161"; } .mdi-av-timer:before { content: "\f162"; } .mdi-baby:before { content: "\f163"; } .mdi-backburger:before { content: "\f164"; } .mdi-backspace:before { content: "\f165"; } .mdi-backup-restore:before { content: "\f166"; } .mdi-bank:before { content: "\f167"; } .mdi-barcode:before { content: "\f168"; } .mdi-barcode-scan:before { content: "\f169"; } .mdi-barley:before { content: "\f16a"; } .mdi-barrel:before { content: "\f16b"; } .mdi-basecamp:before { content: "\f16c"; } .mdi-basket:before { content: "\f16d"; } .mdi-basket-fill:before { content: "\f16e"; } .mdi-basket-unfill:before { content: "\f16f"; } .mdi-battery:before { content: "\f170"; } .mdi-battery-10:before { content: "\f171"; } .mdi-battery-20:before { content: "\f172"; } .mdi-battery-30:before { content: "\f173"; } .mdi-battery-40:before { content: "\f174"; } .mdi-battery-50:before { content: "\f175"; } .mdi-battery-60:before { content: "\f176"; } .mdi-battery-70:before { content: "\f177"; } .mdi-battery-80:before { content: "\f178"; } .mdi-battery-90:before { content: "\f179"; } .mdi-battery-alert:before { content: "\f17a"; } .mdi-battery-charging:before { content: "\f17b"; } .mdi-battery-charging-100:before { content: "\f17c"; } .mdi-battery-charging-20:before { content: "\f17d"; } .mdi-battery-charging-30:before { content: "\f17e"; } .mdi-battery-charging-40:before { content: "\f17f"; } .mdi-battery-charging-60:before { content: "\f180"; } .mdi-battery-charging-80:before { content: "\f181"; } .mdi-battery-charging-90:before { content: "\f182"; } .mdi-battery-minus:before { content: "\f183"; } .mdi-battery-negative:before { content: "\f184"; } .mdi-battery-outline:before { content: "\f185"; } .mdi-battery-plus:before { content: "\f186"; } .mdi-battery-positive:before { content: "\f187"; } .mdi-battery-unknown:before { content: "\f188"; } .mdi-beach:before { content: "\f189"; } .mdi-beaker:before { content: "\f18a"; } .mdi-beaker-empty:before { content: "\f18b"; } .mdi-beaker-empty-outline:before { content: "\f18c"; } .mdi-beaker-outline:before { content: "\f18d"; } .mdi-beats:before { content: "\f18e"; } .mdi-beer:before { content: "\f18f"; } .mdi-behance:before { content: "\f190"; } .mdi-bell:before { content: "\f191"; } .mdi-bell-off:before { content: "\f192"; } .mdi-bell-outline:before { content: "\f193"; } .mdi-bell-plus:before { content: "\f194"; } .mdi-bell-ring:before { content: "\f195"; } .mdi-bell-ring-outline:before { content: "\f196"; } .mdi-bell-sleep:before { content: "\f197"; } .mdi-beta:before { content: "\f198"; } .mdi-bike:before { content: "\f199"; } .mdi-bing:before { content: "\f19a"; } .mdi-binoculars:before { content: "\f19b"; } .mdi-bio:before { content: "\f19c"; } .mdi-biohazard:before { content: "\f19d"; } .mdi-bitbucket:before { content: "\f19e"; } .mdi-black-mesa:before { content: "\f19f"; } .mdi-blackberry:before { content: "\f1a0"; } .mdi-blender:before { content: "\f1a1"; } .mdi-blinds:before { content: "\f1a2"; } .mdi-block-helper:before { content: "\f1a3"; } .mdi-blogger:before { content: "\f1a4"; } .mdi-bluetooth:before { content: "\f1a5"; } .mdi-bluetooth-audio:before { content: "\f1a6"; } .mdi-bluetooth-connect:before { content: "\f1a7"; } .mdi-bluetooth-off:before { content: "\f1a8"; } .mdi-bluetooth-settings:before { content: "\f1a9"; } .mdi-bluetooth-transfer:before { content: "\f1aa"; } .mdi-blur:before { content: "\f1ab"; } .mdi-blur-linear:before { content: "\f1ac"; } .mdi-blur-off:before { content: "\f1ad"; } .mdi-blur-radial:before { content: "\f1ae"; } .mdi-bone:before { content: "\f1af"; } .mdi-book:before { content: "\f1b0"; } .mdi-book-multiple:before { content: "\f1b1"; } .mdi-book-multiple-variant:before { content: "\f1b2"; } .mdi-book-open:before { content: "\f1b3"; } .mdi-book-open-variant:before { content: "\f1b4"; } .mdi-book-variant:before { content: "\f1b5"; } .mdi-bookmark:before { content: "\f1b6"; } .mdi-bookmark-check:before { content: "\f1b7"; } .mdi-bookmark-music:before { content: "\f1b8"; } .mdi-bookmark-outline:before { content: "\f1b9"; } .mdi-bookmark-outline-plus:before { content: "\f1ba"; } .mdi-bookmark-plus:before { content: "\f1bb"; } .mdi-bookmark-remove:before { content: "\f1bc"; } .mdi-border-all:before { content: "\f1bd"; } .mdi-border-bottom:before { content: "\f1be"; } .mdi-border-color:before { content: "\f1bf"; } .mdi-border-horizontal:before { content: "\f1c0"; } .mdi-border-inside:before { content: "\f1c1"; } .mdi-border-left:before { content: "\f1c2"; } .mdi-border-none:before { content: "\f1c3"; } .mdi-border-outside:before { content: "\f1c4"; } .mdi-border-right:before { content: "\f1c5"; } .mdi-border-style:before { content: "\f1c6"; } .mdi-border-top:before { content: "\f1c7"; } .mdi-border-vertical:before { content: "\f1c8"; } .mdi-bowling:before { content: "\f1c9"; } .mdi-box:before { content: "\f1ca"; } .mdi-box-cutter:before { content: "\f1cb"; } .mdi-briefcase:before { content: "\f1cc"; } .mdi-briefcase-check:before { content: "\f1cd"; } .mdi-briefcase-download:before { content: "\f1ce"; } .mdi-briefcase-upload:before { content: "\f1cf"; } .mdi-brightness-1:before { content: "\f1d0"; } .mdi-brightness-2:before { content: "\f1d1"; } .mdi-brightness-3:before { content: "\f1d2"; } .mdi-brightness-4:before { content: "\f1d3"; } .mdi-brightness-5:before { content: "\f1d4"; } .mdi-brightness-6:before { content: "\f1d5"; } .mdi-brightness-7:before { content: "\f1d6"; } .mdi-brightness-auto:before { content: "\f1d7"; } .mdi-broom:before { content: "\f1d8"; } .mdi-brush:before { content: "\f1d9"; } .mdi-bug:before { content: "\f1da"; } .mdi-bulletin-board:before { content: "\f1db"; } .mdi-bullhorn:before { content: "\f1dc"; } .mdi-bus:before { content: "\f1dd"; } .mdi-cached:before { content: "\f1de"; } .mdi-cake:before { content: "\f1df"; } .mdi-cake-layered:before { content: "\f1e0"; } .mdi-cake-variant:before { content: "\f1e1"; } .mdi-calculator:before { content: "\f1e2"; } .mdi-calendar:before { content: "\f1e3"; } .mdi-calendar-blank:before { content: "\f1e4"; } .mdi-calendar-check:before { content: "\f1e5"; } .mdi-calendar-clock:before { content: "\f1e6"; } .mdi-calendar-multiple:before { content: "\f1e7"; } .mdi-calendar-multiple-check:before { content: "\f1e8"; } .mdi-calendar-plus:before { content: "\f1e9"; } .mdi-calendar-remove:before { content: "\f1ea"; } .mdi-calendar-text:before { content: "\f1eb"; } .mdi-calendar-today:before { content: "\f1ec"; } .mdi-call-made:before { content: "\f1ed"; } .mdi-call-merge:before { content: "\f1ee"; } .mdi-call-missed:before { content: "\f1ef"; } .mdi-call-received:before { content: "\f1f0"; } .mdi-call-split:before { content: "\f1f1"; } .mdi-camcorder:before { content: "\f1f2"; } .mdi-camcorder-box:before { content: "\f1f3"; } .mdi-camcorder-box-off:before { content: "\f1f4"; } .mdi-camcorder-off:before { content: "\f1f5"; } .mdi-camera:before { content: "\f1f6"; } .mdi-camera-enhance:before { content: "\f1f7"; } .mdi-camera-front:before { content: "\f1f8"; } .mdi-camera-front-variant:before { content: "\f1f9"; } .mdi-camera-iris:before { content: "\f1fa"; } .mdi-camera-party-mode:before { content: "\f1fb"; } .mdi-camera-rear:before { content: "\f1fc"; } .mdi-camera-rear-variant:before { content: "\f1fd"; } .mdi-camera-switch:before { content: "\f1fe"; } .mdi-camera-timer:before { content: "\f1ff"; } .mdi-candycane:before { content: "\f200"; } .mdi-car:before { content: "\f201"; } .mdi-car-battery:before { content: "\f202"; } .mdi-car-connected:before { content: "\f203"; } .mdi-car-wash:before { content: "\f204"; } .mdi-carrot:before { content: "\f205"; } .mdi-cart:before { content: "\f206"; } .mdi-cart-outline:before { content: "\f207"; } .mdi-cart-plus:before { content: "\f208"; } .mdi-case-sensitive-alt:before { content: "\f209"; } .mdi-cash:before { content: "\f20a"; } .mdi-cash-100:before { content: "\f20b"; } .mdi-cash-multiple:before { content: "\f20c"; } .mdi-cash-usd:before { content: "\f20d"; } .mdi-cast:before { content: "\f20e"; } .mdi-cast-connected:before { content: "\f20f"; } .mdi-castle:before { content: "\f210"; } .mdi-cat:before { content: "\f211"; } .mdi-cellphone:before { content: "\f212"; } .mdi-cellphone-android:before { content: "\f213"; } .mdi-cellphone-basic:before { content: "\f214"; } .mdi-cellphone-dock:before { content: "\f215"; } .mdi-cellphone-iphone:before { content: "\f216"; } .mdi-cellphone-link:before { content: "\f217"; } .mdi-cellphone-link-off:before { content: "\f218"; } .mdi-cellphone-settings:before { content: "\f219"; } .mdi-certificate:before { content: "\f21a"; } .mdi-chair-school:before { content: "\f21b"; } .mdi-chart-arc:before { content: "\f21c"; } .mdi-chart-areaspline:before { content: "\f21d"; } .mdi-chart-bar:before { content: "\f21e"; } .mdi-chart-histogram:before { content: "\f21f"; } .mdi-chart-line:before { content: "\f220"; } .mdi-chart-pie:before { content: "\f221"; } .mdi-check:before { content: "\f222"; } .mdi-check-all:before { content: "\f223"; } .mdi-checkbox-blank:before { content: "\f224"; } .mdi-checkbox-blank-circle:before { content: "\f225"; } .mdi-checkbox-blank-circle-outline:before { content: "\f226"; } .mdi-checkbox-blank-outline:before { content: "\f227"; } .mdi-checkbox-marked:before { content: "\f228"; } .mdi-checkbox-marked-circle:before { content: "\f229"; } .mdi-checkbox-marked-circle-outline:before { content: "\f22a"; } .mdi-checkbox-marked-outline:before { content: "\f22b"; } .mdi-checkbox-multiple-blank:before { content: "\f22c"; } .mdi-checkbox-multiple-blank-outline:before { content: "\f22d"; } .mdi-checkbox-multiple-marked:before { content: "\f22e"; } .mdi-checkbox-multiple-marked-outline:before { content: "\f22f"; } .mdi-checkerboard:before { content: "\f230"; } .mdi-chemical-weapon:before { content: "\f231"; } .mdi-chevron-double-down:before { content: "\f232"; } .mdi-chevron-double-left:before { content: "\f233"; } .mdi-chevron-double-right:before { content: "\f234"; } .mdi-chevron-double-up:before { content: "\f235"; } .mdi-chevron-down:before { content: "\f236"; } .mdi-chevron-left:before { content: "\f237"; } .mdi-chevron-right:before { content: "\f238"; } .mdi-chevron-up:before { content: "\f239"; } .mdi-church:before { content: "\f23a"; } .mdi-cisco-webex:before { content: "\f23b"; } .mdi-city:before { content: "\f23c"; } .mdi-clipboard:before { content: "\f23d"; } .mdi-clipboard-account:before { content: "\f23e"; } .mdi-clipboard-alert:before { content: "\f23f"; } .mdi-clipboard-arrow-down:before { content: "\f240"; } .mdi-clipboard-arrow-left:before { content: "\f241"; } .mdi-clipboard-check:before { content: "\f242"; } .mdi-clipboard-outline:before { content: "\f243"; } .mdi-clipboard-text:before { content: "\f244"; } .mdi-clippy:before { content: "\f245"; } .mdi-clock:before { content: "\f246"; } .mdi-clock-end:before { content: "\f247"; } .mdi-clock-fast:before { content: "\f248"; } .mdi-clock-in:before { content: "\f249"; } .mdi-clock-out:before { content: "\f24a"; } .mdi-clock-start:before { content: "\f24b"; } .mdi-close:before { content: "\f24c"; } .mdi-close-box:before { content: "\f24d"; } .mdi-close-box-outline:before { content: "\f24e"; } .mdi-close-circle:before { content: "\f24f"; } .mdi-close-circle-outline:before { content: "\f250"; } .mdi-close-network:before { content: "\f251"; } .mdi-close-octagon:before { content: "\f252"; } .mdi-close-octagon-outline:before { content: "\f253"; } .mdi-closed-caption:before { content: "\f254"; } .mdi-cloud:before { content: "\f255"; } .mdi-cloud-check:before { content: "\f256"; } .mdi-cloud-circle:before { content: "\f257"; } .mdi-cloud-download:before { content: "\f258"; } .mdi-cloud-outline:before { content: "\f259"; } .mdi-cloud-outline-off:before { content: "\f25a"; } .mdi-cloud-print:before { content: "\f25b"; } .mdi-cloud-print-outline:before { content: "\f25c"; } .mdi-cloud-upload:before { content: "\f25d"; } .mdi-code-array:before { content: "\f25e"; } .mdi-code-braces:before { content: "\f25f"; } .mdi-code-brackets:before { content: "\f260"; } .mdi-code-equal:before { content: "\f261"; } .mdi-code-greater-than:before { content: "\f262"; } .mdi-code-greater-than-or-equal:before { content: "\f263"; } .mdi-code-less-than:before { content: "\f264"; } .mdi-code-less-than-or-equal:before { content: "\f265"; } .mdi-code-not-equal:before { content: "\f266"; } .mdi-code-not-equal-variant:before { content: "\f267"; } .mdi-code-parentheses:before { content: "\f268"; } .mdi-code-string:before { content: "\f269"; } .mdi-code-tags:before { content: "\f26a"; } .mdi-codepen:before { content: "\f26b"; } .mdi-coffee:before { content: "\f26c"; } .mdi-coffee-to-go:before { content: "\f26d"; } .mdi-coin:before { content: "\f26e"; } .mdi-color-helper:before { content: "\f26f"; } .mdi-comment:before { content: "\f270"; } .mdi-comment-account:before { content: "\f271"; } .mdi-comment-account-outline:before { content: "\f272"; } .mdi-comment-alert:before { content: "\f273"; } .mdi-comment-alert-outline:before { content: "\f274"; } .mdi-comment-check:before { content: "\f275"; } .mdi-comment-check-outline:before { content: "\f276"; } .mdi-comment-multiple-outline:before { content: "\f277"; } .mdi-comment-outline:before { content: "\f278"; } .mdi-comment-plus-outline:before { content: "\f279"; } .mdi-comment-processing:before { content: "\f27a"; } .mdi-comment-processing-outline:before { content: "\f27b"; } .mdi-comment-question-outline:before { content: "\f27c"; } .mdi-comment-remove-outline:before { content: "\f27d"; } .mdi-comment-text:before { content: "\f27e"; } .mdi-comment-text-outline:before { content: "\f27f"; } .mdi-compare:before { content: "\f280"; } .mdi-compass:before { content: "\f281"; } .mdi-compass-outline:before { content: "\f282"; } .mdi-console:before { content: "\f283"; } .mdi-contact-mail:before { content: "\f284"; } .mdi-content-copy:before { content: "\f285"; } .mdi-content-cut:before { content: "\f286"; } .mdi-content-duplicate:before { content: "\f287"; } .mdi-content-paste:before { content: "\f288"; } .mdi-content-save:before { content: "\f289"; } .mdi-content-save-all:before { content: "\f28a"; } .mdi-contrast:before { content: "\f28b"; } .mdi-contrast-box:before { content: "\f28c"; } .mdi-contrast-circle:before { content: "\f28d"; } .mdi-cookie:before { content: "\f28e"; } .mdi-cow:before { content: "\f28f"; } .mdi-credit-card:before { content: "\f290"; } .mdi-credit-card-multiple:before { content: "\f291"; } .mdi-credit-card-scan:before { content: "\f292"; } .mdi-crop:before { content: "\f293"; } .mdi-crop-free:before { content: "\f294"; } .mdi-crop-landscape:before { content: "\f295"; } .mdi-crop-portrait:before { content: "\f296"; } .mdi-crop-square:before { content: "\f297"; } .mdi-crosshairs:before { content: "\f298"; } .mdi-crosshairs-gps:before { content: "\f299"; } .mdi-crown:before { content: "\f29a"; } .mdi-cube:before { content: "\f29b"; } .mdi-cube-outline:before { content: "\f29c"; } .mdi-cube-send:before { content: "\f29d"; } .mdi-cube-unfolded:before { content: "\f29e"; } .mdi-cup:before { content: "\f29f"; } .mdi-cup-water:before { content: "\f2a0"; } .mdi-currency-btc:before { content: "\f2a1"; } .mdi-currency-eur:before { content: "\f2a2"; } .mdi-currency-gbp:before { content: "\f2a3"; } .mdi-currency-inr:before { content: "\f2a4"; } .mdi-currency-ngn:before { content: "\f2a5"; } .mdi-currency-rub:before { content: "\f2a6"; } .mdi-currency-try:before { content: "\f2a7"; } .mdi-currency-usd:before { content: "\f2a8"; } .mdi-cursor-default:before { content: "\f2a9"; } .mdi-cursor-default-outline:before { content: "\f2aa"; } .mdi-cursor-move:before { content: "\f2ab"; } .mdi-cursor-pointer:before { content: "\f2ac"; } .mdi-database:before { content: "\f2ad"; } .mdi-database-minus:before { content: "\f2ae"; } .mdi-database-plus:before { content: "\f2af"; } .mdi-debug-step-into:before { content: "\f2b0"; } .mdi-debug-step-out:before { content: "\f2b1"; } .mdi-debug-step-over:before { content: "\f2b2"; } .mdi-decimal-decrease:before { content: "\f2b3"; } .mdi-decimal-increase:before { content: "\f2b4"; } .mdi-delete:before { content: "\f2b5"; } .mdi-delete-variant:before { content: "\f2b6"; } .mdi-delta:before { content: "\f2b7"; } .mdi-deskphone:before { content: "\f2b8"; } .mdi-desktop-mac:before { content: "\f2b9"; } .mdi-desktop-tower:before { content: "\f2ba"; } .mdi-details:before { content: "\f2bb"; } .mdi-deviantart:before { content: "\f2bc"; } .mdi-diamond:before { content: "\f2bd"; } .mdi-dice:before { content: "\f2be"; } .mdi-dice-1:before { content: "\f2bf"; } .mdi-dice-2:before { content: "\f2c0"; } .mdi-dice-3:before { content: "\f2c1"; } .mdi-dice-4:before { content: "\f2c2"; } .mdi-dice-5:before { content: "\f2c3"; } .mdi-dice-6:before { content: "\f2c4"; } .mdi-directions:before { content: "\f2c5"; } .mdi-disk-alert:before { content: "\f2c6"; } .mdi-disqus:before { content: "\f2c7"; } .mdi-disqus-outline:before { content: "\f2c8"; } .mdi-division:before { content: "\f2c9"; } .mdi-division-box:before { content: "\f2ca"; } .mdi-dns:before { content: "\f2cb"; } .mdi-domain:before { content: "\f2cc"; } .mdi-dots-horizontal:before { content: "\f2cd"; } .mdi-dots-vertical:before { content: "\f2ce"; } .mdi-download:before { content: "\f2cf"; } .mdi-drag:before { content: "\f2d0"; } .mdi-drag-horizontal:before { content: "\f2d1"; } .mdi-drag-vertical:before { content: "\f2d2"; } .mdi-drawing:before { content: "\f2d3"; } .mdi-drawing-box:before { content: "\f2d4"; } .mdi-dribbble:before { content: "\f2d5"; } .mdi-dribbble-box:before { content: "\f2d6"; } .mdi-drone:before { content: "\f2d7"; } .mdi-dropbox:before { content: "\f2d8"; } .mdi-drupal:before { content: "\f2d9"; } .mdi-duck:before { content: "\f2da"; } .mdi-dumbbell:before { content: "\f2db"; } .mdi-earth:before { content: "\f2dc"; } .mdi-earth-off:before { content: "\f2dd"; } .mdi-edge:before { content: "\f2de"; } .mdi-eject:before { content: "\f2df"; } .mdi-elevation-decline:before { content: "\f2e0"; } .mdi-elevation-rise:before { content: "\f2e1"; } .mdi-elevator:before { content: "\f2e2"; } .mdi-email:before { content: "\f2e3"; } .mdi-email-open:before { content: "\f2e4"; } .mdi-email-outline:before { content: "\f2e5"; } .mdi-email-secure:before { content: "\f2e6"; } .mdi-emoticon:before { content: "\f2e7"; } .mdi-emoticon-cool:before { content: "\f2e8"; } .mdi-emoticon-devil:before { content: "\f2e9"; } .mdi-emoticon-happy:before { content: "\f2ea"; } .mdi-emoticon-neutral:before { content: "\f2eb"; } .mdi-emoticon-poop:before { content: "\f2ec"; } .mdi-emoticon-sad:before { content: "\f2ed"; } .mdi-emoticon-tongue:before { content: "\f2ee"; } .mdi-engine:before { content: "\f2ef"; } .mdi-engine-outline:before { content: "\f2f0"; } .mdi-equal:before { content: "\f2f1"; } .mdi-equal-box:before { content: "\f2f2"; } .mdi-eraser:before { content: "\f2f3"; } .mdi-escalator:before { content: "\f2f4"; } .mdi-ethernet:before { content: "\f2f5"; } .mdi-ethernet-cable:before { content: "\f2f6"; } .mdi-ethernet-cable-off:before { content: "\f2f7"; } .mdi-etsy:before { content: "\f2f8"; } .mdi-evernote:before { content: "\f2f9"; } .mdi-exclamation:before { content: "\f2fa"; } .mdi-exit-to-app:before { content: "\f2fb"; } .mdi-export:before { content: "\f2fc"; } .mdi-eye:before { content: "\f2fd"; } .mdi-eye-off:before { content: "\f2fe"; } .mdi-eyedropper:before { content: "\f2ff"; } .mdi-eyedropper-variant:before { content: "\f300"; } .mdi-facebook:before { content: "\f301"; } .mdi-facebook-box:before { content: "\f302"; } .mdi-facebook-messenger:before { content: "\f303"; } .mdi-factory:before { content: "\f304"; } .mdi-fan:before { content: "\f305"; } .mdi-fast-forward:before { content: "\f306"; } .mdi-fax:before { content: "\f307"; } .mdi-ferry:before { content: "\f308"; } .mdi-file:before { content: "\f309"; } .mdi-file-chart:before { content: "\f30a"; } .mdi-file-check:before { content: "\f30b"; } .mdi-file-cloud:before { content: "\f30c"; } .mdi-file-delimited:before { content: "\f30d"; } .mdi-file-document:before { content: "\f30e"; } .mdi-file-document-box:before { content: "\f30f"; } .mdi-file-excel:before { content: "\f310"; } .mdi-file-excel-box:before { content: "\f311"; } .mdi-file-export:before { content: "\f312"; } .mdi-file-find:before { content: "\f313"; } .mdi-file-image:before { content: "\f314"; } .mdi-file-import:before { content: "\f315"; } .mdi-file-lock:before { content: "\f316"; } .mdi-file-multiple:before { content: "\f317"; } .mdi-file-music:before { content: "\f318"; } .mdi-file-outline:before { content: "\f319"; } .mdi-file-pdf:before { content: "\f31a"; } .mdi-file-pdf-box:before { content: "\f31b"; } .mdi-file-powerpoint:before { content: "\f31c"; } .mdi-file-powerpoint-box:before { content: "\f31d"; } .mdi-file-presentation-box:before { content: "\f31e"; } .mdi-file-send:before { content: "\f31f"; } .mdi-file-video:before { content: "\f320"; } .mdi-file-word:before { content: "\f321"; } .mdi-file-word-box:before { content: "\f322"; } .mdi-file-xml:before { content: "\f323"; } .mdi-film:before { content: "\f324"; } .mdi-filmstrip:before { content: "\f325"; } .mdi-filmstrip-off:before { content: "\f326"; } .mdi-filter:before { content: "\f327"; } .mdi-filter-outline:before { content: "\f328"; } .mdi-filter-remove:before { content: "\f329"; } .mdi-filter-remove-outline:before { content: "\f32a"; } .mdi-filter-variant:before { content: "\f32b"; } .mdi-fingerprint:before { content: "\f32c"; } .mdi-fire:before { content: "\f32d"; } .mdi-firefox:before { content: "\f32e"; } .mdi-fish:before { content: "\f32f"; } .mdi-flag:before { content: "\f330"; } .mdi-flag-checkered:before { content: "\f331"; } .mdi-flag-outline:before { content: "\f332"; } .mdi-flag-outline-variant:before { content: "\f333"; } .mdi-flag-triangle:before { content: "\f334"; } .mdi-flag-variant:before { content: "\f335"; } .mdi-flash:before { content: "\f336"; } .mdi-flash-auto:before { content: "\f337"; } .mdi-flash-off:before { content: "\f338"; } .mdi-flashlight:before { content: "\f339"; } .mdi-flashlight-off:before { content: "\f33a"; } .mdi-flattr:before { content: "\f33b"; } .mdi-flip-to-back:before { content: "\f33c"; } .mdi-flip-to-front:before { content: "\f33d"; } .mdi-floppy:before { content: "\f33e"; } .mdi-flower:before { content: "\f33f"; } .mdi-folder:before { content: "\f340"; } .mdi-folder-account:before { content: "\f341"; } .mdi-folder-download:before { content: "\f342"; } .mdi-folder-google-drive:before { content: "\f343"; } .mdi-folder-image:before { content: "\f344"; } .mdi-folder-lock:before { content: "\f345"; } .mdi-folder-lock-open:before { content: "\f346"; } .mdi-folder-move:before { content: "\f347"; } .mdi-folder-multiple:before { content: "\f348"; } .mdi-folder-multiple-image:before { content: "\f349"; } .mdi-folder-multiple-outline:before { content: "\f34a"; } .mdi-folder-outline:before { content: "\f34b"; } .mdi-folder-plus:before { content: "\f34c"; } .mdi-folder-remove:before { content: "\f34d"; } .mdi-folder-upload:before { content: "\f34e"; } .mdi-food:before { content: "\f34f"; } .mdi-food-apple:before { content: "\f350"; } .mdi-food-variant:before { content: "\f351"; } .mdi-football:before { content: "\f352"; } .mdi-football-australian:before { content: "\f353"; } .mdi-football-helmet:before { content: "\f354"; } .mdi-format-align-center:before { content: "\f355"; } .mdi-format-align-justify:before { content: "\f356"; } .mdi-format-align-left:before { content: "\f357"; } .mdi-format-align-right:before { content: "\f358"; } .mdi-format-bold:before { content: "\f359"; } .mdi-format-clear:before { content: "\f35a"; } .mdi-format-color-fill:before { content: "\f35b"; } .mdi-format-float-center:before { content: "\f35c"; } .mdi-format-float-left:before { content: "\f35d"; } .mdi-format-float-none:before { content: "\f35e"; } .mdi-format-float-right:before { content: "\f35f"; } .mdi-format-header-1:before { content: "\f360"; } .mdi-format-header-2:before { content: "\f361"; } .mdi-format-header-3:before { content: "\f362"; } .mdi-format-header-4:before { content: "\f363"; } .mdi-format-header-5:before { content: "\f364"; } .mdi-format-header-6:before { content: "\f365"; } .mdi-format-header-decrease:before { content: "\f366"; } .mdi-format-header-equal:before { content: "\f367"; } .mdi-format-header-increase:before { content: "\f368"; } .mdi-format-header-pound:before { content: "\f369"; } .mdi-format-indent-decrease:before { content: "\f36a"; } .mdi-format-indent-increase:before { content: "\f36b"; } .mdi-format-italic:before { content: "\f36c"; } .mdi-format-line-spacing:before { content: "\f36d"; } .mdi-format-list-bulleted:before { content: "\f36e"; } .mdi-format-list-bulleted-type:before { content: "\f36f"; } .mdi-format-list-numbers:before { content: "\f370"; } .mdi-format-paint:before { content: "\f371"; } .mdi-format-paragraph:before { content: "\f372"; } .mdi-format-quote:before { content: "\f373"; } .mdi-format-size:before { content: "\f374"; } .mdi-format-strikethrough:before { content: "\f375"; } .mdi-format-strikethrough-variant:before { content: "\f376"; } .mdi-format-subscript:before { content: "\f377"; } .mdi-format-superscript:before { content: "\f378"; } .mdi-format-text:before { content: "\f379"; } .mdi-format-textdirection-l-to-r:before { content: "\f37a"; } .mdi-format-textdirection-r-to-l:before { content: "\f37b"; } .mdi-format-underline:before { content: "\f37c"; } .mdi-format-wrap-inline:before { content: "\f37d"; } .mdi-format-wrap-square:before { content: "\f37e"; } .mdi-format-wrap-tight:before { content: "\f37f"; } .mdi-format-wrap-top-bottom:before { content: "\f380"; } .mdi-forum:before { content: "\f381"; } .mdi-forward:before { content: "\f382"; } .mdi-foursquare:before { content: "\f383"; } .mdi-fridge:before { content: "\f384"; } .mdi-fridge-filled:before { content: "\f385"; } .mdi-fridge-filled-bottom:before { content: "\f386"; } .mdi-fridge-filled-top:before { content: "\f387"; } .mdi-fullscreen:before { content: "\f388"; } .mdi-fullscreen-exit:before { content: "\f389"; } .mdi-function:before { content: "\f38a"; } .mdi-gamepad:before { content: "\f38b"; } .mdi-gamepad-variant:before { content: "\f38c"; } .mdi-gas-station:before { content: "\f38d"; } .mdi-gate:before { content: "\f38e"; } .mdi-gauge:before { content: "\f38f"; } .mdi-gavel:before { content: "\f390"; } .mdi-gender-female:before { content: "\f391"; } .mdi-gender-male:before { content: "\f392"; } .mdi-gender-male-female:before { content: "\f393"; } .mdi-gender-transgender:before { content: "\f394"; } .mdi-ghost:before { content: "\f395"; } .mdi-gift:before { content: "\f396"; } .mdi-git:before { content: "\f397"; } .mdi-github-box:before { content: "\f398"; } .mdi-github-circle:before { content: "\f399"; } .mdi-glass-flute:before { content: "\f39a"; } .mdi-glass-mug:before { content: "\f39b"; } .mdi-glass-stange:before { content: "\f39c"; } .mdi-glass-tulip:before { content: "\f39d"; } .mdi-glasses:before { content: "\f39e"; } .mdi-gmail:before { content: "\f39f"; } .mdi-google:before { content: "\f3a0"; } .mdi-google-cardboard:before { content: "\f3a1"; } .mdi-google-chrome:before { content: "\f3a2"; } .mdi-google-circles:before { content: "\f3a3"; } .mdi-google-circles-communities:before { content: "\f3a4"; } .mdi-google-circles-extended:before { content: "\f3a5"; } .mdi-google-circles-group:before { content: "\f3a6"; } .mdi-google-controller:before { content: "\f3a7"; } .mdi-google-controller-off:before { content: "\f3a8"; } .mdi-google-drive:before { content: "\f3a9"; } .mdi-google-earth:before { content: "\f3aa"; } .mdi-google-glass:before { content: "\f3ab"; } .mdi-google-nearby:before { content: "\f3ac"; } .mdi-google-pages:before { content: "\f3ad"; } .mdi-google-physical-web:before { content: "\f3ae"; } .mdi-google-play:before { content: "\f3af"; } .mdi-google-plus:before { content: "\f3b0"; } .mdi-google-plus-box:before { content: "\f3b1"; } .mdi-google-translate:before { content: "\f3b2"; } .mdi-google-wallet:before { content: "\f3b3"; } .mdi-grid:before { content: "\f3b4"; } .mdi-grid-off:before { content: "\f3b5"; } .mdi-group:before { content: "\f3b6"; } .mdi-guitar:before { content: "\f3b7"; } .mdi-guitar-pick:before { content: "\f3b8"; } .mdi-guitar-pick-outline:before { content: "\f3b9"; } .mdi-hand-pointing-right:before { content: "\f3ba"; } .mdi-hanger:before { content: "\f3bb"; } .mdi-hangouts:before { content: "\f3bc"; } .mdi-harddisk:before { content: "\f3bd"; } .mdi-headphones:before { content: "\f3be"; } .mdi-headphones-box:before { content: "\f3bf"; } .mdi-headphones-settings:before { content: "\f3c0"; } .mdi-headset:before { content: "\f3c1"; } .mdi-headset-dock:before { content: "\f3c2"; } .mdi-headset-off:before { content: "\f3c3"; } .mdi-heart:before { content: "\f3c4"; } .mdi-heart-box:before { content: "\f3c5"; } .mdi-heart-box-outline:before { content: "\f3c6"; } .mdi-heart-broken:before { content: "\f3c7"; } .mdi-heart-outline:before { content: "\f3c8"; } .mdi-help:before { content: "\f3c9"; } .mdi-help-circle:before { content: "\f3ca"; } .mdi-hexagon:before { content: "\f3cb"; } .mdi-hexagon-outline:before { content: "\f3cc"; } .mdi-history:before { content: "\f3cd"; } .mdi-hololens:before { content: "\f3ce"; } .mdi-home:before { content: "\f3cf"; } .mdi-home-modern:before { content: "\f3d0"; } .mdi-home-variant:before { content: "\f3d1"; } .mdi-hops:before { content: "\f3d2"; } .mdi-hospital:before { content: "\f3d3"; } .mdi-hospital-building:before { content: "\f3d4"; } .mdi-hospital-marker:before { content: "\f3d5"; } .mdi-hotel:before { content: "\f3d6"; } .mdi-houzz:before { content: "\f3d7"; } .mdi-houzz-box:before { content: "\f3d8"; } .mdi-human:before { content: "\f3d9"; } .mdi-human-child:before { content: "\f3da"; } .mdi-human-male-female:before { content: "\f3db"; } .mdi-image:before { content: "\f3dc"; } .mdi-image-album:before { content: "\f3dd"; } .mdi-image-area:before { content: "\f3de"; } .mdi-image-area-close:before { content: "\f3df"; } .mdi-image-broken:before { content: "\f3e0"; } .mdi-image-broken-variant:before { content: "\f3e1"; } .mdi-image-filter:before { content: "\f3e2"; } .mdi-image-filter-black-white:before { content: "\f3e3"; } .mdi-image-filter-center-focus:before { content: "\f3e4"; } .mdi-image-filter-center-focus-weak:before { content: "\f3e5"; } .mdi-image-filter-drama:before { content: "\f3e6"; } .mdi-image-filter-frames:before { content: "\f3e7"; } .mdi-image-filter-hdr:before { content: "\f3e8"; } .mdi-image-filter-none:before { content: "\f3e9"; } .mdi-image-filter-tilt-shift:before { content: "\f3ea"; } .mdi-image-filter-vintage:before { content: "\f3eb"; } .mdi-image-multiple:before { content: "\f3ec"; } .mdi-import:before { content: "\f3ed"; } .mdi-inbox:before { content: "\f3ee"; } .mdi-information:before { content: "\f3ef"; } .mdi-information-outline:before { content: "\f3f0"; } .mdi-instagram:before { content: "\f3f1"; } .mdi-instapaper:before { content: "\f3f2"; } .mdi-internet-explorer:before { content: "\f3f3"; } .mdi-invert-colors:before { content: "\f3f4"; } .mdi-jeepney:before { content: "\f3f5"; } .mdi-jira:before { content: "\f3f6"; } .mdi-jsfiddle:before { content: "\f3f7"; } .mdi-keg:before { content: "\f3f8"; } .mdi-key:before { content: "\f3f9"; } .mdi-key-change:before { content: "\f3fa"; } .mdi-key-minus:before { content: "\f3fb"; } .mdi-key-plus:before { content: "\f3fc"; } .mdi-key-remove:before { content: "\f3fd"; } .mdi-key-variant:before { content: "\f3fe"; } .mdi-keyboard:before { content: "\f3ff"; } .mdi-keyboard-backspace:before { content: "\f400"; } .mdi-keyboard-caps:before { content: "\f401"; } .mdi-keyboard-close:before { content: "\f402"; } .mdi-keyboard-off:before { content: "\f403"; } .mdi-keyboard-return:before { content: "\f404"; } .mdi-keyboard-tab:before { content: "\f405"; } .mdi-keyboard-variant:before { content: "\f406"; } .mdi-label:before { content: "\f407"; } .mdi-label-outline:before { content: "\f408"; } .mdi-lan:before { content: "\f409"; } .mdi-lan-connect:before { content: "\f40a"; } .mdi-lan-disconnect:before { content: "\f40b"; } .mdi-lan-pending:before { content: "\f40c"; } .mdi-language-csharp:before { content: "\f40d"; } .mdi-language-css3:before { content: "\f40e"; } .mdi-language-html5:before { content: "\f40f"; } .mdi-language-javascript:before { content: "\f410"; } .mdi-language-php:before { content: "\f411"; } .mdi-language-python:before { content: "\f412"; } .mdi-language-python-text:before { content: "\f413"; } .mdi-laptop:before { content: "\f414"; } .mdi-laptop-chromebook:before { content: "\f415"; } .mdi-laptop-mac:before { content: "\f416"; } .mdi-laptop-windows:before { content: "\f417"; } .mdi-lastfm:before { content: "\f418"; } .mdi-launch:before { content: "\f419"; } .mdi-layers:before { content: "\f41a"; } .mdi-layers-off:before { content: "\f41b"; } .mdi-leaf:before { content: "\f41c"; } .mdi-led-off:before { content: "\f41d"; } .mdi-led-on:before { content: "\f41e"; } .mdi-led-outline:before { content: "\f41f"; } .mdi-led-variant-off:before { content: "\f420"; } .mdi-led-variant-on:before { content: "\f421"; } .mdi-led-variant-outline:before { content: "\f422"; } .mdi-library:before { content: "\f423"; } .mdi-library-books:before { content: "\f424"; } .mdi-library-music:before { content: "\f425"; } .mdi-library-plus:before { content: "\f426"; } .mdi-lightbulb:before { content: "\f427"; } .mdi-lightbulb-outline:before { content: "\f428"; } .mdi-link:before { content: "\f429"; } .mdi-link-off:before { content: "\f42a"; } .mdi-link-variant:before { content: "\f42b"; } .mdi-link-variant-off:before { content: "\f42c"; } .mdi-linkedin:before { content: "\f42d"; } .mdi-linkedin-box:before { content: "\f42e"; } .mdi-linux:before { content: "\f42f"; } .mdi-lock:before { content: "\f430"; } .mdi-lock-open:before { content: "\f431"; } .mdi-lock-open-outline:before { content: "\f432"; } .mdi-lock-outline:before { content: "\f433"; } .mdi-login:before { content: "\f434"; } .mdi-logout:before { content: "\f435"; } .mdi-looks:before { content: "\f436"; } .mdi-loupe:before { content: "\f437"; } .mdi-lumx:before { content: "\f438"; } .mdi-magnet:before { content: "\f439"; } .mdi-magnet-on:before { content: "\f43a"; } .mdi-magnify:before { content: "\f43b"; } .mdi-magnify-minus:before { content: "\f43c"; } .mdi-magnify-plus:before { content: "\f43d"; } .mdi-mail-ru:before { content: "\f43e"; } .mdi-map:before { content: "\f43f"; } .mdi-map-marker:before { content: "\f440"; } .mdi-map-marker-circle:before { content: "\f441"; } .mdi-map-marker-multiple:before { content: "\f442"; } .mdi-map-marker-off:before { content: "\f443"; } .mdi-map-marker-radius:before { content: "\f444"; } .mdi-margin:before { content: "\f445"; } .mdi-markdown:before { content: "\f446"; } .mdi-marker-check:before { content: "\f447"; } .mdi-martini:before { content: "\f448"; } .mdi-material-ui:before { content: "\f449"; } .mdi-math-compass:before { content: "\f44a"; } .mdi-maxcdn:before { content: "\f44b"; } .mdi-medium:before { content: "\f44c"; } .mdi-memory:before { content: "\f44d"; } .mdi-menu:before { content: "\f44e"; } .mdi-menu-down:before { content: "\f44f"; } .mdi-menu-left:before { content: "\f450"; } .mdi-menu-right:before { content: "\f451"; } .mdi-menu-up:before { content: "\f452"; } .mdi-message:before { content: "\f453"; } .mdi-message-alert:before { content: "\f454"; } .mdi-message-draw:before { content: "\f455"; } .mdi-message-image:before { content: "\f456"; } .mdi-message-outline:before { content: "\f457"; } .mdi-message-processing:before { content: "\f458"; } .mdi-message-reply:before { content: "\f459"; } .mdi-message-reply-text:before { content: "\f45a"; } .mdi-message-text:before { content: "\f45b"; } .mdi-message-text-outline:before { content: "\f45c"; } .mdi-message-video:before { content: "\f45d"; } .mdi-microphone:before { content: "\f45e"; } .mdi-microphone-off:before { content: "\f45f"; } .mdi-microphone-outline:before { content: "\f460"; } .mdi-microphone-settings:before { content: "\f461"; } .mdi-microphone-variant:before { content: "\f462"; } .mdi-microphone-variant-off:before { content: "\f463"; } .mdi-microsoft:before { content: "\f464"; } .mdi-minus:before { content: "\f465"; } .mdi-minus-box:before { content: "\f466"; } .mdi-minus-circle:before { content: "\f467"; } .mdi-minus-circle-outline:before { content: "\f468"; } .mdi-minus-network:before { content: "\f469"; } .mdi-monitor:before { content: "\f46a"; } .mdi-monitor-multiple:before { content: "\f46b"; } .mdi-more:before { content: "\f46c"; } .mdi-motorbike:before { content: "\f46d"; } .mdi-mouse:before { content: "\f46e"; } .mdi-mouse-off:before { content: "\f46f"; } .mdi-mouse-variant:before { content: "\f470"; } .mdi-mouse-variant-off:before { content: "\f471"; } .mdi-movie:before { content: "\f472"; } .mdi-multiplication:before { content: "\f473"; } .mdi-multiplication-box:before { content: "\f474"; } .mdi-music-box:before { content: "\f475"; } .mdi-music-box-outline:before { content: "\f476"; } .mdi-music-circle:before { content: "\f477"; } .mdi-music-note:before { content: "\f478"; } .mdi-music-note-eighth:before { content: "\f479"; } .mdi-music-note-half:before { content: "\f47a"; } .mdi-music-note-off:before { content: "\f47b"; } .mdi-music-note-quarter:before { content: "\f47c"; } .mdi-music-note-sixteenth:before { content: "\f47d"; } .mdi-music-note-whole:before { content: "\f47e"; } .mdi-nature:before { content: "\f47f"; } .mdi-nature-people:before { content: "\f480"; } .mdi-navigation:before { content: "\f481"; } .mdi-needle:before { content: "\f482"; } .mdi-nest-protect:before { content: "\f483"; } .mdi-nest-thermostat:before { content: "\f484"; } .mdi-newspaper:before { content: "\f485"; } .mdi-nfc:before { content: "\f486"; } .mdi-nfc-tap:before { content: "\f487"; } .mdi-nfc-variant:before { content: "\f488"; } .mdi-nodejs:before { content: "\f489"; } .mdi-note:before { content: "\f48a"; } .mdi-note-outline:before { content: "\f48b"; } .mdi-note-plus:before { content: "\f48c"; } .mdi-note-plus-outline:before { content: "\f48d"; } .mdi-note-text:before { content: "\f48e"; } .mdi-notification-clear-all:before { content: "\f48f"; } .mdi-numeric:before { content: "\f490"; } .mdi-numeric-0-box:before { content: "\f491"; } .mdi-numeric-0-box-multiple-outline:before { content: "\f492"; } .mdi-numeric-0-box-outline:before { content: "\f493"; } .mdi-numeric-1-box:before { content: "\f494"; } .mdi-numeric-1-box-multiple-outline:before { content: "\f495"; } .mdi-numeric-1-box-outline:before { content: "\f496"; } .mdi-numeric-2-box:before { content: "\f497"; } .mdi-numeric-2-box-multiple-outline:before { content: "\f498"; } .mdi-numeric-2-box-outline:before { content: "\f499"; } .mdi-numeric-3-box:before { content: "\f49a"; } .mdi-numeric-3-box-multiple-outline:before { content: "\f49b"; } .mdi-numeric-3-box-outline:before { content: "\f49c"; } .mdi-numeric-4-box:before { content: "\f49d"; } .mdi-numeric-4-box-multiple-outline:before { content: "\f49e"; } .mdi-numeric-4-box-outline:before { content: "\f49f"; } .mdi-numeric-5-box:before { content: "\f4a0"; } .mdi-numeric-5-box-multiple-outline:before { content: "\f4a1"; } .mdi-numeric-5-box-outline:before { content: "\f4a2"; } .mdi-numeric-6-box:before { content: "\f4a3"; } .mdi-numeric-6-box-multiple-outline:before { content: "\f4a4"; } .mdi-numeric-6-box-outline:before { content: "\f4a5"; } .mdi-numeric-7-box:before { content: "\f4a6"; } .mdi-numeric-7-box-multiple-outline:before { content: "\f4a7"; } .mdi-numeric-7-box-outline:before { content: "\f4a8"; } .mdi-numeric-8-box:before { content: "\f4a9"; } .mdi-numeric-8-box-multiple-outline:before { content: "\f4aa"; } .mdi-numeric-8-box-outline:before { content: "\f4ab"; } .mdi-numeric-9-box:before { content: "\f4ac"; } .mdi-numeric-9-box-multiple-outline:before { content: "\f4ad"; } .mdi-numeric-9-box-outline:before { content: "\f4ae"; } .mdi-numeric-9-plus-box:before { content: "\f4af"; } .mdi-numeric-9-plus-box-multiple-outline:before { content: "\f4b0"; } .mdi-numeric-9-plus-box-outline:before { content: "\f4b1"; } .mdi-nutrition:before { content: "\f4b2"; } .mdi-octagon:before { content: "\f4b3"; } .mdi-octagon-outline:before { content: "\f4b4"; } .mdi-odnoklassniki:before { content: "\f4b5"; } .mdi-office:before { content: "\f4b6"; } .mdi-oil:before { content: "\f4b7"; } .mdi-oil-temperature:before { content: "\f4b8"; } .mdi-omega:before { content: "\f4b9"; } .mdi-onedrive:before { content: "\f4ba"; } .mdi-open-in-app:before { content: "\f4bb"; } .mdi-open-in-new:before { content: "\f4bc"; } .mdi-opera:before { content: "\f4bd"; } .mdi-ornament:before { content: "\f4be"; } .mdi-ornament-variant:before { content: "\f4bf"; } .mdi-outbox:before { content: "\f4c0"; } .mdi-owl:before { content: "\f4c1"; } .mdi-package:before { content: "\f4c2"; } .mdi-package-down:before { content: "\f4c3"; } .mdi-package-up:before { content: "\f4c4"; } .mdi-package-variant:before { content: "\f4c5"; } .mdi-package-variant-closed:before { content: "\f4c6"; } .mdi-palette:before { content: "\f4c7"; } .mdi-palette-advanced:before { content: "\f4c8"; } .mdi-panda:before { content: "\f4c9"; } .mdi-pandora:before { content: "\f4ca"; } .mdi-panorama:before { content: "\f4cb"; } .mdi-panorama-fisheye:before { content: "\f4cc"; } .mdi-panorama-horizontal:before { content: "\f4cd"; } .mdi-panorama-vertical:before { content: "\f4ce"; } .mdi-panorama-wide-angle:before { content: "\f4cf"; } .mdi-paper-cut-vertical:before { content: "\f4d0"; } .mdi-paperclip:before { content: "\f4d1"; } .mdi-parking:before { content: "\f4d2"; } .mdi-pause:before { content: "\f4d3"; } .mdi-pause-circle:before { content: "\f4d4"; } .mdi-pause-circle-outline:before { content: "\f4d5"; } .mdi-pause-octagon:before { content: "\f4d6"; } .mdi-pause-octagon-outline:before { content: "\f4d7"; } .mdi-paw:before { content: "\f4d8"; } .mdi-pen:before { content: "\f4d9"; } .mdi-pencil:before { content: "\f4da"; } .mdi-pencil-box:before { content: "\f4db"; } .mdi-pencil-box-outline:before { content: "\f4dc"; } .mdi-pencil-lock:before { content: "\f4dd"; } .mdi-pencil-off:before { content: "\f4de"; } .mdi-percent:before { content: "\f4df"; } .mdi-pharmacy:before { content: "\f4e0"; } .mdi-phone:before { content: "\f4e1"; } .mdi-phone-bluetooth:before { content: "\f4e2"; } .mdi-phone-forward:before { content: "\f4e3"; } .mdi-phone-hangup:before { content: "\f4e4"; } .mdi-phone-in-talk:before { content: "\f4e5"; } .mdi-phone-incoming:before { content: "\f4e6"; } .mdi-phone-locked:before { content: "\f4e7"; } .mdi-phone-log:before { content: "\f4e8"; } .mdi-phone-missed:before { content: "\f4e9"; } .mdi-phone-outgoing:before { content: "\f4ea"; } .mdi-phone-paused:before { content: "\f4eb"; } .mdi-phone-settings:before { content: "\f4ec"; } .mdi-phone-voip:before { content: "\f4ed"; } .mdi-pi:before { content: "\f4ee"; } .mdi-pi-box:before { content: "\f4ef"; } .mdi-pig:before { content: "\f4f0"; } .mdi-pill:before { content: "\f4f1"; } .mdi-pin:before { content: "\f4f2"; } .mdi-pin-off:before { content: "\f4f3"; } .mdi-pine-tree:before { content: "\f4f4"; } .mdi-pine-tree-box:before { content: "\f4f5"; } .mdi-pinterest:before { content: "\f4f6"; } .mdi-pinterest-box:before { content: "\f4f7"; } .mdi-pizza:before { content: "\f4f8"; } .mdi-play:before { content: "\f4f9"; } .mdi-play-box-outline:before { content: "\f4fa"; } .mdi-play-circle:before { content: "\f4fb"; } .mdi-play-circle-outline:before { content: "\f4fc"; } .mdi-play-pause:before { content: "\f4fd"; } .mdi-play-protected-content:before { content: "\f4fe"; } .mdi-playlist-minus:before { content: "\f4ff"; } .mdi-playlist-play:before { content: "\f500"; } .mdi-playlist-plus:before { content: "\f501"; } .mdi-playlist-remove:before { content: "\f502"; } .mdi-playstation:before { content: "\f503"; } .mdi-plus:before { content: "\f504"; } .mdi-plus-box:before { content: "\f505"; } .mdi-plus-circle:before { content: "\f506"; } .mdi-plus-circle-multiple-outline:before { content: "\f507"; } .mdi-plus-circle-outline:before { content: "\f508"; } .mdi-plus-network:before { content: "\f509"; } .mdi-plus-one:before { content: "\f50a"; } .mdi-pocket:before { content: "\f50b"; } .mdi-pokeball:before { content: "\f50c"; } .mdi-polaroid:before { content: "\f50d"; } .mdi-poll:before { content: "\f50e"; } .mdi-poll-box:before { content: "\f50f"; } .mdi-polymer:before { content: "\f510"; } .mdi-popcorn:before { content: "\f511"; } .mdi-pound:before { content: "\f512"; } .mdi-pound-box:before { content: "\f513"; } .mdi-power:before { content: "\f514"; } .mdi-power-settings:before { content: "\f515"; } .mdi-power-socket:before { content: "\f516"; } .mdi-presentation:before { content: "\f517"; } .mdi-presentation-play:before { content: "\f518"; } .mdi-printer:before { content: "\f519"; } .mdi-printer-3d:before { content: "\f51a"; } .mdi-printer-alert:before { content: "\f51b"; } .mdi-professional-hexagon:before { content: "\f51c"; } .mdi-projector:before { content: "\f51d"; } .mdi-projector-screen:before { content: "\f51e"; } .mdi-pulse:before { content: "\f51f"; } .mdi-puzzle:before { content: "\f520"; } .mdi-qrcode:before { content: "\f521"; } .mdi-qrcode-scan:before { content: "\f522"; } .mdi-quadcopter:before { content: "\f523"; } .mdi-quality-high:before { content: "\f524"; } .mdi-quicktime:before { content: "\f525"; } .mdi-radar:before { content: "\f526"; } .mdi-radiator:before { content: "\f527"; } .mdi-radio:before { content: "\f528"; } .mdi-radio-handheld:before { content: "\f529"; } .mdi-radio-tower:before { content: "\f52a"; } .mdi-radioactive:before { content: "\f52b"; } .mdi-radiobox-blank:before { content: "\f52c"; } .mdi-radiobox-marked:before { content: "\f52d"; } .mdi-raspberrypi:before { content: "\f52e"; } .mdi-ray-end:before { content: "\f52f"; } .mdi-ray-end-arrow:before { content: "\f530"; } .mdi-ray-start:before { content: "\f531"; } .mdi-ray-start-arrow:before { content: "\f532"; } .mdi-ray-start-end:before { content: "\f533"; } .mdi-ray-vertex:before { content: "\f534"; } .mdi-rdio:before { content: "\f535"; } .mdi-read:before { content: "\f536"; } .mdi-readability:before { content: "\f537"; } .mdi-receipt:before { content: "\f538"; } .mdi-record:before { content: "\f539"; } .mdi-record-rec:before { content: "\f53a"; } .mdi-recycle:before { content: "\f53b"; } .mdi-reddit:before { content: "\f53c"; } .mdi-redo:before { content: "\f53d"; } .mdi-redo-variant:before { content: "\f53e"; } .mdi-refresh:before { content: "\f53f"; } .mdi-regex:before { content: "\f540"; } .mdi-relative-scale:before { content: "\f541"; } .mdi-reload:before { content: "\f542"; } .mdi-remote:before { content: "\f543"; } .mdi-rename-box:before { content: "\f544"; } .mdi-repeat:before { content: "\f545"; } .mdi-repeat-off:before { content: "\f546"; } .mdi-repeat-once:before { content: "\f547"; } .mdi-replay:before { content: "\f548"; } .mdi-reply:before { content: "\f549"; } .mdi-reply-all:before { content: "\f54a"; } .mdi-reproduction:before { content: "\f54b"; } .mdi-resize-bottom-right:before { content: "\f54c"; } .mdi-responsive:before { content: "\f54d"; } .mdi-rewind:before { content: "\f54e"; } .mdi-ribbon:before { content: "\f54f"; } .mdi-road:before { content: "\f550"; } .mdi-road-variant:before { content: "\f551"; } .mdi-rocket:before { content: "\f552"; } .mdi-rotate-3d:before { content: "\f553"; } .mdi-rotate-left:before { content: "\f554"; } .mdi-rotate-left-variant:before { content: "\f555"; } .mdi-rotate-right:before { content: "\f556"; } .mdi-rotate-right-variant:before { content: "\f557"; } .mdi-router-wireless:before { content: "\f558"; } .mdi-routes:before { content: "\f559"; } .mdi-rss:before { content: "\f55a"; } .mdi-rss-box:before { content: "\f55b"; } .mdi-ruler:before { content: "\f55c"; } .mdi-run:before { content: "\f55d"; } .mdi-sale:before { content: "\f55e"; } .mdi-satellite:before { content: "\f55f"; } .mdi-satellite-variant:before { content: "\f560"; } .mdi-scale:before { content: "\f561"; } .mdi-scale-bathroom:before { content: "\f562"; } .mdi-school:before { content: "\f563"; } .mdi-screen-rotation:before { content: "\f564"; } .mdi-screen-rotation-lock:before { content: "\f565"; } .mdi-screwdriver:before { content: "\f566"; } .mdi-script:before { content: "\f567"; } .mdi-sd:before { content: "\f568"; } .mdi-seal:before { content: "\f569"; } .mdi-seat-flat:before { content: "\f56a"; } .mdi-seat-flat-angled:before { content: "\f56b"; } .mdi-seat-individual-suite:before { content: "\f56c"; } .mdi-seat-legroom-extra:before { content: "\f56d"; } .mdi-seat-legroom-normal:before { content: "\f56e"; } .mdi-seat-legroom-reduced:before { content: "\f56f"; } .mdi-seat-recline-extra:before { content: "\f570"; } .mdi-seat-recline-normal:before { content: "\f571"; } .mdi-security:before { content: "\f572"; } .mdi-security-network:before { content: "\f573"; } .mdi-select:before { content: "\f574"; } .mdi-select-all:before { content: "\f575"; } .mdi-select-inverse:before { content: "\f576"; } .mdi-select-off:before { content: "\f577"; } .mdi-selection:before { content: "\f578"; } .mdi-send:before { content: "\f579"; } .mdi-server:before { content: "\f57a"; } .mdi-server-minus:before { content: "\f57b"; } .mdi-server-network:before { content: "\f57c"; } .mdi-server-network-off:before { content: "\f57d"; } .mdi-server-off:before { content: "\f57e"; } .mdi-server-plus:before { content: "\f57f"; } .mdi-server-remove:before { content: "\f580"; } .mdi-server-security:before { content: "\f581"; } .mdi-settings:before { content: "\f582"; } .mdi-settings-box:before { content: "\f583"; } .mdi-shape-plus:before { content: "\f584"; } .mdi-share:before { content: "\f585"; } .mdi-share-variant:before { content: "\f586"; } .mdi-shield:before { content: "\f587"; } .mdi-shield-outline:before { content: "\f588"; } .mdi-shopping:before { content: "\f589"; } .mdi-shopping-music:before { content: "\f58a"; } .mdi-shredder:before { content: "\f58b"; } .mdi-shuffle:before { content: "\f58c"; } .mdi-shuffle-disabled:before { content: "\f58d"; } .mdi-shuffle-variant:before { content: "\f58e"; } .mdi-sigma:before { content: "\f58f"; } .mdi-sign-caution:before { content: "\f590"; } .mdi-signal:before { content: "\f591"; } .mdi-silverware:before { content: "\f592"; } .mdi-silverware-fork:before { content: "\f593"; } .mdi-silverware-spoon:before { content: "\f594"; } .mdi-silverware-variant:before { content: "\f595"; } .mdi-sim:before { content: "\f596"; } .mdi-sim-alert:before { content: "\f597"; } .mdi-sim-off:before { content: "\f598"; } .mdi-sitemap:before { content: "\f599"; } .mdi-skip-backward:before { content: "\f59a"; } .mdi-skip-forward:before { content: "\f59b"; } .mdi-skip-next:before { content: "\f59c"; } .mdi-skip-previous:before { content: "\f59d"; } .mdi-skype:before { content: "\f59e"; } .mdi-skype-business:before { content: "\f59f"; } .mdi-slack:before { content: "\f5a0"; } .mdi-sleep:before { content: "\f5a1"; } .mdi-sleep-off:before { content: "\f5a2"; } .mdi-smoking:before { content: "\f5a3"; } .mdi-smoking-off:before { content: "\f5a4"; } .mdi-snapchat:before { content: "\f5a5"; } .mdi-snowman:before { content: "\f5a6"; } .mdi-sofa:before { content: "\f5a7"; } .mdi-sort:before { content: "\f5a8"; } .mdi-sort-alphabetical:before { content: "\f5a9"; } .mdi-sort-ascending:before { content: "\f5aa"; } .mdi-sort-descending:before { content: "\f5ab"; } .mdi-sort-numeric:before { content: "\f5ac"; } .mdi-sort-variant:before { content: "\f5ad"; } .mdi-soundcloud:before { content: "\f5ae"; } .mdi-source-fork:before { content: "\f5af"; } .mdi-source-pull:before { content: "\f5b0"; } .mdi-speaker:before { content: "\f5b1"; } .mdi-speaker-off:before { content: "\f5b2"; } .mdi-speedometer:before { content: "\f5b3"; } .mdi-spellcheck:before { content: "\f5b4"; } .mdi-spotify:before { content: "\f5b5"; } .mdi-spotlight:before { content: "\f5b6"; } .mdi-spotlight-beam:before { content: "\f5b7"; } .mdi-square-inc:before { content: "\f5b8"; } .mdi-square-inc-cash:before { content: "\f5b9"; } .mdi-stackoverflow:before { content: "\f5ba"; } .mdi-stairs:before { content: "\f5bb"; } .mdi-star:before { content: "\f5bc"; } .mdi-star-circle:before { content: "\f5bd"; } .mdi-star-half:before { content: "\f5be"; } .mdi-star-off:before { content: "\f5bf"; } .mdi-star-outline:before { content: "\f5c0"; } .mdi-steam:before { content: "\f5c1"; } .mdi-steering:before { content: "\f5c2"; } .mdi-step-backward:before { content: "\f5c3"; } .mdi-step-backward-2:before { content: "\f5c4"; } .mdi-step-forward:before { content: "\f5c5"; } .mdi-step-forward-2:before { content: "\f5c6"; } .mdi-stethoscope:before { content: "\f5c7"; } .mdi-stocking:before { content: "\f5c8"; } .mdi-stop:before { content: "\f5c9"; } .mdi-store:before { content: "\f5ca"; } .mdi-store-24-hour:before { content: "\f5cb"; } .mdi-stove:before { content: "\f5cc"; } .mdi-subway:before { content: "\f5cd"; } .mdi-sunglasses:before { content: "\f5ce"; } .mdi-swap-horizontal:before { content: "\f5cf"; } .mdi-swap-vertical:before { content: "\f5d0"; } .mdi-swim:before { content: "\f5d1"; } .mdi-switch:before { content: "\f5d2"; } .mdi-sword:before { content: "\f5d3"; } .mdi-sync:before { content: "\f5d4"; } .mdi-sync-alert:before { content: "\f5d5"; } .mdi-sync-off:before { content: "\f5d6"; } .mdi-tab:before { content: "\f5d7"; } .mdi-tab-unselected:before { content: "\f5d8"; } .mdi-table:before { content: "\f5d9"; } .mdi-table-column-plus-after:before { content: "\f5da"; } .mdi-table-column-plus-before:before { content: "\f5db"; } .mdi-table-column-remove:before { content: "\f5dc"; } .mdi-table-column-width:before { content: "\f5dd"; } .mdi-table-edit:before { content: "\f5de"; } .mdi-table-large:before { content: "\f5df"; } .mdi-table-row-height:before { content: "\f5e0"; } .mdi-table-row-plus-after:before { content: "\f5e1"; } .mdi-table-row-plus-before:before { content: "\f5e2"; } .mdi-table-row-remove:before { content: "\f5e3"; } .mdi-tablet:before { content: "\f5e4"; } .mdi-tablet-android:before { content: "\f5e5"; } .mdi-tablet-ipad:before { content: "\f5e6"; } .mdi-tag:before { content: "\f5e7"; } .mdi-tag-faces:before { content: "\f5e8"; } .mdi-tag-multiple:before { content: "\f5e9"; } .mdi-tag-outline:before { content: "\f5ea"; } .mdi-tag-text-outline:before { content: "\f5eb"; } .mdi-target:before { content: "\f5ec"; } .mdi-taxi:before { content: "\f5ed"; } .mdi-teamviewer:before { content: "\f5ee"; } .mdi-telegram:before { content: "\f5ef"; } .mdi-television:before { content: "\f5f0"; } .mdi-television-guide:before { content: "\f5f1"; } .mdi-temperature-celsius:before { content: "\f5f2"; } .mdi-temperature-fahrenheit:before { content: "\f5f3"; } .mdi-temperature-kelvin:before { content: "\f5f4"; } .mdi-tennis:before { content: "\f5f5"; } .mdi-tent:before { content: "\f5f6"; } .mdi-terrain:before { content: "\f5f7"; } .mdi-text-to-speech:before { content: "\f5f8"; } .mdi-text-to-speech-off:before { content: "\f5f9"; } .mdi-texture:before { content: "\f5fa"; } .mdi-theater:before { content: "\f5fb"; } .mdi-theme-light-dark:before { content: "\f5fc"; } .mdi-thermometer:before { content: "\f5fd"; } .mdi-thermometer-lines:before { content: "\f5fe"; } .mdi-thumb-down:before { content: "\f5ff"; } .mdi-thumb-down-outline:before { content: "\f600"; } .mdi-thumb-up:before { content: "\f601"; } .mdi-thumb-up-outline:before { content: "\f602"; } .mdi-thumbs-up-down:before { content: "\f603"; } .mdi-ticket:before { content: "\f604"; } .mdi-ticket-account:before { content: "\f605"; } .mdi-ticket-confirmation:before { content: "\f606"; } .mdi-tie:before { content: "\f607"; } .mdi-timelapse:before { content: "\f608"; } .mdi-timer:before { content: "\f609"; } .mdi-timer-10:before { content: "\f60a"; } .mdi-timer-3:before { content: "\f60b"; } .mdi-timer-off:before { content: "\f60c"; } .mdi-timer-sand:before { content: "\f60d"; } .mdi-timetable:before { content: "\f60e"; } .mdi-toggle-switch:before { content: "\f60f"; } .mdi-toggle-switch-off:before { content: "\f610"; } .mdi-tooltip:before { content: "\f611"; } .mdi-tooltip-edit:before { content: "\f612"; } .mdi-tooltip-image:before { content: "\f613"; } .mdi-tooltip-outline:before { content: "\f614"; } .mdi-tooltip-outline-plus:before { content: "\f615"; } .mdi-tooltip-text:before { content: "\f616"; } .mdi-tor:before { content: "\f617"; } .mdi-traffic-light:before { content: "\f618"; } .mdi-train:before { content: "\f619"; } .mdi-tram:before { content: "\f61a"; } .mdi-transcribe:before { content: "\f61b"; } .mdi-transcribe-close:before { content: "\f61c"; } .mdi-transfer:before { content: "\f61d"; } .mdi-tree:before { content: "\f61e"; } .mdi-trello:before { content: "\f61f"; } .mdi-trending-down:before { content: "\f620"; } .mdi-trending-neutral:before { content: "\f621"; } .mdi-trending-up:before { content: "\f622"; } .mdi-triangle:before { content: "\f623"; } .mdi-triangle-outline:before { content: "\f624"; } .mdi-trophy:before { content: "\f625"; } .mdi-trophy-award:before { content: "\f626"; } .mdi-trophy-outline:before { content: "\f627"; } .mdi-trophy-variant:before { content: "\f628"; } .mdi-trophy-variant-outline:before { content: "\f629"; } .mdi-truck:before { content: "\f62a"; } .mdi-truck-delivery:before { content: "\f62b"; } .mdi-tshirt-crew:before { content: "\f62c"; } .mdi-tshirt-v:before { content: "\f62d"; } .mdi-tumblr:before { content: "\f62e"; } .mdi-tumblr-reblog:before { content: "\f62f"; } .mdi-twitch:before { content: "\f630"; } .mdi-twitter:before { content: "\f631"; } .mdi-twitter-box:before { content: "\f632"; } .mdi-twitter-circle:before { content: "\f633"; } .mdi-twitter-retweet:before { content: "\f634"; } .mdi-ubuntu:before { content: "\f635"; } .mdi-umbraco:before { content: "\f636"; } .mdi-umbrella:before { content: "\f637"; } .mdi-umbrella-outline:before { content: "\f638"; } .mdi-undo:before { content: "\f639"; } .mdi-undo-variant:before { content: "\f63a"; } .mdi-unfold-less:before { content: "\f63b"; } .mdi-unfold-more:before { content: "\f63c"; } .mdi-ungroup:before { content: "\f63d"; } .mdi-untappd:before { content: "\f63e"; } .mdi-upload:before { content: "\f63f"; } .mdi-usb:before { content: "\f640"; } .mdi-vector-arrange-above:before { content: "\f641"; } .mdi-vector-arrange-below:before { content: "\f642"; } .mdi-vector-circle:before { content: "\f643"; } .mdi-vector-circle-variant:before { content: "\f644"; } .mdi-vector-combine:before { content: "\f645"; } .mdi-vector-curve:before { content: "\f646"; } .mdi-vector-difference:before { content: "\f647"; } .mdi-vector-difference-ab:before { content: "\f648"; } .mdi-vector-difference-ba:before { content: "\f649"; } .mdi-vector-intersection:before { content: "\f64a"; } .mdi-vector-line:before { content: "\f64b"; } .mdi-vector-point:before { content: "\f64c"; } .mdi-vector-polygon:before { content: "\f64d"; } .mdi-vector-polyline:before { content: "\f64e"; } .mdi-vector-selection:before { content: "\f64f"; } .mdi-vector-square:before { content: "\f650"; } .mdi-vector-triangle:before { content: "\f651"; } .mdi-vector-union:before { content: "\f652"; } .mdi-verified:before { content: "\f653"; } .mdi-vibrate:before { content: "\f654"; } .mdi-video:before { content: "\f655"; } .mdi-video-off:before { content: "\f656"; } .mdi-video-switch:before { content: "\f657"; } .mdi-view-agenda:before { content: "\f658"; } .mdi-view-array:before { content: "\f659"; } .mdi-view-carousel:before { content: "\f65a"; } .mdi-view-column:before { content: "\f65b"; } .mdi-view-dashboard:before { content: "\f65c"; } .mdi-view-day:before { content: "\f65d"; } .mdi-view-grid:before { content: "\f65e"; } .mdi-view-headline:before { content: "\f65f"; } .mdi-view-list:before { content: "\f660"; } .mdi-view-module:before { content: "\f661"; } .mdi-view-quilt:before { content: "\f662"; } .mdi-view-stream:before { content: "\f663"; } .mdi-view-week:before { content: "\f664"; } .mdi-vimeo:before { content: "\f665"; } .mdi-vine:before { content: "\f666"; } .mdi-vk:before { content: "\f667"; } .mdi-vk-box:before { content: "\f668"; } .mdi-vk-circle:before { content: "\f669"; } .mdi-voicemail:before { content: "\f66a"; } .mdi-volume-high:before { content: "\f66b"; } .mdi-volume-low:before { content: "\f66c"; } .mdi-volume-medium:before { content: "\f66d"; } .mdi-volume-off:before { content: "\f66e"; } .mdi-vpn:before { content: "\f66f"; } .mdi-walk:before { content: "\f670"; } .mdi-wallet:before { content: "\f671"; } .mdi-wallet-giftcard:before { content: "\f672"; } .mdi-wallet-membership:before { content: "\f673"; } .mdi-wallet-travel:before { content: "\f674"; } .mdi-wan:before { content: "\f675"; } .mdi-watch:before { content: "\f676"; } .mdi-watch-export:before { content: "\f677"; } .mdi-watch-import:before { content: "\f678"; } .mdi-water:before { content: "\f679"; } .mdi-water-off:before { content: "\f67a"; } .mdi-water-percent:before { content: "\f67b"; } .mdi-water-pump:before { content: "\f67c"; } .mdi-weather-cloudy:before { content: "\f67d"; } .mdi-weather-fog:before { content: "\f67e"; } .mdi-weather-hail:before { content: "\f67f"; } .mdi-weather-lightning:before { content: "\f680"; } .mdi-weather-night:before { content: "\f681"; } .mdi-weather-partlycloudy:before { content: "\f682"; } .mdi-weather-pouring:before { content: "\f683"; } .mdi-weather-rainy:before { content: "\f684"; } .mdi-weather-snowy:before { content: "\f685"; } .mdi-weather-sunny:before { content: "\f686"; } .mdi-weather-sunset:before { content: "\f687"; } .mdi-weather-sunset-down:before { content: "\f688"; } .mdi-weather-sunset-up:before { content: "\f689"; } .mdi-weather-windy:before { content: "\f68a"; } .mdi-weather-windy-variant:before { content: "\f68b"; } .mdi-web:before { content: "\f68c"; } .mdi-webcam:before { content: "\f68d"; } .mdi-weight:before { content: "\f68e"; } .mdi-weight-kilogram:before { content: "\f68f"; } .mdi-whatsapp:before { content: "\f690"; } .mdi-wheelchair-accessibility:before { content: "\f691"; } .mdi-white-balance-auto:before { content: "\f692"; } .mdi-white-balance-incandescent:before { content: "\f693"; } .mdi-white-balance-irradescent:before { content: "\f694"; } .mdi-white-balance-sunny:before { content: "\f695"; } .mdi-wifi:before { content: "\f696"; } .mdi-wifi-off:before { content: "\f697"; } .mdi-wii:before { content: "\f698"; } .mdi-wikipedia:before { content: "\f699"; } .mdi-window-close:before { content: "\f69a"; } .mdi-window-closed:before { content: "\f69b"; } .mdi-window-maximize:before { content: "\f69c"; } .mdi-window-minimize:before { content: "\f69d"; } .mdi-window-open:before { content: "\f69e"; } .mdi-window-restore:before { content: "\f69f"; } .mdi-windows:before { content: "\f6a0"; } .mdi-wordpress:before { content: "\f6a1"; } .mdi-worker:before { content: "\f6a2"; } .mdi-wrap:before { content: "\f6a3"; } .mdi-wrench:before { content: "\f6a4"; } .mdi-wunderlist:before { content: "\f6a5"; } .mdi-xbox:before { content: "\f6a6"; } .mdi-xbox-controller:before { content: "\f6a7"; } .mdi-xbox-controller-off:before { content: "\f6a8"; } .mdi-xda:before { content: "\f6a9"; } .mdi-xing:before { content: "\f6aa"; } .mdi-xing-box:before { content: "\f6ab"; } .mdi-xing-circle:before { content: "\f6ac"; } .mdi-xml:before { content: "\f6ad"; } .mdi-yeast:before { content: "\f6ae"; } .mdi-yelp:before { content: "\f6af"; } .mdi-youtube-play:before { content: "\f6b0"; } .mdi-zip-box:before { content: "\f6b1"; } .mdi-18px { font-size: 18px; } .mdi-24px { font-size: 24px; } .mdi-36px { font-size: 36px; } .mdi-48px { font-size: 48px; } .mdi-dark { color: rgba(0, 0, 0, 0.54); } .mdi-dark.mdi-inactive { color: rgba(0, 0, 0, 0.26); } .mdi-light { color: white; } .mdi-light.mdi-inactive { color: rgba(255, 255, 255, 0.3); } /** + * Title: Bigmug Line Flaticon Pack + * Author: Catalin Fertu + * Source: http://www.flaticon.com/packs/bigmug-line + * License: CC BY 3.0 (http://creativecommons.org/licenses/by/3.0/) + */ @font-face { font-family: "fl-bigmug-line"; src: url("../fonts/fl-bigmug-line.eot"); src: url("../fonts/fl-bigmug-line.eot#iefix") format("embedded-opentype"), url("../fonts/fl-bigmug-line.woff") format("woff"), url("../fonts/fl-bigmug-line.ttf") format("truetype"), url("../fonts/fl-bigmug-line.svg") format("svg"); font-weight: normal; font-style: normal; } .fl-bigmug-line-ico, [class^="fl-bigmug-line-"]:before, [class*=" fl-bigmug-line-"]:before, [class^="fl-bigmug-line-"]:after, [class*=" fl-bigmug-line-"]:after { font-family: 'fl-bigmug-line'; font-size: inherit; font-weight: 400; font-style: normal; } .fl-bigmug-line-add137:before { content: "\e000"; } .fl-bigmug-line-add139:before { content: "\e001"; } .fl-bigmug-line-add149:before { content: "\e002"; } .fl-bigmug-line-airplane86:before { content: "\e003"; } .fl-bigmug-line-alarm31:before { content: "\e004"; } .fl-bigmug-line-arrow592:before { content: "\e005"; } .fl-bigmug-line-attach8:before { content: "\e006"; } .fl-bigmug-line-attachment15:before { content: "\e007"; } .fl-bigmug-line-audio46:before { content: "\e008"; } .fl-bigmug-line-back44:before { content: "\e009"; } .fl-bigmug-line-back46:before { content: "\e00a"; } .fl-bigmug-line-big104:before { content: "\e00b"; } .fl-bigmug-line-book188:before { content: "\e00c"; } .fl-bigmug-line-bookmark28:before { content: "\e00d"; } .fl-bigmug-line-bottle34:before { content: "\e00e"; } .fl-bigmug-line-button5:before { content: "\e00f"; } .fl-bigmug-line-buttons5:before { content: "\e010"; } .fl-bigmug-line-cellphone55:before { content: "\e011"; } .fl-bigmug-line-cellular9:before { content: "\e012"; } .fl-bigmug-line-center10:before { content: "\e013"; } .fl-bigmug-line-chat51:before { content: "\e014"; } .fl-bigmug-line-chat55:before { content: "\e015"; } .fl-bigmug-line-checkmark14:before { content: "\e016"; } .fl-bigmug-line-checkmark15:before { content: "\e017"; } .fl-bigmug-line-checkmark16:before { content: "\e018"; } .fl-bigmug-line-circular220:before { content: "\e019"; } .fl-bigmug-line-circular224:before { content: "\e01a"; } .fl-bigmug-line-circular228:before { content: "\e01b"; } .fl-bigmug-line-circular229:before { content: "\e01c"; } .fl-bigmug-line-clipboard68:before { content: "\e01d"; } .fl-bigmug-line-close42:before { content: "\e01e"; } .fl-bigmug-line-cloud255:before { content: "\e01f"; } .fl-bigmug-line-cloud260:before { content: "\e020"; } .fl-bigmug-line-cocktail26:before { content: "\e021"; } .fl-bigmug-line-code30:before { content: "\e022"; } .fl-bigmug-line-collapse5:before { content: "\e023"; } .fl-bigmug-line-comment45:before { content: "\e024"; } .fl-bigmug-line-compass80:before { content: "\e025"; } .fl-bigmug-line-contract5:before { content: "\e026"; } .fl-bigmug-line-copy23:before { content: "\e027"; } .fl-bigmug-line-crescent23:before { content: "\e028"; } .fl-bigmug-line-cropping1:before { content: "\e029"; } .fl-bigmug-line-cross81:before { content: "\e02a"; } .fl-bigmug-line-cross83:before { content: "\e02b"; } .fl-bigmug-line-cube29:before { content: "\e02c"; } .fl-bigmug-line-double97:before { content: "\e02d"; } .fl-bigmug-line-double98:before { content: "\e02e"; } .fl-bigmug-line-double99:before { content: "\e02f"; } .fl-bigmug-line-down55:before { content: "\e030"; } .fl-bigmug-line-down56:before { content: "\e031"; } .fl-bigmug-line-down58:before { content: "\e032"; } .fl-bigmug-line-down59:before { content: "\e033"; } .fl-bigmug-line-down64:before { content: "\e034"; } .fl-bigmug-line-download136:before { content: "\e035"; } .fl-bigmug-line-download142:before { content: "\e036"; } .fl-bigmug-line-download146:before { content: "\e037"; } .fl-bigmug-line-download147:before { content: "\e038"; } .fl-bigmug-line-download148:before { content: "\e039"; } .fl-bigmug-line-electrical17:before { content: "\e03a"; } .fl-bigmug-line-electronic57:before { content: "\e03b"; } .fl-bigmug-line-email64:before { content: "\e03c"; } .fl-bigmug-line-email67:before { content: "\e03d"; } .fl-bigmug-line-equalization3:before { content: "\e03e"; } .fl-bigmug-line-equalizer26:before { content: "\e03f"; } .fl-bigmug-line-event6:before { content: "\e040"; } .fl-bigmug-line-expand25:before { content: "\e041"; } .fl-bigmug-line-expanding2:before { content: "\e042"; } .fl-bigmug-line-fast33:before { content: "\e043"; } .fl-bigmug-line-favourites5:before { content: "\e044"; } .fl-bigmug-line-file68:before { content: "\e045"; } .fl-bigmug-line-file69:before { content: "\e046"; } .fl-bigmug-line-film57:before { content: "\e047"; } .fl-bigmug-line-flag53:before { content: "\e048"; } .fl-bigmug-line-fog10:before { content: "\e049"; } .fl-bigmug-line-foggy3:before { content: "\e04a"; } .fl-bigmug-line-folder173:before { content: "\e04b"; } .fl-bigmug-line-fork34:before { content: "\e04c"; } .fl-bigmug-line-four87:before { content: "\e04d"; } .fl-bigmug-line-full40:before { content: "\e04e"; } .fl-bigmug-line-games32:before { content: "\e04f"; } .fl-bigmug-line-gear30:before { content: "\e050"; } .fl-bigmug-line-giftbox54:before { content: "\e051"; } .fl-bigmug-line-graphical8:before { content: "\e052"; } .fl-bigmug-line-headphones32:before { content: "\e053"; } .fl-bigmug-line-hot67:before { content: "\e054"; } .fl-bigmug-line-images21:before { content: "\e055"; } .fl-bigmug-line-ink12:before { content: "\e056"; } .fl-bigmug-line-label25:before { content: "\e057"; } .fl-bigmug-line-left144:before { content: "\e058"; } .fl-bigmug-line-left145:before { content: "\e059"; } .fl-bigmug-line-left146:before { content: "\e05a"; } .fl-bigmug-line-left148:before { content: "\e05b"; } .fl-bigmug-line-left152:before { content: "\e05c"; } .fl-bigmug-line-left153:before { content: "\e05d"; } .fl-bigmug-line-left158:before { content: "\e05e"; } .fl-bigmug-line-left159:before { content: "\e05f"; } .fl-bigmug-line-like51:before { content: "\e060"; } .fl-bigmug-line-link52:before { content: "\e061"; } .fl-bigmug-line-list63:before { content: "\e062"; } .fl-bigmug-line-list65:before { content: "\e063"; } .fl-bigmug-line-lock64:before { content: "\e064"; } .fl-bigmug-line-login12:before { content: "\e065"; } .fl-bigmug-line-login9:before { content: "\e066"; } .fl-bigmug-line-map87:before { content: "\e067"; } .fl-bigmug-line-megaphone11:before { content: "\e068"; } .fl-bigmug-line-men25:before { content: "\e069"; } .fl-bigmug-line-menu40:before { content: "\e06a"; } .fl-bigmug-line-menu41:before { content: "\e06b"; } .fl-bigmug-line-microphone76:before { content: "\e06c"; } .fl-bigmug-line-microphone77:before { content: "\e06d"; } .fl-bigmug-line-minus79:before { content: "\e06e"; } .fl-bigmug-line-minus80:before { content: "\e06f"; } .fl-bigmug-line-minus83:before { content: "\e070"; } .fl-bigmug-line-minus86:before { content: "\e071"; } .fl-bigmug-line-monitor74:before { content: "\e072"; } .fl-bigmug-line-music218:before { content: "\e073"; } .fl-bigmug-line-music219:before { content: "\e074"; } .fl-bigmug-line-music221:before { content: "\e075"; } .fl-bigmug-line-musical100:before { content: "\e076"; } .fl-bigmug-line-musical98:before { content: "\e077"; } .fl-bigmug-line-mute34:before { content: "\e078"; } .fl-bigmug-line-new83:before { content: "\e079"; } .fl-bigmug-line-nine16:before { content: "\e07a"; } .fl-bigmug-line-note35:before { content: "\e07b"; } .fl-bigmug-line-notebook41:before { content: "\e07c"; } .fl-bigmug-line-notification4:before { content: "\e07d"; } .fl-bigmug-line-notification5:before { content: "\e07e"; } .fl-bigmug-line-opened25:before { content: "\e07f"; } .fl-bigmug-line-oval34:before { content: "\e080"; } .fl-bigmug-line-paintbrush9:before { content: "\e081"; } .fl-bigmug-line-paper122:before { content: "\e082"; } .fl-bigmug-line-pause37:before { content: "\e083"; } .fl-bigmug-line-pencil85:before { content: "\e084"; } .fl-bigmug-line-phone351:before { content: "\e085"; } .fl-bigmug-line-photo181:before { content: "\e086"; } .fl-bigmug-line-pin42:before { content: "\e087"; } .fl-bigmug-line-planetary2:before { content: "\e088"; } .fl-bigmug-line-play83:before { content: "\e089"; } .fl-bigmug-line-portfolio23:before { content: "\e08a"; } .fl-bigmug-line-print34:before { content: "\e08b"; } .fl-bigmug-line-radio46:before { content: "\e08c"; } .fl-bigmug-line-rain30:before { content: "\e08d"; } .fl-bigmug-line-rectangular78:before { content: "\e08e"; } .fl-bigmug-line-recycling10:before { content: "\e08f"; } .fl-bigmug-line-rewind37:before { content: "\e090"; } .fl-bigmug-line-right139:before { content: "\e091"; } .fl-bigmug-line-right141:before { content: "\e092"; } .fl-bigmug-line-right142:before { content: "\e093"; } .fl-bigmug-line-right144:before { content: "\e094"; } .fl-bigmug-line-right148:before { content: "\e095"; } .fl-bigmug-line-right153:before { content: "\e096"; } .fl-bigmug-line-right154:before { content: "\e097"; } .fl-bigmug-line-right156:before { content: "\e098"; } .fl-bigmug-line-rounded51:before { content: "\e099"; } .fl-bigmug-line-sand14:before { content: "\e09a"; } .fl-bigmug-line-save15:before { content: "\e09b"; } .fl-bigmug-line-search74:before { content: "\e09c"; } .fl-bigmug-line-search78:before { content: "\e09d"; } .fl-bigmug-line-share27:before { content: "\e09e"; } .fl-bigmug-line-shopping198:before { content: "\e09f"; } .fl-bigmug-line-shopping199:before { content: "\e0a0"; } .fl-bigmug-line-shopping202:before { content: "\e0a1"; } .fl-bigmug-line-shopping204:before { content: "\e0a2"; } .fl-bigmug-line-shuffle17:before { content: "\e0a3"; } .fl-bigmug-line-sort47:before { content: "\e0a4"; } .fl-bigmug-line-sort48:before { content: "\e0a5"; } .fl-bigmug-line-speaker75:before { content: "\e0a6"; } .fl-bigmug-line-speaker80:before { content: "\e0a7"; } .fl-bigmug-line-speaker81:before { content: "\e0a8"; } .fl-bigmug-line-speaker86:before { content: "\e0a9"; } .fl-bigmug-line-speaker87:before { content: "\e0aa"; } .fl-bigmug-line-speech96:before { content: "\e0ab"; } .fl-bigmug-line-square152:before { content: "\e0ac"; } .fl-bigmug-line-square156:before { content: "\e0ad"; } .fl-bigmug-line-square160:before { content: "\e0ae"; } .fl-bigmug-line-store10:before { content: "\e0af"; } .fl-bigmug-line-sun81:before { content: "\e0b0"; } .fl-bigmug-line-sunrise3:before { content: "\e0b1"; } .fl-bigmug-line-switch23:before { content: "\e0b2"; } .fl-bigmug-line-switch24:before { content: "\e0b3"; } .fl-bigmug-line-tag47:before { content: "\e0b4"; } .fl-bigmug-line-television20:before { content: "\e0b5"; } .fl-bigmug-line-text108:before { content: "\e0b6"; } .fl-bigmug-line-text109:before { content: "\e0b7"; } .fl-bigmug-line-three142:before { content: "\e0b8"; } .fl-bigmug-line-timer35:before { content: "\e0b9"; } .fl-bigmug-line-tool16:before { content: "\e0ba"; } .fl-bigmug-line-triangle33:before { content: "\e0bb"; } .fl-bigmug-line-trophy55:before { content: "\e0bc"; } .fl-bigmug-line-two311:before { content: "\e0bd"; } .fl-bigmug-line-two316:before { content: "\e0be"; } .fl-bigmug-line-two317:before { content: "\e0bf"; } .fl-bigmug-line-two319:before { content: "\e0c0"; } .fl-bigmug-line-two323:before { content: "\e0c1"; } .fl-bigmug-line-unlocked27:before { content: "\e0c2"; } .fl-bigmug-line-up100:before { content: "\e0c3"; } .fl-bigmug-line-up102:before { content: "\e0c4"; } .fl-bigmug-line-up103:before { content: "\e0c5"; } .fl-bigmug-line-up104:before { content: "\e0c6"; } .fl-bigmug-line-up107:before { content: "\e0c7"; } .fl-bigmug-line-up111:before { content: "\e0c8"; } .fl-bigmug-line-up112:before { content: "\e0c9"; } .fl-bigmug-line-up114:before { content: "\e0ca"; } .fl-bigmug-line-up98:before { content: "\e0cb"; } .fl-bigmug-line-up99:before { content: "\e0cc"; } .fl-bigmug-line-upload91:before { content: "\e0cd"; } .fl-bigmug-line-upload92:before { content: "\e0ce"; } .fl-bigmug-line-upper8:before { content: "\e0cf"; } .fl-bigmug-line-user143:before { content: "\e0d0"; } .fl-bigmug-line-user144:before { content: "\e0d1"; } .fl-bigmug-line-video163:before { content: "\e0d2"; } .fl-bigmug-line-wallet26:before { content: "\e0d3"; } .fl-bigmug-line-weather21:before { content: "\e0d4"; } .fl-bigmug-line-weekly14:before { content: "\e0d5"; } .fl-bigmug-line-weekly15:before { content: "\e0d6"; } .fl-bigmug-line-wind24:before { content: "\e0d7"; } .fl-bigmug-line-window50:before { content: "\e0d8"; } .fl-bigmug-line-winds4:before { content: "\e0d9"; } .fl-bigmug-line-wrench66:before { content: "\e0da"; } .fl-bigmug-line-zoom60:before { content: "\e0db"; } @font-face { font-family: 'Linearicons'; font-weight: normal; font-style: normal; src: url(../fonts/Linearicons.eot); /* For IE6-8 */ src: local("Linearicons"), local("Linearicons"), url(../fonts/Linearicons.woff) format("woff"), url(../fonts/Linearicons.ttf) format("truetype"); } .linearicons, [class^="linearicons-"], [class*=" linearicons-"] { /* use !important to prevent issues with browser extensions that change fonts */ font-family: 'Linearicons' !important; speak: none; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; /* Better Font Rendering =========== */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .linearicons-home:before { content: "\e900"; } .linearicons-home2:before { content: "\e901"; } .linearicons-home3:before { content: "\e902"; } .linearicons-home-icon3:before { content: "\e902"; } .linearicons-home4:before { content: "\e903"; } .linearicons-home5:before { content: "\e904"; } .linearicons-home6:before { content: "\e905"; } .linearicons-bathtub:before { content: "\e906"; } .linearicons-toothbrush:before { content: "\e907"; } .linearicons-bed:before { content: "\e908"; } .linearicons-couch:before { content: "\e909"; } .linearicons-chair:before { content: "\e90a"; } .linearicons-city:before { content: "\e90b"; } .linearicons-apartment:before { content: "\e90c"; } .linearicons-pencil:before { content: "\e90d"; } .linearicons-pencil2:before { content: "\e90e"; } .linearicons-pen:before { content: "\e90f"; } .linearicons-pencil3:before { content: "\e910"; } .linearicons-eraser:before { content: "\e911"; } .linearicons-pencil4:before { content: "\e912"; } .linearicons-pencil5:before { content: "\e913"; } .linearicons-feather:before { content: "\e914"; } .linearicons-feather2:before { content: "\e915"; } .linearicons-feather3:before { content: "\e916"; } .linearicons-pen2:before { content: "\e917"; } .linearicons-pen-add:before { content: "\e918"; } .linearicons-pen-remove:before { content: "\e919"; } .linearicons-vector:before { content: "\e91a"; } .linearicons-pen3:before { content: "\e91b"; } .linearicons-blog:before { content: "\e91c"; } .linearicons-brush:before { content: "\e91d"; } .linearicons-brush2:before { content: "\e91e"; } .linearicons-spray:before { content: "\e91f"; } .linearicons-paint-roller:before { content: "\e920"; } .linearicons-stamp:before { content: "\e921"; } .linearicons-tape:before { content: "\e922"; } .linearicons-desk-tape:before { content: "\e923"; } .linearicons-texture:before { content: "\e924"; } .linearicons-eye-dropper:before { content: "\e925"; } .linearicons-palette:before { content: "\e926"; } .linearicons-color-sampler:before { content: "\e927"; } .linearicons-bucket:before { content: "\e928"; } .linearicons-gradient:before { content: "\e929"; } .linearicons-gradient2:before { content: "\e92a"; } .linearicons-magic-wand:before { content: "\e92b"; } .linearicons-magnet:before { content: "\e92c"; } .linearicons-pencil-ruler:before { content: "\e92d"; } .linearicons-pencil-ruler2:before { content: "\e92e"; } .linearicons-compass:before { content: "\e92f"; } .linearicons-aim:before { content: "\e930"; } .linearicons-gun:before { content: "\e931"; } .linearicons-bottle:before { content: "\e932"; } .linearicons-drop:before { content: "\e933"; } .linearicons-drop-crossed:before { content: "\e934"; } .linearicons-drop2:before { content: "\e935"; } .linearicons-snow:before { content: "\e936"; } .linearicons-snow2:before { content: "\e937"; } .linearicons-fire:before { content: "\e938"; } .linearicons-lighter:before { content: "\e939"; } .linearicons-knife:before { content: "\e93a"; } .linearicons-dagger:before { content: "\e93b"; } .linearicons-tissue:before { content: "\e93c"; } .linearicons-toilet-paper:before { content: "\e93d"; } .linearicons-poop:before { content: "\e93e"; } .linearicons-umbrella:before { content: "\e93f"; } .linearicons-umbrella2:before { content: "\e940"; } .linearicons-rain:before { content: "\e941"; } .linearicons-tornado:before { content: "\e942"; } .linearicons-wind:before { content: "\e943"; } .linearicons-fan:before { content: "\e944"; } .linearicons-contrast:before { content: "\e945"; } .linearicons-sun-small:before { content: "\e946"; } .linearicons-sun:before { content: "\e947"; } .linearicons-sun2:before { content: "\e948"; } .linearicons-moon:before { content: "\e949"; } .linearicons-cloud:before { content: "\e94a"; } .linearicons-cloud-upload:before { content: "\e94b"; } .linearicons-cloud-download:before { content: "\e94c"; } .linearicons-cloud-rain:before { content: "\e94d"; } .linearicons-cloud-hailstones:before { content: "\e94e"; } .linearicons-cloud-snow:before { content: "\e94f"; } .linearicons-cloud-windy:before { content: "\e950"; } .linearicons-sun-wind:before { content: "\e951"; } .linearicons-cloud-fog:before { content: "\e952"; } .linearicons-cloud-sun:before { content: "\e953"; } .linearicons-cloud-lightning:before { content: "\e954"; } .linearicons-cloud-sync:before { content: "\e955"; } .linearicons-cloud-lock:before { content: "\e956"; } .linearicons-cloud-gear:before { content: "\e957"; } .linearicons-cloud-alert:before { content: "\e958"; } .linearicons-cloud-check:before { content: "\e959"; } .linearicons-cloud-cross:before { content: "\e95a"; } .linearicons-cloud-crossed:before { content: "\e95b"; } .linearicons-cloud-database:before { content: "\e95c"; } .linearicons-database:before { content: "\e95d"; } .linearicons-database-add:before { content: "\e95e"; } .linearicons-database-remove:before { content: "\e95f"; } .linearicons-database-lock:before { content: "\e960"; } .linearicons-database-refresh:before { content: "\e961"; } .linearicons-database-check:before { content: "\e962"; } .linearicons-database-history:before { content: "\e963"; } .linearicons-database-upload:before { content: "\e964"; } .linearicons-database-download:before { content: "\e965"; } .linearicons-server:before { content: "\e966"; } .linearicons-shield:before { content: "\e967"; } .linearicons-shield-check:before { content: "\e968"; } .linearicons-shield-alert:before { content: "\e969"; } .linearicons-shield-cross:before { content: "\e96a"; } .linearicons-lock:before { content: "\e96b"; } .linearicons-rotation-lock:before { content: "\e96c"; } .linearicons-unlock:before { content: "\e96d"; } .linearicons-key:before { content: "\e96e"; } .linearicons-key-hole:before { content: "\e96f"; } .linearicons-toggle-off:before { content: "\e970"; } .linearicons-toggle-on:before { content: "\e971"; } .linearicons-cog:before { content: "\e972"; } .linearicons-cog2:before { content: "\e973"; } .linearicons-wrench:before { content: "\e974"; } .linearicons-screwdriver:before { content: "\e975"; } .linearicons-hammer-wrench:before { content: "\e976"; } .linearicons-hammer:before { content: "\e977"; } .linearicons-saw:before { content: "\e978"; } .linearicons-axe:before { content: "\e979"; } .linearicons-axe2:before { content: "\e97a"; } .linearicons-shovel:before { content: "\e97b"; } .linearicons-pickaxe:before { content: "\e97c"; } .linearicons-factory:before { content: "\e97d"; } .linearicons-factory2:before { content: "\e97e"; } .linearicons-recycle:before { content: "\e97f"; } .linearicons-trash:before { content: "\e980"; } .linearicons-trash2:before { content: "\e981"; } .linearicons-trash3:before { content: "\e982"; } .linearicons-broom:before { content: "\e983"; } .linearicons-game:before { content: "\e984"; } .linearicons-gamepad:before { content: "\e985"; } .linearicons-joystick:before { content: "\e986"; } .linearicons-dice:before { content: "\e987"; } .linearicons-spades:before { content: "\e988"; } .linearicons-diamonds:before { content: "\e989"; } .linearicons-clubs:before { content: "\e98a"; } .linearicons-hearts:before { content: "\e98b"; } .linearicons-heart:before { content: "\e98c"; } .linearicons-star:before { content: "\e98d"; } .linearicons-star-half:before { content: "\e98e"; } .linearicons-star-empty:before { content: "\e98f"; } .linearicons-flag:before { content: "\e990"; } .linearicons-flag2:before { content: "\e991"; } .linearicons-flag3:before { content: "\e992"; } .linearicons-mailbox-full:before { content: "\e993"; } .linearicons-mailbox-empty:before { content: "\e994"; } .linearicons-at-sign:before { content: "\e995"; } .linearicons-envelope:before { content: "\e996"; } .linearicons-envelope-open:before { content: "\e997"; } .linearicons-paperclip:before { content: "\e998"; } .linearicons-paper-plane:before { content: "\e999"; } .linearicons-reply:before { content: "\e99a"; } .linearicons-reply-all:before { content: "\e99b"; } .linearicons-inbox:before { content: "\e99c"; } .linearicons-inbox2:before { content: "\e99d"; } .linearicons-outbox:before { content: "\e99e"; } .linearicons-box:before { content: "\e99f"; } .linearicons-archive:before { content: "\e9a0"; } .linearicons-archive2:before { content: "\e9a1"; } .linearicons-drawers:before { content: "\e9a2"; } .linearicons-drawers2:before { content: "\e9a3"; } .linearicons-drawers3:before { content: "\e9a4"; } .linearicons-eye:before { content: "\e9a5"; } .linearicons-eye-crossed:before { content: "\e9a6"; } .linearicons-eye-plus:before { content: "\e9a7"; } .linearicons-eye-minus:before { content: "\e9a8"; } .linearicons-binoculars:before { content: "\e9a9"; } .linearicons-binoculars2:before { content: "\e9aa"; } .linearicons-hdd:before { content: "\e9ab"; } .linearicons-hdd-down:before { content: "\e9ac"; } .linearicons-hdd-up:before { content: "\e9ad"; } .linearicons-floppy-disk:before { content: "\e9ae"; } .linearicons-disc:before { content: "\e9af"; } .linearicons-tape2:before { content: "\e9b0"; } .linearicons-printer:before { content: "\e9b1"; } .linearicons-shredder:before { content: "\e9b2"; } .linearicons-file-empty:before { content: "\e9b3"; } .linearicons-file-add:before { content: "\e9b4"; } .linearicons-file-check:before { content: "\e9b5"; } .linearicons-file-lock:before { content: "\e9b6"; } .linearicons-files:before { content: "\e9b7"; } .linearicons-copy:before { content: "\e9b8"; } .linearicons-compare:before { content: "\e9b9"; } .linearicons-folder:before { content: "\e9ba"; } .linearicons-folder-search:before { content: "\e9bb"; } .linearicons-folder-plus:before { content: "\e9bc"; } .linearicons-folder-minus:before { content: "\e9bd"; } .linearicons-folder-download:before { content: "\e9be"; } .linearicons-folder-upload:before { content: "\e9bf"; } .linearicons-folder-star:before { content: "\e9c0"; } .linearicons-folder-heart:before { content: "\e9c1"; } .linearicons-folder-user:before { content: "\e9c2"; } .linearicons-folder-shared:before { content: "\e9c3"; } .linearicons-folder-music:before { content: "\e9c4"; } .linearicons-folder-picture:before { content: "\e9c5"; } .linearicons-folder-film:before { content: "\e9c6"; } .linearicons-scissors:before { content: "\e9c7"; } .linearicons-paste:before { content: "\e9c8"; } .linearicons-clipboard-empty:before { content: "\e9c9"; } .linearicons-clipboard-pencil:before { content: "\e9ca"; } .linearicons-clipboard-text:before { content: "\e9cb"; } .linearicons-clipboard-check:before { content: "\e9cc"; } .linearicons-clipboard-down:before { content: "\e9cd"; } .linearicons-clipboard-left:before { content: "\e9ce"; } .linearicons-clipboard-alert:before { content: "\e9cf"; } .linearicons-clipboard-user:before { content: "\e9d0"; } .linearicons-register:before { content: "\e9d1"; } .linearicons-enter:before { content: "\e9d2"; } .linearicons-exit:before { content: "\e9d3"; } .linearicons-papers:before { content: "\e9d4"; } .linearicons-news:before { content: "\e9d5"; } .linearicons-reading:before { content: "\e9d6"; } .linearicons-typewriter:before { content: "\e9d7"; } .linearicons-document:before { content: "\e9d8"; } .linearicons-document2:before { content: "\e9d9"; } .linearicons-graduation-hat:before { content: "\e9da"; } .linearicons-license:before { content: "\e9db"; } .linearicons-license2:before { content: "\e9dc"; } .linearicons-medal-empty:before { content: "\e9dd"; } .linearicons-medal-first:before { content: "\e9de"; } .linearicons-medal-second:before { content: "\e9df"; } .linearicons-medal-third:before { content: "\e9e0"; } .linearicons-podium:before { content: "\e9e1"; } .linearicons-trophy:before { content: "\e9e2"; } .linearicons-trophy2:before { content: "\e9e3"; } .linearicons-music-note:before { content: "\e9e4"; } .linearicons-music-note2:before { content: "\e9e5"; } .linearicons-music-note3:before { content: "\e9e6"; } .linearicons-playlist:before { content: "\e9e7"; } .linearicons-playlist-add:before { content: "\e9e8"; } .linearicons-guitar:before { content: "\e9e9"; } .linearicons-trumpet:before { content: "\e9ea"; } .linearicons-album:before { content: "\e9eb"; } .linearicons-shuffle:before { content: "\e9ec"; } .linearicons-repeat-one:before { content: "\e9ed"; } .linearicons-repeat:before { content: "\e9ee"; } .linearicons-headphones:before { content: "\e9ef"; } .linearicons-headset:before { content: "\e9f0"; } .linearicons-loudspeaker:before { content: "\e9f1"; } .linearicons-equalizer:before { content: "\e9f2"; } .linearicons-theater:before { content: "\e9f3"; } .linearicons-3d-glasses:before { content: "\e9f4"; } .linearicons-ticket:before { content: "\e9f5"; } .linearicons-presentation:before { content: "\e9f6"; } .linearicons-play:before { content: "\e9f7"; } .linearicons-film-play:before { content: "\e9f8"; } .linearicons-clapboard-play:before { content: "\e9f9"; } .linearicons-media:before { content: "\e9fa"; } .linearicons-film:before { content: "\e9fb"; } .linearicons-film2:before { content: "\e9fc"; } .linearicons-surveillance:before { content: "\e9fd"; } .linearicons-surveillance2:before { content: "\e9fe"; } .linearicons-camera:before { content: "\e9ff"; } .linearicons-camera-crossed:before { content: "\ea00"; } .linearicons-camera-play:before { content: "\ea01"; } .linearicons-time-lapse:before { content: "\ea02"; } .linearicons-record:before { content: "\ea03"; } .linearicons-camera2:before { content: "\ea04"; } .linearicons-camera-flip:before { content: "\ea05"; } .linearicons-panorama:before { content: "\ea06"; } .linearicons-time-lapse2:before { content: "\ea07"; } .linearicons-shutter:before { content: "\ea08"; } .linearicons-shutter2:before { content: "\ea09"; } .linearicons-face-detection:before { content: "\ea0a"; } .linearicons-flare:before { content: "\ea0b"; } .linearicons-convex:before { content: "\ea0c"; } .linearicons-concave:before { content: "\ea0d"; } .linearicons-picture:before { content: "\ea0e"; } .linearicons-picture2:before { content: "\ea0f"; } .linearicons-picture3:before { content: "\ea10"; } .linearicons-pictures:before { content: "\ea11"; } .linearicons-book:before { content: "\ea12"; } .linearicons-audio-book:before { content: "\ea13"; } .linearicons-book2:before { content: "\ea14"; } .linearicons-bookmark:before { content: "\ea15"; } .linearicons-bookmark2:before { content: "\ea16"; } .linearicons-label:before { content: "\ea17"; } .linearicons-library:before { content: "\ea18"; } .linearicons-library2:before { content: "\ea19"; } .linearicons-contacts:before { content: "\ea1a"; } .linearicons-profile:before { content: "\ea1b"; } .linearicons-portrait:before { content: "\ea1c"; } .linearicons-portrait2:before { content: "\ea1d"; } .linearicons-user:before { content: "\ea1e"; } .linearicons-user-plus:before { content: "\ea1f"; } .linearicons-user-minus:before { content: "\ea20"; } .linearicons-user-lock:before { content: "\ea21"; } .linearicons-users:before { content: "\ea22"; } .linearicons-users2:before { content: "\ea23"; } .linearicons-users-plus:before { content: "\ea24"; } .linearicons-users-minus:before { content: "\ea25"; } .linearicons-group-work:before { content: "\ea26"; } .linearicons-woman:before { content: "\ea27"; } .linearicons-man:before { content: "\ea28"; } .linearicons-baby:before { content: "\ea29"; } .linearicons-baby2:before { content: "\ea2a"; } .linearicons-baby3:before { content: "\ea2b"; } .linearicons-baby-bottle:before { content: "\ea2c"; } .linearicons-walk:before { content: "\ea2d"; } .linearicons-hand-waving:before { content: "\ea2e"; } .linearicons-jump:before { content: "\ea2f"; } .linearicons-run:before { content: "\ea30"; } .linearicons-woman2:before { content: "\ea31"; } .linearicons-man2:before { content: "\ea32"; } .linearicons-man-woman:before { content: "\ea33"; } .linearicons-height:before { content: "\ea34"; } .linearicons-weight:before { content: "\ea35"; } .linearicons-scale:before { content: "\ea36"; } .linearicons-button:before { content: "\ea37"; } .linearicons-bow-tie:before { content: "\ea38"; } .linearicons-tie:before { content: "\ea39"; } .linearicons-socks:before { content: "\ea3a"; } .linearicons-shoe:before { content: "\ea3b"; } .linearicons-shoes:before { content: "\ea3c"; } .linearicons-hat:before { content: "\ea3d"; } .linearicons-pants:before { content: "\ea3e"; } .linearicons-shorts:before { content: "\ea3f"; } .linearicons-flip-flops:before { content: "\ea40"; } .linearicons-shirt:before { content: "\ea41"; } .linearicons-hanger:before { content: "\ea42"; } .linearicons-laundry:before { content: "\ea43"; } .linearicons-store:before { content: "\ea44"; } .linearicons-haircut:before { content: "\ea45"; } .linearicons-store-24:before { content: "\ea46"; } .linearicons-barcode:before { content: "\ea47"; } .linearicons-barcode2:before { content: "\ea48"; } .linearicons-barcode3:before { content: "\ea49"; } .linearicons-cashier:before { content: "\ea4a"; } .linearicons-bag:before { content: "\ea4b"; } .linearicons-bag2:before { content: "\ea4c"; } .linearicons-cart:before { content: "\ea4d"; } .linearicons-cart-empty:before { content: "\ea4e"; } .linearicons-cart-full:before { content: "\ea4f"; } .linearicons-cart-plus:before { content: "\ea50"; } .linearicons-cart-plus2:before { content: "\ea51"; } .linearicons-cart-add:before { content: "\ea52"; } .linearicons-cart-remove:before { content: "\ea53"; } .linearicons-cart-exchange:before { content: "\ea54"; } .linearicons-tag:before { content: "\ea55"; } .linearicons-tags:before { content: "\ea56"; } .linearicons-receipt:before { content: "\ea57"; } .linearicons-wallet:before { content: "\ea58"; } .linearicons-credit-card:before { content: "\ea59"; } .linearicons-cash-dollar:before { content: "\ea5a"; } .linearicons-cash-euro:before { content: "\ea5b"; } .linearicons-cash-pound:before { content: "\ea5c"; } .linearicons-cash-yen:before { content: "\ea5d"; } .linearicons-bag-dollar:before { content: "\ea5e"; } .linearicons-bag-euro:before { content: "\ea5f"; } .linearicons-bag-pound:before { content: "\ea60"; } .linearicons-bag-yen:before { content: "\ea61"; } .linearicons-coin-dollar:before { content: "\ea62"; } .linearicons-coin-euro:before { content: "\ea63"; } .linearicons-coin-pound:before { content: "\ea64"; } .linearicons-coin-yen:before { content: "\ea65"; } .linearicons-calculator:before { content: "\ea66"; } .linearicons-calculator2:before { content: "\ea67"; } .linearicons-abacus:before { content: "\ea68"; } .linearicons-vault:before { content: "\ea69"; } .linearicons-telephone:before { content: "\ea6a"; } .linearicons-phone-lock:before { content: "\ea6b"; } .linearicons-phone-wave:before { content: "\ea6c"; } .linearicons-phone-pause:before { content: "\ea6d"; } .linearicons-phone-outgoing:before { content: "\ea6e"; } .linearicons-phone-incoming:before { content: "\ea6f"; } .linearicons-phone-in-out:before { content: "\ea70"; } .linearicons-phone-error:before { content: "\ea71"; } .linearicons-phone-sip:before { content: "\ea72"; } .linearicons-phone-plus:before { content: "\ea73"; } .linearicons-phone-minus:before { content: "\ea74"; } .linearicons-voicemail:before { content: "\ea75"; } .linearicons-dial:before { content: "\ea76"; } .linearicons-telephone2:before { content: "\ea77"; } .linearicons-pushpin:before { content: "\ea78"; } .linearicons-pushpin2:before { content: "\ea79"; } .linearicons-map-marker:before { content: "\ea7a"; } .linearicons-map-marker-user:before { content: "\ea7b"; } .linearicons-map-marker-down:before { content: "\ea7c"; } .linearicons-map-marker-check:before { content: "\ea7d"; } .linearicons-map-marker-crossed:before { content: "\ea7e"; } .linearicons-radar:before { content: "\ea7f"; } .linearicons-compass2:before { content: "\ea80"; } .linearicons-map:before { content: "\ea81"; } .linearicons-map2:before { content: "\ea82"; } .linearicons-location:before { content: "\ea83"; } .linearicons-road-sign:before { content: "\ea84"; } .linearicons-calendar-empty:before { content: "\ea85"; } .linearicons-calendar-check:before { content: "\ea86"; } .linearicons-calendar-cross:before { content: "\ea87"; } .linearicons-calendar-31:before { content: "\ea88"; } .linearicons-calendar-full:before { content: "\ea89"; } .linearicons-calendar-insert:before { content: "\ea8a"; } .linearicons-calendar-text:before { content: "\ea8b"; } .linearicons-calendar-user:before { content: "\ea8c"; } .linearicons-mouse:before { content: "\ea8d"; } .linearicons-mouse-left:before { content: "\ea8e"; } .linearicons-mouse-right:before { content: "\ea8f"; } .linearicons-mouse-both:before { content: "\ea90"; } .linearicons-keyboard:before { content: "\ea91"; } .linearicons-keyboard-up:before { content: "\ea92"; } .linearicons-keyboard-down:before { content: "\ea93"; } .linearicons-delete:before { content: "\ea94"; } .linearicons-spell-check:before { content: "\ea95"; } .linearicons-escape:before { content: "\ea96"; } .linearicons-enter2:before { content: "\ea97"; } .linearicons-screen:before { content: "\ea98"; } .linearicons-aspect-ratio:before { content: "\ea99"; } .linearicons-signal:before { content: "\ea9a"; } .linearicons-signal-lock:before { content: "\ea9b"; } .linearicons-signal-80:before { content: "\ea9c"; } .linearicons-signal-60:before { content: "\ea9d"; } .linearicons-signal-40:before { content: "\ea9e"; } .linearicons-signal-20:before { content: "\ea9f"; } .linearicons-signal-0:before { content: "\eaa0"; } .linearicons-signal-blocked:before { content: "\eaa1"; } .linearicons-sim:before { content: "\eaa2"; } .linearicons-flash-memory:before { content: "\eaa3"; } .linearicons-usb-drive:before { content: "\eaa4"; } .linearicons-phone:before { content: "\eaa5"; } .linearicons-smartphone:before { content: "\eaa6"; } .linearicons-smartphone-notification:before { content: "\eaa7"; } .linearicons-smartphone-vibration:before { content: "\eaa8"; } .linearicons-smartphone-embed:before { content: "\eaa9"; } .linearicons-smartphone-waves:before { content: "\eaaa"; } .linearicons-tablet:before { content: "\eaab"; } .linearicons-tablet2:before { content: "\eaac"; } .linearicons-laptop:before { content: "\eaad"; } .linearicons-laptop-phone:before { content: "\eaae"; } .linearicons-desktop:before { content: "\eaaf"; } .linearicons-launch:before { content: "\eab0"; } .linearicons-new-tab:before { content: "\eab1"; } .linearicons-window:before { content: "\eab2"; } .linearicons-cable:before { content: "\eab3"; } .linearicons-cable2:before { content: "\eab4"; } .linearicons-tv:before { content: "\eab5"; } .linearicons-radio:before { content: "\eab6"; } .linearicons-remote-control:before { content: "\eab7"; } .linearicons-power-switch:before { content: "\eab8"; } .linearicons-power:before { content: "\eab9"; } .linearicons-power-crossed:before { content: "\eaba"; } .linearicons-flash-auto:before { content: "\eabb"; } .linearicons-lamp:before { content: "\eabc"; } .linearicons-flashlight:before { content: "\eabd"; } .linearicons-lampshade:before { content: "\eabe"; } .linearicons-cord:before { content: "\eabf"; } .linearicons-outlet:before { content: "\eac0"; } .linearicons-battery-power:before { content: "\eac1"; } .linearicons-battery-empty:before { content: "\eac2"; } .linearicons-battery-alert:before { content: "\eac3"; } .linearicons-battery-error:before { content: "\eac4"; } .linearicons-battery-low1:before { content: "\eac5"; } .linearicons-battery-low2:before { content: "\eac6"; } .linearicons-battery-low3:before { content: "\eac7"; } .linearicons-battery-mid1:before { content: "\eac8"; } .linearicons-battery-mid2:before { content: "\eac9"; } .linearicons-battery-mid3:before { content: "\eaca"; } .linearicons-battery-full:before { content: "\eacb"; } .linearicons-battery-charging:before { content: "\eacc"; } .linearicons-battery-charging2:before { content: "\eacd"; } .linearicons-battery-charging3:before { content: "\eace"; } .linearicons-battery-charging4:before { content: "\eacf"; } .linearicons-battery-charging5:before { content: "\ead0"; } .linearicons-battery-charging6:before { content: "\ead1"; } .linearicons-battery-charging7:before { content: "\ead2"; } .linearicons-chip:before { content: "\ead3"; } .linearicons-chip-x64:before { content: "\ead4"; } .linearicons-chip-x86:before { content: "\ead5"; } .linearicons-bubble:before { content: "\ead6"; } .linearicons-bubbles:before { content: "\ead7"; } .linearicons-bubble-dots:before { content: "\ead8"; } .linearicons-bubble-alert:before { content: "\ead9"; } .linearicons-bubble-question:before { content: "\eada"; } .linearicons-bubble-text:before { content: "\eadb"; } .linearicons-bubble-pencil:before { content: "\eadc"; } .linearicons-bubble-picture:before { content: "\eadd"; } .linearicons-bubble-video:before { content: "\eade"; } .linearicons-bubble-user:before { content: "\eadf"; } .linearicons-bubble-quote:before { content: "\eae0"; } .linearicons-bubble-heart:before { content: "\eae1"; } .linearicons-bubble-emoticon:before { content: "\eae2"; } .linearicons-bubble-attachment:before { content: "\eae3"; } .linearicons-phone-bubble:before { content: "\eae4"; } .linearicons-quote-open:before { content: "\eae5"; } .linearicons-quote-close:before { content: "\eae6"; } .linearicons-dna:before { content: "\eae7"; } .linearicons-heart-pulse:before { content: "\eae8"; } .linearicons-pulse:before { content: "\eae9"; } .linearicons-syringe:before { content: "\eaea"; } .linearicons-pills:before { content: "\eaeb"; } .linearicons-first-aid:before { content: "\eaec"; } .linearicons-lifebuoy:before { content: "\eaed"; } .linearicons-bandage:before { content: "\eaee"; } .linearicons-bandages:before { content: "\eaef"; } .linearicons-thermometer:before { content: "\eaf0"; } .linearicons-microscope:before { content: "\eaf1"; } .linearicons-brain:before { content: "\eaf2"; } .linearicons-beaker:before { content: "\eaf3"; } .linearicons-skull:before { content: "\eaf4"; } .linearicons-bone:before { content: "\eaf5"; } .linearicons-construction:before { content: "\eaf6"; } .linearicons-construction-cone:before { content: "\eaf7"; } .linearicons-pie-chart:before { content: "\eaf8"; } .linearicons-pie-chart2:before { content: "\eaf9"; } .linearicons-graph:before { content: "\eafa"; } .linearicons-chart-growth:before { content: "\eafb"; } .linearicons-chart-bars:before { content: "\eafc"; } .linearicons-chart-settings:before { content: "\eafd"; } .linearicons-cake:before { content: "\eafe"; } .linearicons-gift:before { content: "\eaff"; } .linearicons-balloon:before { content: "\eb00"; } .linearicons-rank:before { content: "\eb01"; } .linearicons-rank2:before { content: "\eb02"; } .linearicons-rank3:before { content: "\eb03"; } .linearicons-crown:before { content: "\eb04"; } .linearicons-lotus:before { content: "\eb05"; } .linearicons-diamond:before { content: "\eb06"; } .linearicons-diamond2:before { content: "\eb07"; } .linearicons-diamond3:before { content: "\eb08"; } .linearicons-diamond4:before { content: "\eb09"; } .linearicons-linearicons:before { content: "\eb0a"; } .linearicons-teacup:before { content: "\eb0b"; } .linearicons-teapot:before { content: "\eb0c"; } .linearicons-glass:before { content: "\eb0d"; } .linearicons-bottle2:before { content: "\eb0e"; } .linearicons-glass-cocktail:before { content: "\eb0f"; } .linearicons-glass2:before { content: "\eb10"; } .linearicons-dinner:before { content: "\eb11"; } .linearicons-dinner2:before { content: "\eb12"; } .linearicons-chef:before { content: "\eb13"; } .linearicons-scale2:before { content: "\eb14"; } .linearicons-egg:before { content: "\eb15"; } .linearicons-egg2:before { content: "\eb16"; } .linearicons-eggs:before { content: "\eb17"; } .linearicons-platter:before { content: "\eb18"; } .linearicons-steak:before { content: "\eb19"; } .linearicons-hamburger:before { content: "\eb1a"; } .linearicons-hotdog:before { content: "\eb1b"; } .linearicons-pizza:before { content: "\eb1c"; } .linearicons-sausage:before { content: "\eb1d"; } .linearicons-chicken:before { content: "\eb1e"; } .linearicons-fish:before { content: "\eb1f"; } .linearicons-carrot:before { content: "\eb20"; } .linearicons-cheese:before { content: "\eb21"; } .linearicons-bread:before { content: "\eb22"; } .linearicons-ice-cream:before { content: "\eb23"; } .linearicons-ice-cream2:before { content: "\eb24"; } .linearicons-candy:before { content: "\eb25"; } .linearicons-lollipop:before { content: "\eb26"; } .linearicons-coffee-bean:before { content: "\eb27"; } .linearicons-coffee-cup:before { content: "\eb28"; } .linearicons-cherry:before { content: "\eb29"; } .linearicons-grapes:before { content: "\eb2a"; } .linearicons-citrus:before { content: "\eb2b"; } .linearicons-apple:before { content: "\eb2c"; } .linearicons-leaf:before { content: "\eb2d"; } .linearicons-landscape:before { content: "\eb2e"; } .linearicons-pine-tree:before { content: "\eb2f"; } .linearicons-tree:before { content: "\eb30"; } .linearicons-cactus:before { content: "\eb31"; } .linearicons-paw:before { content: "\eb32"; } .linearicons-footprint:before { content: "\eb33"; } .linearicons-speed-slow:before { content: "\eb34"; } .linearicons-speed-medium:before { content: "\eb35"; } .linearicons-speed-fast:before { content: "\eb36"; } .linearicons-rocket:before { content: "\eb37"; } .linearicons-hammer2:before { content: "\eb38"; } .linearicons-balance:before { content: "\eb39"; } .linearicons-briefcase:before { content: "\eb3a"; } .linearicons-luggage-weight:before { content: "\eb3b"; } .linearicons-dolly:before { content: "\eb3c"; } .linearicons-plane:before { content: "\eb3d"; } .linearicons-plane-crossed:before { content: "\eb3e"; } .linearicons-helicopter:before { content: "\eb3f"; } .linearicons-traffic-lights:before { content: "\eb40"; } .linearicons-siren:before { content: "\eb41"; } .linearicons-road:before { content: "\eb42"; } .linearicons-engine:before { content: "\eb43"; } .linearicons-oil-pressure:before { content: "\eb44"; } .linearicons-coolant-temperature:before { content: "\eb45"; } .linearicons-car-battery:before { content: "\eb46"; } .linearicons-gas:before { content: "\eb47"; } .linearicons-gallon:before { content: "\eb48"; } .linearicons-transmission:before { content: "\eb49"; } .linearicons-car:before { content: "\eb4a"; } .linearicons-car-wash:before { content: "\eb4b"; } .linearicons-car-wash2:before { content: "\eb4c"; } .linearicons-bus:before { content: "\eb4d"; } .linearicons-bus2:before { content: "\eb4e"; } .linearicons-car2:before { content: "\eb4f"; } .linearicons-parking:before { content: "\eb50"; } .linearicons-car-lock:before { content: "\eb51"; } .linearicons-taxi:before { content: "\eb52"; } .linearicons-car-siren:before { content: "\eb53"; } .linearicons-car-wash3:before { content: "\eb54"; } .linearicons-car-wash4:before { content: "\eb55"; } .linearicons-ambulance:before { content: "\eb56"; } .linearicons-truck:before { content: "\eb57"; } .linearicons-trailer:before { content: "\eb58"; } .linearicons-scale-truck:before { content: "\eb59"; } .linearicons-train:before { content: "\eb5a"; } .linearicons-ship:before { content: "\eb5b"; } .linearicons-ship2:before { content: "\eb5c"; } .linearicons-anchor:before { content: "\eb5d"; } .linearicons-boat:before { content: "\eb5e"; } .linearicons-bicycle:before { content: "\eb5f"; } .linearicons-bicycle2:before { content: "\eb60"; } .linearicons-dumbbell:before { content: "\eb61"; } .linearicons-bench-press:before { content: "\eb62"; } .linearicons-swim:before { content: "\eb63"; } .linearicons-football:before { content: "\eb64"; } .linearicons-baseball-bat:before { content: "\eb65"; } .linearicons-baseball:before { content: "\eb66"; } .linearicons-tennis:before { content: "\eb67"; } .linearicons-tennis2:before { content: "\eb68"; } .linearicons-ping-pong:before { content: "\eb69"; } .linearicons-hockey:before { content: "\eb6a"; } .linearicons-8ball:before { content: "\eb6b"; } .linearicons-bowling:before { content: "\eb6c"; } .linearicons-bowling-pins:before { content: "\eb6d"; } .linearicons-golf:before { content: "\eb6e"; } .linearicons-golf2:before { content: "\eb6f"; } .linearicons-archery:before { content: "\eb70"; } .linearicons-slingshot:before { content: "\eb71"; } .linearicons-soccer:before { content: "\eb72"; } .linearicons-basketball:before { content: "\eb73"; } .linearicons-cube:before { content: "\eb74"; } .linearicons-3d-rotate:before { content: "\eb75"; } .linearicons-puzzle:before { content: "\eb76"; } .linearicons-glasses:before { content: "\eb77"; } .linearicons-glasses2:before { content: "\eb78"; } .linearicons-accessibility:before { content: "\eb79"; } .linearicons-wheelchair:before { content: "\eb7a"; } .linearicons-wall:before { content: "\eb7b"; } .linearicons-fence:before { content: "\eb7c"; } .linearicons-wall2:before { content: "\eb7d"; } .linearicons-icons:before { content: "\eb7e"; } .linearicons-resize-handle:before { content: "\eb7f"; } .linearicons-icons2:before { content: "\eb80"; } .linearicons-select:before { content: "\eb81"; } .linearicons-select2:before { content: "\eb82"; } .linearicons-site-map:before { content: "\eb83"; } .linearicons-earth:before { content: "\eb84"; } .linearicons-earth-lock:before { content: "\eb85"; } .linearicons-network:before { content: "\eb86"; } .linearicons-network-lock:before { content: "\eb87"; } .linearicons-planet:before { content: "\eb88"; } .linearicons-happy:before { content: "\eb89"; } .linearicons-smile:before { content: "\eb8a"; } .linearicons-grin:before { content: "\eb8b"; } .linearicons-tongue:before { content: "\eb8c"; } .linearicons-sad:before { content: "\eb8d"; } .linearicons-wink:before { content: "\eb8e"; } .linearicons-dream:before { content: "\eb8f"; } .linearicons-shocked:before { content: "\eb90"; } .linearicons-shocked2:before { content: "\eb91"; } .linearicons-tongue2:before { content: "\eb92"; } .linearicons-neutral:before { content: "\eb93"; } .linearicons-happy-grin:before { content: "\eb94"; } .linearicons-cool:before { content: "\eb95"; } .linearicons-mad:before { content: "\eb96"; } .linearicons-grin-evil:before { content: "\eb97"; } .linearicons-evil:before { content: "\eb98"; } .linearicons-wow:before { content: "\eb99"; } .linearicons-annoyed:before { content: "\eb9a"; } .linearicons-wondering:before { content: "\eb9b"; } .linearicons-confused:before { content: "\eb9c"; } .linearicons-zipped:before { content: "\eb9d"; } .linearicons-grumpy:before { content: "\eb9e"; } .linearicons-mustache:before { content: "\eb9f"; } .linearicons-tombstone-hipster:before { content: "\eba0"; } .linearicons-tombstone:before { content: "\eba1"; } .linearicons-ghost:before { content: "\eba2"; } .linearicons-ghost-hipster:before { content: "\eba3"; } .linearicons-halloween:before { content: "\eba4"; } .linearicons-christmas:before { content: "\eba5"; } .linearicons-easter-egg:before { content: "\eba6"; } .linearicons-mustache2:before { content: "\eba7"; } .linearicons-mustache-glasses:before { content: "\eba8"; } .linearicons-pipe:before { content: "\eba9"; } .linearicons-alarm:before { content: "\ebaa"; } .linearicons-alarm-add:before { content: "\ebab"; } .linearicons-alarm-snooze:before { content: "\ebac"; } .linearicons-alarm-ringing:before { content: "\ebad"; } .linearicons-bullhorn:before { content: "\ebae"; } .linearicons-hearing:before { content: "\ebaf"; } .linearicons-volume-high:before { content: "\ebb0"; } .linearicons-volume-medium:before { content: "\ebb1"; } .linearicons-volume-low:before { content: "\ebb2"; } .linearicons-volume:before { content: "\ebb3"; } .linearicons-mute:before { content: "\ebb4"; } .linearicons-lan:before { content: "\ebb5"; } .linearicons-lan2:before { content: "\ebb6"; } .linearicons-wifi:before { content: "\ebb7"; } .linearicons-wifi-lock:before { content: "\ebb8"; } .linearicons-wifi-blocked:before { content: "\ebb9"; } .linearicons-wifi-mid:before { content: "\ebba"; } .linearicons-wifi-low:before { content: "\ebbb"; } .linearicons-wifi-low2:before { content: "\ebbc"; } .linearicons-wifi-alert:before { content: "\ebbd"; } .linearicons-wifi-alert-mid:before { content: "\ebbe"; } .linearicons-wifi-alert-low:before { content: "\ebbf"; } .linearicons-wifi-alert-low2:before { content: "\ebc0"; } .linearicons-stream:before { content: "\ebc1"; } .linearicons-stream-check:before { content: "\ebc2"; } .linearicons-stream-error:before { content: "\ebc3"; } .linearicons-stream-alert:before { content: "\ebc4"; } .linearicons-communication:before { content: "\ebc5"; } .linearicons-communication-crossed:before { content: "\ebc6"; } .linearicons-broadcast:before { content: "\ebc7"; } .linearicons-antenna:before { content: "\ebc8"; } .linearicons-satellite:before { content: "\ebc9"; } .linearicons-satellite2:before { content: "\ebca"; } .linearicons-mic:before { content: "\ebcb"; } .linearicons-mic-mute:before { content: "\ebcc"; } .linearicons-mic2:before { content: "\ebcd"; } .linearicons-spotlights:before { content: "\ebce"; } .linearicons-hourglass:before { content: "\ebcf"; } .linearicons-loading:before { content: "\ebd0"; } .linearicons-loading2:before { content: "\ebd1"; } .linearicons-loading3:before { content: "\ebd2"; } .linearicons-refresh:before { content: "\ebd3"; } .linearicons-refresh2:before { content: "\ebd4"; } .linearicons-undo:before { content: "\ebd5"; } .linearicons-redo:before { content: "\ebd6"; } .linearicons-jump2:before { content: "\ebd7"; } .linearicons-undo2:before { content: "\ebd8"; } .linearicons-redo2:before { content: "\ebd9"; } .linearicons-sync:before { content: "\ebda"; } .linearicons-repeat-one2:before { content: "\ebdb"; } .linearicons-sync-crossed:before { content: "\ebdc"; } .linearicons-sync2:before { content: "\ebdd"; } .linearicons-repeat-one3:before { content: "\ebde"; } .linearicons-sync-crossed2:before { content: "\ebdf"; } .linearicons-return:before { content: "\ebe0"; } .linearicons-return2:before { content: "\ebe1"; } .linearicons-refund:before { content: "\ebe2"; } .linearicons-history:before { content: "\ebe3"; } .linearicons-history2:before { content: "\ebe4"; } .linearicons-self-timer:before { content: "\ebe5"; } .linearicons-clock:before { content: "\ebe6"; } .linearicons-clock2:before { content: "\ebe7"; } .linearicons-clock3:before { content: "\ebe8"; } .linearicons-watch:before { content: "\ebe9"; } .linearicons-alarm2:before { content: "\ebea"; } .linearicons-alarm-add2:before { content: "\ebeb"; } .linearicons-alarm-remove:before { content: "\ebec"; } .linearicons-alarm-check:before { content: "\ebed"; } .linearicons-alarm-error:before { content: "\ebee"; } .linearicons-timer:before { content: "\ebef"; } .linearicons-timer-crossed:before { content: "\ebf0"; } .linearicons-timer2:before { content: "\ebf1"; } .linearicons-timer-crossed2:before { content: "\ebf2"; } .linearicons-download:before { content: "\ebf3"; } .linearicons-upload:before { content: "\ebf4"; } .linearicons-download2:before { content: "\ebf5"; } .linearicons-upload2:before { content: "\ebf6"; } .linearicons-enter-up:before { content: "\ebf7"; } .linearicons-enter-down:before { content: "\ebf8"; } .linearicons-enter-left:before { content: "\ebf9"; } .linearicons-enter-right:before { content: "\ebfa"; } .linearicons-exit-up:before { content: "\ebfb"; } .linearicons-exit-down:before { content: "\ebfc"; } .linearicons-exit-left:before { content: "\ebfd"; } .linearicons-exit-right:before { content: "\ebfe"; } .linearicons-enter-up2:before { content: "\ebff"; } .linearicons-enter-down2:before { content: "\ec00"; } .linearicons-enter-vertical:before { content: "\ec01"; } .linearicons-enter-left2:before { content: "\ec02"; } .linearicons-enter-right2:before { content: "\ec03"; } .linearicons-enter-horizontal:before { content: "\ec04"; } .linearicons-exit-up2:before { content: "\ec05"; } .linearicons-exit-down2:before { content: "\ec06"; } .linearicons-exit-left2:before { content: "\ec07"; } .linearicons-exit-right2:before { content: "\ec08"; } .linearicons-cli:before { content: "\ec09"; } .linearicons-bug:before { content: "\ec0a"; } .linearicons-code:before { content: "\ec0b"; } .linearicons-file-code:before { content: "\ec0c"; } .linearicons-file-image:before { content: "\ec0d"; } .linearicons-file-zip:before { content: "\ec0e"; } .linearicons-file-audio:before { content: "\ec0f"; } .linearicons-file-video:before { content: "\ec10"; } .linearicons-file-preview:before { content: "\ec11"; } .linearicons-file-charts:before { content: "\ec12"; } .linearicons-file-stats:before { content: "\ec13"; } .linearicons-file-spreadsheet:before { content: "\ec14"; } .linearicons-link:before { content: "\ec15"; } .linearicons-unlink:before { content: "\ec16"; } .linearicons-link2:before { content: "\ec17"; } .linearicons-unlink2:before { content: "\ec18"; } .linearicons-thumbs-up:before { content: "\ec19"; } .linearicons-thumbs-down:before { content: "\ec1a"; } .linearicons-thumbs-up2:before { content: "\ec1b"; } .linearicons-thumbs-down2:before { content: "\ec1c"; } .linearicons-thumbs-up3:before { content: "\ec1d"; } .linearicons-thumbs-down3:before { content: "\ec1e"; } .linearicons-share:before { content: "\ec1f"; } .linearicons-share2:before { content: "\ec20"; } .linearicons-share3:before { content: "\ec21"; } .linearicons-magnifier:before { content: "\ec22"; } .linearicons-file-search:before { content: "\ec23"; } .linearicons-find-replace:before { content: "\ec24"; } .linearicons-zoom-in:before { content: "\ec25"; } .linearicons-zoom-out:before { content: "\ec26"; } .linearicons-loupe:before { content: "\ec27"; } .linearicons-loupe-zoom-in:before { content: "\ec28"; } .linearicons-loupe-zoom-out:before { content: "\ec29"; } .linearicons-cross:before { content: "\ec2a"; } .linearicons-menu:before { content: "\ec2b"; } .linearicons-list:before { content: "\ec2c"; } .linearicons-list2:before { content: "\ec2d"; } .linearicons-list3:before { content: "\ec2e"; } .linearicons-menu2:before { content: "\ec2f"; } .linearicons-list4:before { content: "\ec30"; } .linearicons-menu3:before { content: "\ec31"; } .linearicons-exclamation:before { content: "\ec32"; } .linearicons-question:before { content: "\ec33"; } .linearicons-check:before { content: "\ec34"; } .linearicons-cross2:before { content: "\ec35"; } .linearicons-plus:before { content: "\ec36"; } .linearicons-minus:before { content: "\ec37"; } .linearicons-percent:before { content: "\ec38"; } .linearicons-chevron-up:before { content: "\ec39"; } .linearicons-chevron-down:before { content: "\ec3a"; } .linearicons-chevron-left:before { content: "\ec3b"; } .linearicons-chevron-right:before { content: "\ec3c"; } .linearicons-chevrons-expand-vertical:before { content: "\ec3d"; } .linearicons-chevrons-expand-horizontal:before { content: "\ec3e"; } .linearicons-chevrons-contract-vertical:before { content: "\ec3f"; } .linearicons-chevrons-contract-horizontal:before { content: "\ec40"; } .linearicons-arrow-up:before { content: "\ec41"; } .linearicons-arrow-down:before { content: "\ec42"; } .linearicons-arrow-left:before { content: "\ec43"; } .linearicons-arrow-right:before { content: "\ec44"; } .linearicons-arrow-up-right:before { content: "\ec45"; } .linearicons-arrows-merge:before { content: "\ec46"; } .linearicons-arrows-split:before { content: "\ec47"; } .linearicons-arrow-divert:before { content: "\ec48"; } .linearicons-arrow-return:before { content: "\ec49"; } .linearicons-expand:before { content: "\ec4a"; } .linearicons-contract:before { content: "\ec4b"; } .linearicons-expand2:before { content: "\ec4c"; } .linearicons-contract2:before { content: "\ec4d"; } .linearicons-move:before { content: "\ec4e"; } .linearicons-tab:before { content: "\ec4f"; } .linearicons-arrow-wave:before { content: "\ec50"; } .linearicons-expand3:before { content: "\ec51"; } .linearicons-expand4:before { content: "\ec52"; } .linearicons-contract3:before { content: "\ec53"; } .linearicons-notification:before { content: "\ec54"; } .linearicons-warning:before { content: "\ec55"; } .linearicons-notification-circle:before { content: "\ec56"; } .linearicons-question-circle:before { content: "\ec57"; } .linearicons-menu-circle:before { content: "\ec58"; } .linearicons-checkmark-circle:before { content: "\ec59"; } .linearicons-cross-circle:before { content: "\ec5a"; } .linearicons-plus-circle:before { content: "\ec5b"; } .linearicons-circle-minus:before { content: "\ec5c"; } .linearicons-percent-circle:before { content: "\ec5d"; } .linearicons-arrow-up-circle:before { content: "\ec5e"; } .linearicons-arrow-down-circle:before { content: "\ec5f"; } .linearicons-arrow-left-circle:before { content: "\ec60"; } .linearicons-arrow-right-circle:before { content: "\ec61"; } .linearicons-chevron-up-circle:before { content: "\ec62"; } .linearicons-chevron-down-circle:before { content: "\ec63"; } .linearicons-chevron-left-circle:before { content: "\ec64"; } .linearicons-chevron-right-circle:before { content: "\ec65"; } .linearicons-backward-circle:before { content: "\ec66"; } .linearicons-first-circle:before { content: "\ec67"; } .linearicons-previous-circle:before { content: "\ec68"; } .linearicons-stop-circle:before { content: "\ec69"; } .linearicons-play-circle:before { content: "\ec6a"; } .linearicons-pause-circle:before { content: "\ec6b"; } .linearicons-next-circle:before { content: "\ec6c"; } .linearicons-last-circle:before { content: "\ec6d"; } .linearicons-forward-circle:before { content: "\ec6e"; } .linearicons-eject-circle:before { content: "\ec6f"; } .linearicons-crop:before { content: "\ec70"; } .linearicons-frame-expand:before { content: "\ec71"; } .linearicons-frame-contract:before { content: "\ec72"; } .linearicons-focus:before { content: "\ec73"; } .linearicons-transform:before { content: "\ec74"; } .linearicons-grid:before { content: "\ec75"; } .linearicons-grid-crossed:before { content: "\ec76"; } .linearicons-layers:before { content: "\ec77"; } .linearicons-layers-crossed:before { content: "\ec78"; } .linearicons-toggle:before { content: "\ec79"; } .linearicons-rulers:before { content: "\ec7a"; } .linearicons-ruler:before { content: "\ec7b"; } .linearicons-funnel:before { content: "\ec7c"; } .linearicons-flip-horizontal:before { content: "\ec7d"; } .linearicons-flip-vertical:before { content: "\ec7e"; } .linearicons-flip-horizontal2:before { content: "\ec7f"; } .linearicons-flip-vertical2:before { content: "\ec80"; } .linearicons-angle:before { content: "\ec81"; } .linearicons-angle2:before { content: "\ec82"; } .linearicons-subtract:before { content: "\ec83"; } .linearicons-combine:before { content: "\ec84"; } .linearicons-intersect:before { content: "\ec85"; } .linearicons-exclude:before { content: "\ec86"; } .linearicons-align-center-vertical:before { content: "\ec87"; } .linearicons-align-right:before { content: "\ec88"; } .linearicons-align-bottom:before { content: "\ec89"; } .linearicons-align-left:before { content: "\ec8a"; } .linearicons-align-center-horizontal:before { content: "\ec8b"; } .linearicons-align-top:before { content: "\ec8c"; } .linearicons-square:before { content: "\ec8d"; } .linearicons-plus-square:before { content: "\ec8e"; } .linearicons-minus-square:before { content: "\ec8f"; } .linearicons-percent-square:before { content: "\ec90"; } .linearicons-arrow-up-square:before { content: "\ec91"; } .linearicons-arrow-down-square:before { content: "\ec92"; } .linearicons-arrow-left-square:before { content: "\ec93"; } .linearicons-arrow-right-square:before { content: "\ec94"; } .linearicons-chevron-up-square:before { content: "\ec95"; } .linearicons-chevron-down-square:before { content: "\ec96"; } .linearicons-chevron-left-square:before { content: "\ec97"; } .linearicons-chevron-right-square:before { content: "\ec98"; } .linearicons-check-square:before { content: "\ec99"; } .linearicons-cross-square:before { content: "\ec9a"; } .linearicons-menu-square:before { content: "\ec9b"; } .linearicons-prohibited:before { content: "\ec9c"; } .linearicons-circle:before { content: "\ec9d"; } .linearicons-radio-button:before { content: "\ec9e"; } .linearicons-ligature:before { content: "\ec9f"; } .linearicons-text-format:before { content: "\eca0"; } .linearicons-text-format-remove:before { content: "\eca1"; } .linearicons-text-size:before { content: "\eca2"; } .linearicons-bold:before { content: "\eca3"; } .linearicons-italic:before { content: "\eca4"; } .linearicons-underline:before { content: "\eca5"; } .linearicons-strikethrough:before { content: "\eca6"; } .linearicons-highlight:before { content: "\eca7"; } .linearicons-text-align-left:before { content: "\eca8"; } .linearicons-text-align-center:before { content: "\eca9"; } .linearicons-text-align-right:before { content: "\ecaa"; } .linearicons-text-align-justify:before { content: "\ecab"; } .linearicons-line-spacing:before { content: "\ecac"; } .linearicons-indent-increase:before { content: "\ecad"; } .linearicons-indent-decrease:before { content: "\ecae"; } .linearicons-text-wrap:before { content: "\ecaf"; } .linearicons-pilcrow:before { content: "\ecb0"; } .linearicons-direction-ltr:before { content: "\ecb1"; } .linearicons-direction-rtl:before { content: "\ecb2"; } .linearicons-page-break:before { content: "\ecb3"; } .linearicons-page-break2:before { content: "\ecb4"; } .linearicons-sort-alpha-asc:before { content: "\ecb5"; } .linearicons-sort-alpha-desc:before { content: "\ecb6"; } .linearicons-sort-numeric-asc:before { content: "\ecb7"; } .linearicons-sort-numeric-desc:before { content: "\ecb8"; } .linearicons-sort-amount-asc:before { content: "\ecb9"; } .linearicons-sort-amount-desc:before { content: "\ecba"; } .linearicons-sort-time-asc:before { content: "\ecbb"; } .linearicons-sort-time-desc:before { content: "\ecbc"; } .linearicons-sigma:before { content: "\ecbd"; } .linearicons-pencil-line:before { content: "\ecbe"; } .linearicons-hand:before { content: "\ecbf"; } .linearicons-pointer-up:before { content: "\ecc0"; } .linearicons-pointer-right:before { content: "\ecc1"; } .linearicons-pointer-down:before { content: "\ecc2"; } .linearicons-pointer-left:before { content: "\ecc3"; } .linearicons-finger-tap:before { content: "\ecc4"; } .linearicons-fingers-tap:before { content: "\ecc5"; } .linearicons-reminder:before { content: "\ecc6"; } .linearicons-fingers-crossed:before { content: "\ecc7"; } .linearicons-fingers-victory:before { content: "\ecc8"; } .linearicons-gesture-zoom:before { content: "\ecc9"; } .linearicons-gesture-pinch:before { content: "\ecca"; } .linearicons-fingers-scroll-horizontal:before { content: "\eccb"; } .linearicons-fingers-scroll-vertical:before { content: "\eccc"; } .linearicons-fingers-scroll-left:before { content: "\eccd"; } .linearicons-fingers-scroll-right:before { content: "\ecce"; } .linearicons-hand2:before { content: "\eccf"; } .linearicons-pointer-up2:before { content: "\ecd0"; } .linearicons-pointer-right2:before { content: "\ecd1"; } .linearicons-pointer-down2:before { content: "\ecd2"; } .linearicons-pointer-left2:before { content: "\ecd3"; } .linearicons-finger-tap2:before { content: "\ecd4"; } .linearicons-fingers-tap2:before { content: "\ecd5"; } .linearicons-reminder2:before { content: "\ecd6"; } .linearicons-gesture-zoom2:before { content: "\ecd7"; } .linearicons-gesture-pinch2:before { content: "\ecd8"; } .linearicons-fingers-scroll-horizontal2:before { content: "\ecd9"; } .linearicons-fingers-scroll-vertical2:before { content: "\ecda"; } .linearicons-fingers-scroll-left2:before { content: "\ecdb"; } .linearicons-fingers-scroll-right2:before { content: "\ecdc"; } .linearicons-fingers-scroll-vertical3:before { content: "\ecdd"; } .linearicons-border-style:before { content: "\ecde"; } .linearicons-border-all:before { content: "\ecdf"; } .linearicons-border-outer:before { content: "\ece0"; } .linearicons-border-inner:before { content: "\ece1"; } .linearicons-border-top:before { content: "\ece2"; } .linearicons-border-horizontal:before { content: "\ece3"; } .linearicons-border-bottom:before { content: "\ece4"; } .linearicons-border-left:before { content: "\ece5"; } .linearicons-border-vertical:before { content: "\ece6"; } .linearicons-border-right:before { content: "\ece7"; } .linearicons-border-none:before { content: "\ece8"; } .linearicons-ellipsis:before { content: "\ece9"; } \ No newline at end of file diff --git a/site/client_site/static/css/portal.css b/site/client_site/static/css/portal.css new file mode 100644 index 0000000..10834da --- /dev/null +++ b/site/client_site/static/css/portal.css @@ -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; } diff --git a/site/client_site/static/css/site.css b/site/client_site/static/css/site.css new file mode 100644 index 0000000..d30e184 --- /dev/null +++ b/site/client_site/static/css/site.css @@ -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; } +} diff --git a/site/client_site/static/css/style.css b/site/client_site/static/css/style.css new file mode 100755 index 0000000..d6089c0 --- /dev/null +++ b/site/client_site/static/css/style.css @@ -0,0 +1,239 @@ +@charset "UTF-8"; /* +* Trunk version 2.0.0 +*/ a:focus, button:focus { outline: none !important; } button::-moz-focus-inner { border: 0; } *:focus { outline: none; } blockquote { padding: 0; margin: 0; } input, button, select, textarea { outline: none; } label { margin-bottom: 0; } p { margin: 0; } ul, ol { list-style: none; padding: 0; margin: 0; } ul li, ol li { display: block; } dl { margin: 0; } dt, dd { line-height: inherit; } dt { font-weight: inherit; } dd { margin-bottom: 0; } cite { font-style: normal; } form { margin-bottom: 0; } blockquote { padding-left: 0; border-left: 0; } address { margin-top: 0; margin-bottom: 0; } figure { margin-bottom: 0; } html p a:hover { text-decoration: none; } /* +* +* Typography +*/ body { font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.7; font-weight: 300; color: #74757f; background-color: #fff; -webkit-text-size-adjust: none; -webkit-font-smoothing: subpixel-antialiased; letter-spacing: .03em; } @media (min-width: 768px) { body { font-size: 16px; line-height: 1.5; } } h1, h2, h3, h4, h5, h6, [class^='heading-'] { margin-top: 0; margin-bottom: 0; color: #151515; } h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover, h5 a:hover, h6 a:hover, [class^='heading-'] a:hover { color: #0045b6; } h1 span, h2 span, h3 span, h4 span, h5 span, h6 span, [class^='heading-'] span { display: inline-block; } h1 span[data-toggle='modal'], h2 span[data-toggle='modal'], h3 span[data-toggle='modal'], h4 span[data-toggle='modal'], h5 span[data-toggle='modal'], h6 span[data-toggle='modal'], [class^='heading-'] span[data-toggle='modal'] { border-bottom: 2px dashed rgba(0, 69, 182, 0.3); cursor: pointer; } h1 > span.icon, h2 > span.icon, h3 > span.icon, h4 > span.icon, h5 > span.icon, h6 > span.icon, [class^='heading-'] > span.icon { display: inline-block; margin-right: 5px; } h1, .heading-1 { font-size: 30px; line-height: 1.15; letter-spacing: 0; } @media (min-width: 576px) { h1, .heading-1 { font-size: 32px; } } @media (min-width: 768px) { h1, .heading-1 { font-size: 46px; line-height: 1.25; } } @media (min-width: 992px) { h1, .heading-1 { font-size: 54px; } } @media (min-width: 1200px) { h1, .heading-1 { font-size: 58px; } } @media (min-width: 1600px) { h1, .heading-1 { font-size: 90px; line-height: 0.88889; } } h2, .heading-2 { font-family: "Poppins", sans-serif; font-size: 28px; line-height: 1.4; letter-spacing: 0; } @media (min-width: 576px) { h2, .heading-2 { font-size: 30px; } } @media (min-width: 768px) { h2, .heading-2 { font-size: 44px; } } @media (min-width: 1200px) { h2, .heading-2 { font-size: 50px; line-height: 1.4; } } h3, .heading-3 { font-size: 24px; line-height: 1.3; letter-spacing: 0; } @media (min-width: 768px) { h3, .heading-3 { font-size: 30px; line-height: 1.4; } } @media (min-width: 1200px) { h3, .heading-3 { font-size: 38px; line-height: 1.21053; } } h3 .big, .heading-3 .big { font-size: 2em; line-height: 1; } h4, .heading-4 { font-size: 18px; line-height: 1.5; letter-spacing: 0; } @media (min-width: 1200px) { h4, .heading-4 { font-size: 24px; line-height: 1.33333; } } h4.h4-smaller, .heading-4.h4-smaller { font-size: 19px; } @media (min-width: 1200px) { h4.h4-smaller, .heading-4.h4-smaller { font-size: 22px; line-height: 1.45455; } } h5, .heading-5 { font-size: 16px; line-height: 1.5; letter-spacing: .01em; } @media (min-width: 768px) { h5, .heading-5 { font-size: 22px; line-height: 1.27273; } } h6, .heading-6 { font-family: "Poppins", sans-serif; font-size: 14px; line-height: 1.8; letter-spacing: .2em; text-transform: uppercase; } @media (min-width: 768px) { h6, .heading-6 { font-size: 16px; line-height: 1.5; font-weight: 700; } } .title-decorated { position: relative; padding-left: 40px; text-align: left; } .title-decorated::before { content: ''; position: absolute; left: 0; top: 12px; width: 30px; border-bottom: 1px solid; } .title-docor-text { font-size: 20px; color: #fff; } @media (min-width: 576px) { .title-docor-text { font-size: 24px; } } @media (min-width: 768px) { .title-docor-text { padding-left: 96px; font-size: 37px; } .title-docor-text::before { width: 74px; border-bottom-width: 2px; } } @media (min-width: 768px) { .title-decorated-lg { padding-left: 80px; } .title-decorated-lg::before { width: 62px; } .title-decorated-lg + p { margin-top: 32px; } } small, .small { display: block; font-size: 12px; line-height: 1.5; } mark, .mark { padding: 3px 5px; color: #fff; background: #0045b6; } strong { font-weight: 700; color: #74757f; } .big { font-size: 16px; line-height: 1.55; } @media (min-width: 768px) { .big { font-size: 20px; line-height: 1.6; } } .lead { font-size: 24px; line-height: 34px; font-weight: 300; } code { padding: 4px 5px; border-radius: 0; font-size: 90%; color: #111111; background: #f5f6fa; } .text-large { font-family: "Poppins", sans-serif; font-size: 30px; font-weight: 500; letter-spacing: 0; line-height: 1.2; text-transform: uppercase; } @media (min-width: 576px) { .text-large { font-size: 55px; } } @media (min-width: 768px) { .text-large { font-size: 68px; } } @media (min-width: 992px) { .text-large { font-size: 80px; } } @media (min-width: 1200px) { .text-large { font-size: 100px; } } .context-dark .text-large, .bg-gray-700 .text-large, .bg-gray-800 .text-large, .bg-primary .text-large, .bg-primary-darker .text-large { color: #fff; } .text-extra-large { font-size: 100px; line-height: .8; font-weight: 800; } @media (min-width: 768px) { .text-extra-large { font-size: 130px; line-height: .7; } } @media (min-width: 992px) { .text-extra-large { font-size: 150px; } } @media (min-width: 1600px) { .text-extra-large { font-size: 180px; } } .wow-outer span { display: block; } .wow-outer .wow > span { display: inline; } p [data-toggle='tooltip'] { padding-left: .25em; padding-right: .25em; color: #0045b6; } p [style*='max-width'] { display: inline-block; } p a { color: inherit; } p .text-width-1 { display: inline-block; max-width: 730px; } .text-width-2 { display: inline-block; max-width: 350px; } .heading-subtitle { font-size: 24px; margin-top: 20px; } .about-subtitle { font-size: 20px; line-height: 32px; margin-top: 35px; } html .page .text-primary { color: #0045b6; } .price-property { font-size: 30px; font-family: "Poppins", sans-serif; line-height: 1; } @media (min-width: 768px) { .price-property { font-size: 40px; } } .page .text-danger { color: #dc0000; } .page .text-gray-700 { color: #495057; } .page .text-color-1 { color: #deef7f; } .context-dark .text-gray-700, .bg-gray-700 .text-gray-700, .bg-gray-800 .text-gray-700, .bg-primary .text-gray-700, .bg-primary-darker .text-gray-700 { color: #fff; } /* +* +* Brand +*/ .brand { display: inline-block; } .brand .brand-logo-light { display: none; } .brand .brand-logo-dark { display: block; } /* +* +* Links +*/ a { transition: all 0.3s ease-in-out; } a, a:focus, a:active, a:hover { text-decoration: none; } a, a:focus, a:active { color: #0045b6; } a:hover { color: #00286a; } a[href*='tel'], a[href*='mailto'] { white-space: nowrap; } .link-hover { color: #343db0; } .link-default, .link-default:active, .link-default:focus { color: #74757f; } .link-default:hover { color: #0045b6; } .context-dark .link-default, .bg-gray-700 .link-default, .bg-gray-800 .link-default, .bg-primary .link-default, .bg-primary-darker .link-default { color: #aeb1be; } .context-dark .link-default:hover, .bg-gray-700 .link-default:hover, .bg-gray-800 .link-default:hover, .bg-primary .link-default:hover, .bg-primary-darker .link-default:hover { color: #fff; } .link-image { display: block; max-width: 75%; margin-left: auto; margin-right: auto; opacity: .45; transition: all 0.3s ease-in-out; } .link-image:hover { opacity: 1; } .privacy-link { display: inline-block; color: #4854ed; } * + .privacy-link { margin-top: 25px; } /* +* +* Blocks +*/ .block-center, .block-sm, .block-lg { margin-left: auto; margin-right: auto; } .block-sm { max-width: 560px; } .block-lg { max-width: 768px; } .block-center { padding: 10px; } .block-center:hover .block-center-header { background-color: #0045b6; } .block-center-title { background-color: #fff; } @media (max-width: 1599.98px) { .block-center { padding: 20px; } .block-center:hover .block-center-header { background-color: #fff; } .block-center-header { background-color: #0045b6; } } /* +* +* Boxes +*/ .box-minimal { display: flex; margin-left: -15px; text-align: left; } .box-minimal > * { margin-left: 15px; } .box-chloe__icon { font-size: 26px; } .box-minimal-icon .box-chloe__icon { position: relative; top: 4px; min-width: 26px; font-size: 24px; line-height: 1; color: #0045b6; } .box-minimal-icon .box-chloe__icon.fl-bigmug-line-checkmark14 { font-size: 20px; } .box-minimal-icon .box-chloe__icon .box-chloe__icon { position: relative; top: 4px; min-width: 26px; font-size: 24px; line-height: 1; } .box-minimal-text { width: 100%; max-width: 320px; margin-left: auto; margin-right: auto; } * + .box-minimal { margin-top: 30px; } * + .box-minimal-title { margin-top: 10px; } * + .box-minimal-text { margin-top: 15px; } @media (max-width: 575.98px) { .box-minimal { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (max-width: 767.98px) { .box-minimal * + p { margin-top: 7px; } } @media (min-width: 768px) { .box-minimal { padding-right: 15px; margin-left: -30px; } .box-minimal > * { margin-left: 30px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .box-minimal { padding-right: 0; } .box-minimal-title { font-size: 22px; } } @media (min-width: 1200px) { .box-minimal-icon, .box-chloe__icon { top: 0; font-size: 36px; } .box-minimal-icon .box-chloe__icon, .box-chloe__icon .box-chloe__icon { font-size: 32px; top: 0; } .box-minimal-icon.fl-bigmug-line-checkmark14, .box-chloe__icon.fl-bigmug-line-checkmark14 { font-size: 30px; } } .box-light { text-align: left; } .box-light-icon { font-size: 30px; line-height: 1; color: #0045b6; } * + .box-light-title { margin-top: 10px; } @media (max-width: 575.98px) { .box-light { max-width: 300px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .box-light { max-width: 240px; } .box-chloe__icon { font-size: 32px; } * + .box-light-title { margin-top: 16px; } } @media (min-width: 768px) and (max-width: 1199.98px) { .box-light-title { font-size: 20px; } } @media (min-width: 992px) { .box-light-icon { font-size: 32px; } * + .box-light-title { margin-top: 25px; } } @media (min-width: 1200px) { .box-light-icon { font-size: 40px; } } .box-creative { max-width: 330px; margin-left: auto; margin-right: auto; text-align: center; cursor: default; } .box-creative:hover .box-creative-icon { color: #fff; } .box-creative:hover .box-creative-icon::after { opacity: 1; visibility: visible; transform: translate3d(0, 0, 0); } .box-creative-shadow .box-creative-icon { background: #fff; border: 0; box-shadow: 0 17px 24px 0 rgba(0, 0, 0, 0.05); } .box-creative-icon { position: relative; z-index: 0; display: inline-flex; align-items: center; justify-content: center; width: 2.8em; height: 2.8em; border: 2px solid #e8e9ee; border-radius: 50%; font-size: 24px; line-height: 1; color: #0045b6; } .box-creative-icon::before { position: relative; z-index: 2; } .box-creative-icon::after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: -1; border-radius: inherit; background: #0045b6; border-color: #0045b6; pointer-events: none; opacity: 0; visibility: hidden; transition: .15s ease-in; transform: scale3d(1.05, 1.05, 1.05); } * + .box-creative-icon { margin-top: 10px; } * + .box-creative-title { margin-top: 10px; } @media (min-width: 768px) { .box-creative-icon { font-size: 30px; } * + .box-creative-title { margin-top: 15px; } } @media (max-width: 991.98px) { .box-creative * + p { margin-top: 10px; } } @media (min-width: 1200px) { .box-creative-icon { width: 2.4em; height: 2.4em; font-size: 40px; } * + .box-creative-title { margin-top: 24px; } } .box-1 { max-width: 280px; margin-left: auto; margin-right: auto; } @media (min-width: 768px) { .box-2 { padding-right: 30px; } } @media (min-width: 992px) { .box-2 { padding-right: 35px; } } @media (min-width: 1200px) { .box-2 { padding-right: 100px; } } @media (min-width: 992px) { .box-3 { padding-left: 10px; } } @media (min-width: 1200px) { .box-3 { padding-left: 70px; } } div.box-cta-1 { display: inline-table; text-align: center; vertical-align: middle; margin-bottom: -25px; margin-left: -40px; } div.box-cta-1:empty { margin-bottom: 0; margin-left: 0; } div.box-cta-1 > * { display: inline-block; margin: 0 0 25px 40px; } div.box-cta-1 > * { margin-top: 0; vertical-align: middle; } div.box-cta-thin { display: inline-table; text-align: center; vertical-align: middle; margin-bottom: -25px; margin-left: -30px; } div.box-cta-thin:empty { margin-bottom: 0; margin-left: 0; } div.box-cta-thin > * { display: inline-block; margin: 0 0 25px 30px; } div.box-cta-thin > * { margin-top: 0; vertical-align: middle; } @media (min-width: 768px) and (max-width: 991.98px) { div.box-cta-thin > * { max-width: 500px; } } .box-indigo { position: relative; padding: 5px 0 22px; max-width: 500px; } .box-indigo::before { content: ''; position: absolute; top: 0; right: 100px; bottom: 0; left: 30px; z-index: -1; background: rgba(73, 80, 87, 0.12); pointer-events: none; } .box-indigo p { line-height: 1.35; } .box-indigo-header > * { padding-left: 60px; } .box-indigo-header > * + * { margin-top: 0; } .box-indigo-mark { font-weight: 300; color: #deef7f; } .box-indigo-title, .box-indigo-subtitle { font-weight: 700; letter-spacing: 0; color: #495057; } p.box-indigo-title { position: relative; font-size: 30px; line-height: 1.15; } p.box-indigo-title::before { content: ''; position: absolute; top: 50%; left: 0; transform: translate3d(0, -50%, 0); display: inline-block; width: 37px; height: 0.05em; margin-right: 15px; vertical-align: middle; background: #0045b6; } @media (min-width: 576px) { p.box-indigo-title { font-size: 60px; } } @media (min-width: 768px) { p.box-indigo-title { font-size: 75px; line-height: 1.2; } } p.box-indigo-subtitle { font-size: 24px; line-height: 1.2; } p.box-indigo-subtitle .box-indigo-mark { font-size: 0.85em; } @media (min-width: 576px) { p.box-indigo-subtitle { font-size: 34px; } } @media (min-width: 768px) { p.box-indigo-subtitle { font-size: 40px; } } .context-dark .box-indigo::before, .bg-gray-700 .box-indigo::before, .bg-gray-800 .box-indigo::before, .bg-primary .box-indigo::before, .bg-primary-darker .box-indigo::before { background: rgba(255, 255, 255, 0.12); } .context-dark .box-indigo p, .bg-gray-700 .box-indigo p, .bg-gray-800 .box-indigo p, .bg-primary .box-indigo p, .bg-primary-darker .box-indigo p { color: #aeb1be; } .context-dark p.box-indigo-title, .bg-gray-700 p.box-indigo-title, .bg-gray-800 p.box-indigo-title, .bg-primary p.box-indigo-title, .bg-primary-darker p.box-indigo-title, .context-dark p.box-indigo-subtitle, .bg-gray-700 p.box-indigo-subtitle, .bg-gray-800 p.box-indigo-subtitle, .bg-primary p.box-indigo-subtitle, .bg-primary-darker p.box-indigo-subtitle { color: #fff; } .box-promo { position: relative; display: flex; max-width: 750px; margin-left: auto; margin-right: auto; text-align: left; box-shadow: 0 2px 24px 0 rgba(0, 0, 0, 0.15); transition: .33s ease-in-out; } .box-promo h6, .box-promo .heading-6 { color: #ff747b; } .box-promo:hover { transform: translate(0, -5px); box-shadow: 0 2px 24px 1px rgba(0, 0, 0, 0.2); } .box-promo-mark { position: absolute; top: 14%; right: 6.5%; display: flex; align-items: center; justify-content: center; line-height: 1; border-radius: 50%; color: rgba(0, 69, 182, 0.1); border: 2px solid; pointer-events: none; } .box-promo-mark::before, .box-promo-mark::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); background: rgba(0, 69, 182, 0.1); } .box-promo-mark::before { width: 2px; height: 50%; } .box-promo-mark::after { height: 2px; width: 50%; } .box-promo-inner, .box-promo-dummy { width: 100%; flex-shrink: 0; } .box-promo-inner { display: flex; align-items: center; padding: 30px; } .box-promo-dummy { visibility: hidden; opacity: 0; pointer-events: none; } .box-promo-dummy::before { content: ''; display: block; padding-bottom: 48.53333%; } .box-promo-content { width: 100%; max-width: 500px; } .box-promo-content * + h3, .box-promo-content * + .heading-3 { margin-top: 15px; } .box-promo-content * + p { margin-top: 10px; } .box-promo-content * + .button { margin-top: 30px; } @media (min-width: 768px) { .box-promo-inner { padding: 39px 35px; } } @media (min-width: 1200px) { .box-promo-inner { padding: 60px 70px; } .box-promo-mark { top: 24%; right: 6.5%; width: 120px; height: 120px; } } /* +* Element groups +*/ html .group { margin-bottom: -15px; margin-left: -20px; } html .group:empty { margin-bottom: 0; margin-left: 0; } html .group > * { display: inline-block; margin: 0 0 15px 20px; } @media (min-width: 992px) { html .group { margin-left: -30px; } html .group > * { margin-left: 30px; } } html .group-xs { margin-bottom: -7px; margin-left: -7px; } html .group-xs:empty { margin-bottom: 0; margin-left: 0; } html .group-xs > * { display: inline-block; margin: 0 0 7px 7px; } html .group-sm { margin-bottom: -10px; margin-left: -10px; } html .group-sm:empty { margin-bottom: 0; margin-left: 0; } html .group-sm > * { display: inline-block; margin: 0 0 10px 10px; } html .group-lg { margin-bottom: -25px; margin-left: -40px; } html .group-lg:empty { margin-bottom: 0; margin-left: 0; } html .group-lg > * { display: inline-block; margin: 0 0 25px 40px; } html .group-xl { margin-bottom: -20px; margin-left: -30px; } html .group-xl:empty { margin-bottom: 0; margin-left: 0; } html .group-xl > * { display: inline-block; margin: 0 0 20px 30px; } @media (min-width: 992px) { html .group-xl { margin-bottom: -20px; margin-left: -45px; } html .group-xl > * { margin-bottom: 20px; margin-left: 45px; } } html .group-sm-justify .button { padding-left: 20px; padding-right: 20px; } @media (min-width: 768px) { html .group-sm-justify { display: flex; align-items: center; justify-content: center; } html .group-sm-justify > * { flex-grow: 1; flex-shrink: 0; } html .group-sm-justify .button { min-width: 130px; padding-left: 20px; padding-right: 20px; } } html .group-middle { display: table; vertical-align: middle; } html .group-middle > * { vertical-align: middle; } * + .group { margin-top: 30px; } * + .group-xs { margin-top: 35px; } * + .group-sm { margin-top: 30px; } * + .group-xl { margin-top: 20px; } /* +* +* Responsive units +*/ .unit { display: flex; flex: 0 1 100%; margin-bottom: -30px; margin-left: -20px; } .unit > * { margin-bottom: 30px; margin-left: 20px; } .unit:empty { margin-bottom: 0; margin-left: 0; } .unit-body { flex: 0 1 auto; } .unit-left, .unit-right { flex: 0 0 auto; max-width: 100%; } .unit-spacing-xs { margin-bottom: -15px; margin-left: -7px; } .unit-spacing-xs > * { margin-bottom: 15px; margin-left: 7px; } /* +* +* Lists +*/ .list > li + li { margin-top: 10px; } .list-xs > li + li { margin-top: 8px; } .list-sm > li + li { margin-top: 15px; } .list-md > li + li { margin-top: 18px; } .list-lg > li + li { margin-top: 25px; } .list-xl li + li { margin-top: 30px; } @media (min-width: 1200px) { .list-xl * + p { margin-top: 18px; } } @media (min-width: 1600px) { .list-xl * + p { margin-top: 24px; } } @media (min-width: 768px) { .list-xl > li + li { margin-top: 60px; } } @media (min-width: 1600px) { .list-xl > li + li { margin-top: 78px; } } .list-inline > li { display: inline-block; } html .list-inline-md { transform: translate3d(0, -8px, 0); margin-bottom: -8px; margin-left: -10px; margin-right: -10px; } html .list-inline-md > * { margin-top: 8px; padding-left: 10px; padding-right: 10px; } @media (min-width: 992px) { html .list-inline-md { margin-left: -14px; margin-right: -14px; } html .list-inline-md > * { padding-left: 14px; padding-right: 14px; } } .list-terms dt { font-size: 20px; line-height: 1.3; font-weight: 700; letter-spacing: 0; color: #495057; } .list-terms dt + dd { margin-top: 8px; } .list-terms dd + dt { margin-top: 25px; } * + .list-terms { margin-top: 25px; } .list-terms + .privacy-link { margin-top: 20px; } @media (min-width: 768px) { .list-terms dt { font-size: 24px; } .list-terms dd + dt { margin-top: 40px; } } @media (min-width: 992px) { .list-terms dt + dd { margin-top: 18px; } .list-terms dd + dt { margin-top: 55px; } } @media (min-width: 1200px) { .list-terms dt { max-width: 85%; } } @media (min-width: 1600px) { .list-terms dd + dt { margin-top: 75px; } .list-terms + .privacy-link { margin-top: 75px; } } .index-list { counter-reset: li; } .index-list > li .list-index-counter:before { content: counter(li, decimal-leading-zero); counter-increment: li; } .list-marked { text-align: left; } .list-marked > li::before { position: relative; top: 1px; display: inline-block; min-width: 25px; content: '\f14f'; font: 400 14px 'Material Design Icons'; line-height: inherit; color: #aeb1be; } .list-marked > li + li { margin-top: 9px; } * + .list-marked { margin-top: 15px; } .list-ordered { counter-reset: li; text-align: left; } .list-ordered > li { position: relative; padding-left: 25px; } .list-ordered > li:before { content: counter(li, decimal) "."; counter-increment: li; position: absolute; top: 0; left: 0; display: inline-block; width: 15px; color: #aeb1be; } .list-ordered > li + li { margin-top: 9px; } * + .list-ordered { margin-top: 15px; } .list-nav { transform: translate3d(0, -10px, 0); margin-bottom: -10px; margin-left: -10px; margin-right: -10px; font-family: "Poppins", sans-serif; font-weight: 400; font-size: .875em; letter-spacing: .08em; text-transform: uppercase; color: #495057; } .list-nav > * { margin-top: 10px; padding-left: 10px; padding-right: 10px; } .list-nav li { display: inline-block; vertical-align: middle; } .list-nav a { color: inherit; } .list-nav a:hover { color: #0045b6; } @media (min-width: 768px) { .list-nav { margin-left: -13px; margin-right: -13px; } .list-nav > * { padding-left: 13px; padding-right: 13px; } } @media (min-width: 992px) { .list-nav { margin-left: -28px; margin-right: -28px; } .list-nav > * { padding-left: 28px; padding-right: 28px; } } .context-dark .list-nav, .bg-gray-700 .list-nav, .bg-gray-800 .list-nav, .bg-primary .list-nav, .bg-primary-darker .list-nav { color: #fff; } .context-dark .list-nav a:hover, .bg-gray-700 .list-nav a:hover, .bg-gray-800 .list-nav a:hover, .bg-primary .list-nav a:hover, .bg-primary-darker .list-nav a:hover { color: #aeb1be; } .list-inline-bordered { margin-left: -18px; margin-right: -18px; color: #495057; } .list-inline-bordered > * { padding-left: 18px; padding-right: 18px; } .list-inline-bordered * { color: inherit; } .list-inline-bordered > li > button { background: none; border: none; display: inline-block; padding: 0; outline: none; outline-offset: 0; cursor: pointer; -webkit-appearance: none; transition: .22s; } .list-inline-bordered > li > button::-moz-focus-inner { border: none; padding: 0; } .list-inline-bordered > li { display: inline-block; line-height: 24px; } .list-inline-bordered > li:not(:last-child) { border-right: 1px solid #74757f; } .context-dark .list-inline-bordered, .bg-gray-700 .list-inline-bordered, .bg-gray-800 .list-inline-bordered, .bg-primary .list-inline-bordered, .bg-primary-darker .list-inline-bordered { color: #fff; } .context-dark .list-inline-bordered button.active, .bg-gray-700 .list-inline-bordered button.active, .bg-gray-800 .list-inline-bordered button.active, .bg-primary .list-inline-bordered button.active, .bg-primary-darker .list-inline-bordered button.active, .context-dark .list-inline-bordered button:hover, .bg-gray-700 .list-inline-bordered button:hover, .bg-gray-800 .list-inline-bordered button:hover, .bg-primary .list-inline-bordered button:hover, .bg-primary-darker .list-inline-bordered button:hover { color: #aeb1be; } .list-inline-comma > li { display: inline-block; color: #0045b6; } .list-inline-comma > li:not(:last-child)::after { content: '\002C'; } .list-inline-comma a { color: inherit; } .list-inline-comma a:hover { color: #343db0; } .list-inline-comma-default > li { color: #74757f; } .list-inline-comma-default a:hover { color: #0045b6; } /* +* +* Images +*/ img { display: inline-block; max-width: 100%; height: auto; } .img-responsive { width: 100%; } /* +* +* Icons +*/ .icon { display: inline-block; font-size: 16px; line-height: 1; } .icon::before { position: relative; display: inline-block; font-weight: 400; font-style: normal; speak: none; text-transform: none; } .icon-creative { display: inline-block; text-align: center; width: 2em; height: 2em; line-height: 2em; border-radius: 50%; } .icon-creative, .icon-creative:active, .icon-creative:focus { color: #aeb1be; } .icon-creative::after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: -1; border-radius: inherit; pointer-events: none; opacity: 0; transform: scale3d(0, 0, 0); transition: .22s; } .icon-creative:hover { color: #fff; } .icon-creative:hover::after { opacity: 1; transform: scale3d(1, 1, 1); } .icon-creative[class*='facebook']::after { background: #2059a0; } .icon-creative[class*='twitter']::after { background: #00aae6; } .icon-creative[class*='instagram']::after { background: linear-gradient(to top, #f15823 0%, #a7058e 100%); } .icon-creative[class*='google']::after { background: #ff3807; } .icon-creative[class*='linkedin']::after { background: #2881a8; } .icon-modern { width: 2em; height: 2em; line-height: 2em; text-align: center; border-radius: 50%; border: 1px solid #e8e9ee; color: #151515; } .icon-circle { border-radius: 50%; } .icon-sm { font-size: 20px; } .icon-md { font-size: 1.5em; } .icon-lg { font-size: 1.875em; } /* +* +* Tables custom +*/ .table { width: 100%; max-width: 100%; text-align: left; background: #fff; border-collapse: collapse; } .table th, .table td { color: #495057; vertical-align: middle; } .table th { padding: 13px 24px; border-top: 0; font-size: 16px; font-weight: 300; } .table thead th { border-bottom: 1px solid #aeb1be; } .table td { padding: 23px 24px; } .table tbody tr:first-child td { border-top: 0; } .table tr td { border-bottom: 1px solid #e8e9ee; } .table tfoot td { font-weight: 700; } * + .table-responsive { margin-top: 30px; } @media (min-width: 768px) { * + .table-responsive { margin-top: 40px; } } .table-hover tbody tr { transition: .55s; } .table-hover tbody tr:hover { background: #f5f6fa; } .table-job-positions th, .table-job-positions td { white-space: nowrap; } .table-job-positions td:first-child { width: 8%; padding-right: 6px; text-align: center; } .table-job-positions .position { color: #0045b6; } .table-job-positions time { display: block; white-space: nowrap; } @media (max-width: 575.98px) { .table-job-positions th, .table-job-positions td { padding-left: 12px; padding-right: 12px; } .table-job-positions td { padding-top: 10px; padding-bottom: 10px; } } .table-responsive + .button-lg { margin-top: 30px; } @media (min-width: 1200px) { .table-responsive + .button-lg { margin-top: 40px; } } @media (min-width: 1600px) { .table-responsive + .button-lg { margin-top: 55px; } } /* +* +* Dividers +*/ hr { margin-top: 0; margin-bottom: 0; border-top: 1px solid #e8e9ee; } .context-dark hr, .bg-gray-700 hr, .bg-gray-800 hr, .bg-primary hr, .bg-primary-darker hr { border-top-color: #74757f; } .divider-small { font-size: 0; line-height: 0; } .divider-small::after { content: ''; display: inline-block; width: 70px; height: 2px; background-color: #0045b6; } .divider-small + h3, .divider-small + .heading-3, .divider-small-outer + h3, .divider-small-outer + .heading-3 { margin-top: 20px; } /* +* +* Buttons +*/ .button { position: relative; overflow: hidden; display: inline-block; padding: 13px 35px; font-size: 14px; line-height: 1.25; border: 2px solid; border-radius: 6px; font-family: "Poppins", sans-serif; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; text-overflow: ellipsis; text-align: center; cursor: pointer; vertical-align: middle; user-select: none; transition: 0.25s cubic-bezier(0.2, 1, 0.3, 1); } .form-btn { display: block; width: 100%; height: 100%; } .button-block { display: block; width: 100%; } .button-default, .button-default:focus { color: #aeb1be; background-color: #495057; border-color: #495057; } .button-default:hover, .button-default:active { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button.button-primary, .button.button-primary:focus { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button.button-primary:hover, .button.button-primary:active { color: #fff; background-color: #232426; border-color: #232426; } html .button.button-primary-lighten, html .button.button-primary-lighten:focus { color: #fff; background-color: #0045b6; border-color: #0045b6; } html .button.button-primary-lighten:hover, html .button.button-primary-lighten:active { color: #fff; background-color: #232426; border-color: #232426; } .button-accent-outline, .button-accent-outline:focus { color: #151515; background-color: transparent; border-color: #0045b6; } .button-accent-outline:hover, .button-accent-outline:active { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button-primary-outline, .button-primary-outline:focus { color: #151515; background-color: transparent; border-color: #151515; } .button-primary-outline:hover, .button-primary-outline:active { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button-gray-bordered { border-width: 1px; } .button-gray-bordered, .button-gray-bordered:focus { color: #151515; background-color: transparent; border-color: #aeb1be; } .button-gray-bordered:hover, .button-gray-bordered:active { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button-primary-white, .button-primary-white:focus { color: #111111; background-color: #fff; border-color: transparent; } .button-primary-white:hover, .button-primary-white:active { color: #fff; background-color: #0045b6; border-color: #0045b6; } .button.button-facebook, .button.button-facebook:focus { color: #fff; background-color: #2059a0; border-color: #2059a0; } .button.button-facebook:hover, .button.button-facebook:active { color: #fff; background-color: #2465b5; border-color: #2465b5; } .button.button-twitter, .button.button-twitter:focus { color: #fff; background-color: #00aae6; border-color: #00aae6; } .button.button-twitter:hover, .button.button-twitter:active { color: #fff; background-color: #01bdff; border-color: #01bdff; } .button.button-google, .button.button-google:focus { color: #fff; background-color: #ff3807; border-color: #ff3807; } .button.button-google:hover, .button.button-google:active { color: #fff; background-color: #ff4c21; border-color: #ff4c21; } .button-winona { position: relative; overflow: hidden; display: inline-block; vertical-align: middle; text-align: center; } .button-winona .content-original, .button-winona .content-dubbed { vertical-align: middle; transition: transform 0.3s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .button-winona .content-original { display: block; } .button-winona .content-dubbed { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, 0, 0); } .button-winona:hover .content-original { opacity: 0; transform: translate3d(0, -30%, 0); } .button-winona:hover .content-dubbed { opacity: 1; transform: translate3d(0, 0, 0) translateY(-50%); } .button-sm { padding: 9px 32px; font-size: 12px; line-height: 1.5; } .button-lg { padding: 14px 35px; font-size: 15px; line-height: 1.5; } @media (min-width: 576px) { .button-lg { font-size: 16px; padding-left: 55px; padding-right: 55px; } } .button-lg--smaller { padding-left: 35px; padding-right: 35px; } html .button.button-icon { display: inline-flex; } .button-icon { justify-content: center; align-items: center; vertical-align: middle; } .button-icon .icon { position: relative; top: -1px; display: inline-block; vertical-align: middle; color: inherit; font-size: 1.55em; line-height: 1em; transition: none; } .button-icon:hover .icon { color: inherit; } .button-icon-left .icon { padding-right: 5px; } .button-icon-right { flex-direction: row-reverse; } .button-icon-right .icon { padding-left: 8px; } .button-icon.button-icon-only { padding-left: 20px; padding-right: 20px; } .button-icon.button-lg .icon { padding-right: 8px; } .button-icon.button-lg.button-icon-right .icon { padding: 0 0 0 8px; } .button-icon.button-sm .icon { padding-right: 3px; font-size: 1.33em; } .button-icon.button-sm.button-icon-right .icon { padding: 0 0 0 3px; } .btn-primary { border-radius: 3px; font-family: "Poppins", sans-serif; font-weight: 500; letter-spacing: .05em; text-transform: uppercase; transition: .33s; } .btn-primary, .btn-primary:active, .btn-primary:focus { color: #fff; background: #0045b6; border-color: #0045b6; } .btn-primary:hover { color: #fff; background: #000; border-color: #000; } .button-group { display: flex; } .button-group .button { margin: 0; flex-grow: 1; } .button-group .button:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } .button-group .button:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } .button-video { position: relative; display: block; border-radius: 50%; transition: .33s; color: #495057; } .button-video::before, .button-video:after { display: block; content: '\f4fc'; font-family: 'Material Design Icons'; font-size: 64px; line-height: 1; border-radius: 50%; } .button-video::before { z-index: -1; pointer-events: none; } .button-video::after { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, -50%, 0); transition: opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .button-video:hover { color: #0045b6; } .button-video:hover::before { opacity: 0; transition: transform 0.9s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); transform: scale3d(1.5, 1.5, 1.5); } .button-video:hover::after { opacity: 1; } .button-video-lg::before, .button-video-lg:after { font-size: 90px; } .context-dark .button-video, .bg-gray-700 .button-video, .bg-gray-800 .button-video, .bg-primary .button-video, .bg-primary-darker .button-video { color: #fff; } /* +* +* Form styles +*/ .rd-form { position: relative; text-align: left; } .rd-form .button { min-height: 48px; } .rd-form .button-icon-only { padding-top: 11px; padding-bottom: 11px; } * + .rd-form { margin-top: 20px; } input:-webkit-autofill ~ label, input:-webkit-autofill ~ .form-validation { color: #000 !important; } .form-wrap { position: relative; } .form-wrap.has-error .form-input { border-color: #dc0000; } .form-wrap.has-focus .form-input { border-color: #0045b6; } * + .form-wrap { margin-top: 24px; } .form-input { display: block; width: 100%; min-height: 48px; padding: 11px 19px; font-size: 16px; font-weight: 400; line-height: 1.5; color: #495057; background-color: #fff; background-image: none; border-radius: 6px; -webkit-appearance: none; transition: .3s ease-in-out; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; border: 1px solid #c5c6cd; } .form-input:focus { outline: 0; } textarea.form-input { height: 150px; min-height: 48px; max-height: 255px; resize: vertical; } .form-label, .form-label-outside { margin-bottom: 0; font-weight: 300; } .form-label { position: absolute; top: 24px; left: 0; right: 0; padding-left: 19px; padding-right: 19px; font-size: 16px; font-weight: 400; line-height: 1.5; color: #495057; pointer-events: none; text-align: left; z-index: 9; transition: .25s; will-change: transform; transform: translateY(-50%); } .form-label.focus { opacity: 0; } .form-label.auto-fill { color: #495057; } .form-label-outside { width: 100%; padding-right: 150px; margin-bottom: 4px; font-size: 14px; color: #aeb1be; cursor: pointer; } .form-label-outside ~ .form-validation { top: 10px; } [data-x-mode='true'] .form-label { pointer-events: auto; } .form-validation { position: absolute; right: 15px; top: 0; z-index: 11; margin-top: 3px; font-size: 10px; font-weight: 500; line-height: 12px; letter-spacing: 0; color: #dc0000; transition: .3s; transform: scale3d(1, 1, 1); } .form-validation:empty { transform: scale3d(1, 0, 1); } .form-validation-left .form-validation { top: 2px; bottom: auto; right: auto; left: 14px; } #form-output-global { position: fixed; bottom: 30px; left: 15px; z-index: 2000; visibility: hidden; transform: translate3d(-500px, 0, 0); transition: .3s all ease; } #form-output-global.active { visibility: visible; transform: translate3d(0, 0, 0); } @media (min-width: 576px) { #form-output-global { left: 30px; } } .form-output { position: absolute; top: 100%; left: 0; font-size: 10px; font-weight: 400; line-height: 1.2; margin-top: 2px; transition: .3s; opacity: 0; visibility: hidden; } .form-output.active { opacity: 1; visibility: visible; } .form-output.error { color: #dc0000; } .form-output.success { color: #98bf44; } .radio .radio-custom, .radio-inline .radio-custom, .checkbox .checkbox-custom, .checkbox-inline .checkbox-custom { opacity: 0; } .radio .radio-custom, .radio .radio-custom-dummy, .radio-inline .radio-custom, .radio-inline .radio-custom-dummy, .checkbox .checkbox-custom, .checkbox .checkbox-custom-dummy, .checkbox-inline .checkbox-custom, .checkbox-inline .checkbox-custom-dummy { position: absolute; left: 0; width: 14px; height: 14px; outline: none; cursor: pointer; } .radio .radio-custom-dummy, .radio-inline .radio-custom-dummy, .checkbox .checkbox-custom-dummy, .checkbox-inline .checkbox-custom-dummy { pointer-events: none; background: #fff; } .radio .radio-custom-dummy::after, .radio-inline .radio-custom-dummy::after, .checkbox .checkbox-custom-dummy::after, .checkbox-inline .checkbox-custom-dummy::after { position: absolute; opacity: 0; transition: .22s; } .radio .radio-custom:focus, .radio-inline .radio-custom:focus, .checkbox .checkbox-custom:focus, .checkbox-inline .checkbox-custom:focus { outline: none; } .radio input, .radio-inline input, .checkbox input, .checkbox-inline input { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; } .radio-custom:checked + .radio-custom-dummy:after, .checkbox-custom:checked + .checkbox-custom-dummy:after { opacity: 1; } .radio, .radio-inline { padding-left: 28px; } .radio .radio-custom-dummy, .radio-inline .radio-custom-dummy { top: 1px; left: 0; width: 18px; height: 18px; border-radius: 50%; border: 1px solid #74757f; } .radio .radio-custom-dummy::after, .radio-inline .radio-custom-dummy::after { content: ''; top: 3px; right: 3px; bottom: 3px; left: 3px; background: #495057; border-radius: inherit; } .checkbox, .checkbox-inline { padding-left: 28px; } .checkbox .checkbox-custom-dummy, .checkbox-inline .checkbox-custom-dummy { left: 0; width: 18px; height: 18px; margin: 0; border: 1px solid #74757f; } .checkbox .checkbox-custom-dummy::after, .checkbox-inline .checkbox-custom-dummy::after { content: '\f222'; font-family: "Material Design Icons"; position: absolute; top: -1px; left: -2px; font-size: 20px; line-height: 18px; color: #aeb1be; } .toggle-custom { padding-left: 60px; -webkit-appearance: none; } .toggle-custom:checked ~ .checkbox-custom-dummy::after { background: #0045b6; transform: translate(20px, -50%); } .toggle-custom ~ .checkbox-custom-dummy { position: relative; display: inline-block; margin-top: -1px; width: 44px; height: 20px; background: #fff; cursor: pointer; } .toggle-custom ~ .checkbox-custom-dummy::after { content: ''; position: absolute; display: inline-block; width: 16px; height: 16px; left: 0; top: 50%; background: #74757f; transform: translate(4px, -50%); opacity: 1; transition: .22s; } .form-inline { display: flex; flex-wrap: wrap; align-items: stretch; text-align: center; margin-bottom: -8px; } .form-inline > * { margin-bottom: 8px; } .form-inline > * { margin-top: 0; margin-right: -6px; margin-left: -6px; } .form-inline .form-wrap { flex: 20 0; min-width: 220px; } .form-inline .form-wrap-select { text-align: left; } .form-inline .form-label { display: block; } .form-inline .form-input { border-color: #fff; } .form-inline .form-button { flex-grow: 1; min-height: 48px; } .form-inline .form-button .button { width: 100%; min-height: inherit; } @media (min-width: 576px) { .form-inline .button { display: block; } } .form-inline.form-inline-centered { justify-content: center; } .form-sm .form-input, .form-sm .button { padding-top: 9px; padding-bottom: 9px; min-height: 40px; } .form-sm .form-validation { top: -16px; } .form-sm .form-label { top: 20px; } .form-sm * + .button { margin-top: 10px; } .form-lg .form-input, .form-lg .select2-container--default .select2-selection--single .select2-selection__rendered { padding-top: 15px; padding-bottom: 15px; } .form-lg .form-input, .form-lg .select2-container--default .select2-selection--single .select2-selection__rendered, .form-lg .form-wrap-button { min-height: 56px; } .form-lg .form-label { top: 28px; } .form-layout-1 { margin-bottom: -20px; margin-left: -20px; } .form-layout-1:empty { margin-bottom: 0; margin-left: 0; } .form-layout-1 > * { display: inline-block; margin: 0 0 20px 20px; } .form-layout-1 > * { display: block; } .form-layout-1 .form-wrap-button { display: flex; } .form-layout-1 .button { display: block; width: 100%; } * + form.form-layout-1 { margin-top: 35px; } @media (min-width: 768px) { .form-layout-1 { display: flex; } .form-layout-1 > * { flex-shrink: 0; } .form-layout-1 .form-wrap-main { flex-grow: 1; } .form-layout-1 .form-wrap-select { width: 100%; max-width: 220px; } } @media (min-width: 992px) { * + form.form-layout-1 { margin-top: 50px; } } .schedule-form { margin-top: 0; } @media (min-width: 1200px) { .schedule-form { margin-top: 35px; } } .schedule-form .form-wrap { margin-top: 12px; } .schedule-form .form-wrap input { background-color: #f5f6fa; border-color: #e8e9ee; } .schedule-form .form-wrap input ::placeholder { color: #151515; opacity: 1; } .schedule-form .form-wrap input ::placeholder :-ms-input-placeholder { color: #151515; } /* +* +* Posts +*/ .post-inline { max-width: 313px; } .post-inline-title a { color: inherit; } .post-inline-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -10px; margin-right: -10px; color: rgba(255, 255, 255, 0.3); } .post-inline-meta > * { margin-top: 5px; padding-left: 10px; padding-right: 10px; } .post-inline-meta > li { position: relative; display: inline-block; } .post-inline-meta > li:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; width: 1px; height: 1px; border-radius: 50%; background: #495057; } .post-inline-meta a { color: inherit; } * + .post-inline { margin-top: 15px; } * + .post-inline-meta { margin-top: 5px; } @media (min-width: 768px) { * + .post-inline { margin-top: 22px; } } .context-dark .post-inline-title, .bg-gray-700 .post-inline-title, .bg-gray-800 .post-inline-title, .bg-primary .post-inline-title, .bg-primary-darker .post-inline-title { color: #fff; } .context-dark .post-inline-title a:hover, .bg-gray-700 .post-inline-title a:hover, .bg-gray-800 .post-inline-title a:hover, .bg-primary .post-inline-title a:hover, .bg-primary-darker .post-inline-title a:hover { color: #aeb1be; } .context-dark .post-inline-meta > li:not(:last-child)::after, .bg-gray-700 .post-inline-meta > li:not(:last-child)::after, .bg-gray-800 .post-inline-meta > li:not(:last-child)::after, .bg-primary .post-inline-meta > li:not(:last-child)::after, .bg-primary-darker .post-inline-meta > li:not(:last-child)::after { background: #aeb1be; } .context-dark .post-inline-meta a:hover, .bg-gray-700 .post-inline-meta a:hover, .bg-gray-800 .post-inline-meta a:hover, .bg-primary .post-inline-meta a:hover, .bg-primary-darker .post-inline-meta a:hover { color: #fff; } .post-classic { text-align: left; } .post-classic-media { position: relative; display: block; } .post-classic-media::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.4); transition: .4s ease-in-out; opacity: 0; visibility: hidden; pointer-events: none; } .post-classic-media img { width: 100%; } .post-classic-media:hover::before { opacity: 1; visibility: visible; } .post-classic-meta { transform: translate3d(0, -3px, 0); margin-bottom: -3px; margin-left: -14px; margin-right: -14px; color: #aeb1be; } .post-classic-meta > * { margin-top: 3px; padding-left: 14px; padding-right: 14px; } .post-classic-meta > li { position: relative; display: inline-block; } .post-classic-meta > li:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; width: 2px; height: 2px; background: #aeb1be; transform: translate3d(0, -50%, 0); } .post-classic-meta a:hover { color: #343db0; } * + .post-classic-meta { margin-top: 15px; } * + .post-classic-title { margin-top: 9px; } @media (max-width: 575.98px) { .post-classic { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 576px) and (max-width: 767.98px) { .post-classic-title { font-size: 17px; } } @media (min-width: 768px) { .post-classic-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -20px; margin-right: -20px; } .post-classic-meta > * { margin-top: 5px; padding-left: 20px; padding-right: 20px; } * + .post-classic-meta { margin-top: 25px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .post-classic-meta { margin-left: -15px; margin-right: -15px; font-size: 14px; } .post-classic-meta > * { padding-left: 15px; padding-right: 15px; } } .post-modern { text-align: left; } .post-modern-title { color: #151515; font-weight: 400; } .post-modern-title a:hover { color: #0045b6; } .post-modern-media { position: relative; display: block; } .post-modern-media::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.4); transition: .22s ease-in-out; opacity: 0; visibility: hidden; pointer-events: none; } .post-modern-media img { width: 100%; } .post-modern-media:hover::before { opacity: 1; visibility: visible; } .post-modern-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -15px; margin-right: -15px; color: #495057; font-weight: 500; } .post-modern-meta > * { margin-top: 5px; padding-left: 15px; padding-right: 15px; } .post-modern-meta > li { position: relative; display: inline-block; line-height: 24px; font-size: 18px; } .post-modern-meta > li:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; width: 1px; height: 1.5em; background: #e8e9ee; transform: translate3d(0, -50%, 0); } .post-modern-meta a:hover { color: #343db0; } .post-modern-meta + p { color: #151515; } * + .post-modern-title { margin-top: 9px; } * + .post-modern-meta { margin-top: 10px; } @media (max-width: 575.98px) { .post-modern { max-width: 430px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .post-modern-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -20px; margin-right: -20px; } .post-modern-meta > * { margin-top: 5px; padding-left: 20px; padding-right: 20px; } * + .post-modern-title { margin-top: 15px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .post-modern-meta { margin-left: -15px; margin-right: -15px; font-size: 14px; } .post-modern-meta > * { padding-left: 15px; padding-right: 15px; } } @media (min-width: 1200px) { * + .post-modern-title { margin-top: 25px; } * + .post-modern-meta { margin-top: 15px; } } .post-block { position: relative; z-index: 1; display: flex; overflow: hidden; max-width: 640px; border-radius: 6px; text-align: left; } .post-block::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; opacity: .3; background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, #000 100%); } .post-block::after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none; border-radius: inherit; background: rgba(0, 0, 0, 0.5); z-index: 1; opacity: 0; visibility: hidden; transition: 1s ease-in-out; } .post-block:hover::after { opacity: 1; visibility: visible; } .post-block > * { width: 100%; flex-shrink: 0; } .post-block-dummy { width: 100%; } .post-block-dummy::before { content: ''; display: block; visibility: hidden; pointer-events: none; padding-bottom: 71.58556%; } .post-block-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .post-block-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .post-block-caption { position: relative; z-index: 2; display: flex; flex-direction: column; justify-content: flex-end; padding: 20px; } .post-block-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -20px; margin-right: -20px; color: #495057; } .post-block-meta > * { margin-top: 5px; padding-left: 20px; padding-right: 20px; } .post-block-meta > li { position: relative; display: inline-block; line-height: 24px; } .post-block-meta > li:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; width: 1px; height: 1.5em; background: #e8e9ee; transform: translate3d(0, -50%, 0); } .post-block-meta a { color: inherit; } .post-block-meta a:hover { color: #0045b6; } .post-block-meta time { display: block; } .context-dark .post-block-meta, .bg-gray-700 .post-block-meta, .bg-gray-800 .post-block-meta, .bg-primary .post-block-meta, .bg-primary-darker .post-block-meta { color: #aeb1be; } .context-dark .post-block-meta a:hover, .bg-gray-700 .post-block-meta a:hover, .bg-gray-800 .post-block-meta a:hover, .bg-primary .post-block-meta a:hover, .bg-primary-darker .post-block-meta a:hover { color: #fff; } .context-dark .post-block-meta > li:not(:last-child)::after, .bg-gray-700 .post-block-meta > li:not(:last-child)::after, .bg-gray-800 .post-block-meta > li:not(:last-child)::after, .bg-primary .post-block-meta > li:not(:last-child)::after, .bg-primary-darker .post-block-meta > li:not(:last-child)::after { background: #aeb1be; } .context-dark .post-block-title a:hover, .bg-gray-700 .post-block-title a:hover, .bg-gray-800 .post-block-title a:hover, .bg-primary .post-block-title a:hover, .bg-primary-darker .post-block-title a:hover { color: #aeb1be; } @media (max-width: 767.98px) { .post-block { max-width: 420px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .post-block-meta { margin-left: -25px; margin-right: -25px; } .post-block-meta > * { padding-left: 25px; padding-right: 25px; } .post-block-caption { padding: 35px 40px; } } .post-light { display: flex; align-items: flex-start; max-width: 450px; margin-left: -20px; text-align: left; } .post-light > * { margin-left: 20px; } .post-light:hover .post-light-time { background: #0045b6; } .post-light-time { position: relative; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; flex-shrink: 0; min-width: 100px; width: 100px; padding: 20px; color: #fff; background: #aeb1be; border-radius: 6px; transition: .22s ease-in-out; } .post-light-time > * { line-height: 1; } .post-light-time > * + * { margin-top: 3px; } .post-light-time-big { font-size: 2.25em; font-weight: 700; } a.post-tiny-media { display: block; } .post-light-media { position: relative; } .post-light-media::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.1); transition: .22s ease-in-out; opacity: 0; visibility: hidden; pointer-events: none; } .post-light-media img { width: 100%; } .post-light-main { flex-grow: 1; } .post-light-title { color: #0045b6; } .post-light-title a:hover { color: #343db0; } .post-light-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -20px; margin-right: -20px; color: #495057; } .post-light-meta > * { margin-top: 5px; padding-left: 20px; padding-right: 20px; } .post-light-meta > li { position: relative; display: inline-block; line-height: 24px; } .post-light-meta > li:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; width: 1px; height: 1.5em; background: #e8e9ee; transform: translate3d(0, -50%, 0); } .post-light-meta a { color: inherit; } .post-light-meta a:hover { color: #0045b6; } * + .post-light { margin-top: 30px; } * + .post-light-meta { margin-top: 9px; } @media (min-width: 768px) { .post-light { margin-left: -36px; } .post-light > * { margin-left: 36px; } } @media (min-width: 992px) { * + .post-light { margin-top: 38px; } } .post-tiny { text-align: left; } a.post-tiny-media { position: relative; display: block; } a.post-tiny-media::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.3); transition: .22s ease-in-out; opacity: 0; visibility: hidden; pointer-events: none; } a.post-tiny-media img { width: 100%; } a.post-tiny-media:hover::before { opacity: 1; visibility: visible; } .post-tiny-title { color: #0045b6; } .post-tiny-title a:hover { color: #343db0; } .post-tiny-time { display: block; color: #aeb1be; } * + .post-tiny-time { margin-top: 10px; } @media (max-width: 575.98px) { .post-tiny { max-width: 360px; margin-left: auto; margin-right: auto; } } .post-creative > img { width: 100%; } .post-creative > * + img { margin-top: 25px; } .post-creative > img + * { margin-top: 25px; } .post-creative * + h4, .post-creative * + .heading-4 { margin-top: 15px; } .post-creative-title { font-weight: 700; } .post-creative-meta { transform: translate3d(0, -5px, 0); margin-bottom: -5px; margin-left: -15px; margin-right: -15px; } .post-creative-meta > * { margin-top: 5px; padding-left: 15px; padding-right: 15px; } .post-creative-meta > li, .post-creative-meta > li > * { display: inline-block; vertical-align: middle; } .post-creative-meta > li > * + * { margin-left: 5px; } .post-creative-meta a { color: inherit; } .post-creative-meta a:hover { color: #0045b6; } .post-creative-meta .icon { font-size: 1.5em; color: #0045b6; } .post-creative-footer { transform: translate3d(0, -10px, 0); margin-bottom: -10px; margin-left: -15px; margin-right: -15px; } .post-creative-footer > * { margin-top: 10px; padding-left: 15px; padding-right: 15px; } .post-creative-footer > * { display: inline-block; vertical-align: middle; } * + .post-creative-meta { margin-top: 15px; } * + .post-creative-footer { margin-top: 25px; } @media (min-width: 768px) { .post-creative-title { max-width: 80%; } } @media (min-width: 992px) { * + .post-creative-meta { margin-top: 25px; } .post-creative + [class*='section'] { margin-top: 30px; } } @media (min-width: 1200px) { .post-creative h4, .post-creative .heading-4 { font-size: 22px; line-height: 1.46; } .post-creative > * + img { margin-top: 30px; } .post-creative > img + * { margin-top: 40px; } .post-creative * + h4, .post-creative * + .heading-4 { margin-top: 32px; } .post-creative > .quote-light + img { margin-top: 50px; } * + .post-creative-meta { margin-top: 30px; } * + .post-creative-footer { margin-top: 40px; } } @media (min-width: 768px) { .post-creative { font-size: 20px; } .post-creative h4, .post-creative .heading-4 { font-size: 20px; line-height: 1.56; } } @media (min-width: 1600px) { .post-creative + [class*='section'] { margin-top: 55px; } } /* +* +* Quotes +*/ .quote-light-mark { fill: #aeb1be; } .quote-light-cite { font-size: 20px; color: #0045b6; } .quote-light-caption { line-height: 1.2; } .quote-light-text { font-size: 16px; line-height: 1.5; letter-spacing: 0; color: #495057; } html img.quote-light-image { display: inline-block; } * + .quote-light { margin-top: 30px; } * + .quote-light-caption { margin-top: 3px; } * + .quote-light-mark { margin-top: 15px; } * + .quote-light-text { margin-top: 8px; } * + .quote-light-footer { margin-top: 20px; } @media (min-width: 576px) { .quote-light-cite { font-size: 20px; } } @media (min-width: 768px) { .quote-light-cite { font-size: 24px; } .quote-light-text { font-size: 21px; line-height: 1.45; } * + .quote-light { margin-top: 40px; } * + .quote-light-mark { margin-top: 35px; } * + .quote-light-text { margin-top: 15px; } * + .quote-light-footer { margin-top: 30px; } } @media (min-width: 1600px) { .quote-light-text { font-size: 24px; line-height: 1.33333; } * + .quote-light { margin-top: 50px; } * + .quote-light-mark { margin-top: 50px; } * + .quote-light-footer { margin-top: 45px; } } .quote-classic { text-align: left; } .quote-classic-meta { display: flex; align-items: center; flex-wrap: wrap; margin-bottom: -20px; margin-left: -20px; } .quote-classic-meta:empty { margin-bottom: 0; margin-left: 0; } .quote-classic-meta > * { display: inline-block; margin: 0 0 20px 20px; } .quote-classic-avatar { position: relative; overflow: hidden; width: 50px; height: 50px; border-radius: 50%; flex-shrink: 0; } .quote-classic-avatar img { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .quote-classic-avatar img { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .quote-classic-cite { font-size: 24px; line-height: 1.2; color: #151515; font-family: "Poppins", sans-serif; } .quote-classic-caption { color: #151515; } * + .quote-classic-text { margin-top: 25px; } * + .quote-classic-caption { margin-top: 0; } @media (max-width: 575.98px) { .quote-classic { margin-left: auto; margin-right: auto; } } .quote-modern { text-align: left; } .quote-modern-meta { display: flex; align-items: center; flex-wrap: wrap; margin-bottom: -20px; margin-left: -20px; } .quote-modern-meta:empty { margin-bottom: 0; margin-left: 0; } .quote-modern-meta > * { display: inline-block; margin: 0 0 20px 20px; } .quote-modern-avatar { position: relative; overflow: hidden; z-index: 2; width: 50px; height: 50px; border-radius: 50%; flex-shrink: 0; } .quote-modern-avatar img { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .quote-modern-avatar img { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .quote-modern-cite { font-size: 20px; line-height: 1.2; color: #0045b6; } .quote-modern-caption { color: #aeb1be; } .quote-modern-mark { fill: #aeb1be; } * + .quote-modern-meta { margin-top: 25px; } * + .quote-modern-caption { margin-top: 0; } * + .quote-modern-text { margin-top: 10px; } .quote-modern-big .quote-modern-text { font-size: 18px; line-height: 1.5; color: #495057; } @media (min-width: 992px) { .quote-modern-big .quote-modern-text { font-size: 20px; } } @media (min-width: 1200px) { .quote-modern-big .quote-modern-text { font-size: 24px; line-height: 1.33333; } .quote-modern-big * + .quote-modern-text { margin-top: 20px; } } /* +* +* Thumbnails +*/ .video-overlay { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-position: center center; background-size: cover; cursor: pointer; } .figure-light figcaption { padding-top: 15px; color: #74757f; } * + .figure-light { margin-top: 20px; } @media (min-width: 1200px) { * + .figure-light { margin-top: 40px; } } .figure-card { position: relative; overflow: hidden; display: flex; } .figure-card .figure-card-sizer { display: block; width: 100%; } .figure-card .figure-card-sizer::before { display: block; content: ''; visibility: hidden; padding-bottom: 56.25%; } .figure-card img { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .figure-card img { height: 100%; width: 100%; left: 0; top: 0; transform: none; object-fit: cover; object-position: center center; } } .thumbnail-video-1 { position: relative; text-align: center; } .thumbnail-video-1 .embed-responsive::before { min-height: 250px; } .thumbnail-video-1 .video-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; z-index: 1; background-position: 80% center; } .thumbnail-video-1 .video-overlay::after { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0, 0, 0, 0.4); z-index: -1; } .thumbnail-video-1 * + .button-video { margin-top: 25px; } .thumbnail-minimal { position: relative; overflow: hidden; display: block; } .thumbnail-minimal::before { content: ''; display: block; padding-bottom: 100%; } .thumbnail-minimal-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .thumbnail-minimal-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .thumbnail-minimal-caption { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; background: rgba(0, 69, 182, 0.6); display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: all 0.3s ease-in-out; } .thumbnail-minimal-caption::before { content: '\f504'; font-family: 'Material Design Icons'; font-size: 30px; line-height: 1; color: #fff; } .thumbnail-minimal:hover .thumbnail-minimal-caption { opacity: 1; visibility: visible; } .thumbnail-classic { position: relative; overflow: hidden; display: flex; text-align: left; } .thumbnail-classic * { color: inherit; } .thumbnail-classic, .thumbnail-classic:active, .thumbnail-classic:focus, .thumbnail-classic:hover { color: #fff; } .thumbnail-classic:hover { color: #fff; } .thumbnail-classic > * { width: 100%; flex-shrink: 0; } .thumbnail-classic-sm .thumbnail-classic-dummy::before { padding-bottom: 69.18919%; } .thumbnail-classic-dummy { width: 100%; } .thumbnail-classic-dummy::before { content: ''; display: block; visibility: hidden; pointer-events: none; padding-bottom: 73.84615%; } .thumbnail-classic-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .thumbnail-classic-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .thumbnail-classic-caption { position: relative; z-index: 1; display: flex; flex-direction: column; justify-content: flex-end; width: 100%; padding: 20px; background: rgba(73, 80, 87, 0.4); font-size: 14px; } .thumbnail-classic-caption * + p { margin-top: 5px; } .thumbnail-classic-title { font-size: 20px; line-height: 1.3; } @media (max-width: 575.98px) { .thumbnail-classic { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .thumbnail-classic-caption { padding: 25px 20px; } } @media (min-width: 992px) { .thumbnail-classic-lg .thumbnail-classic-dummy::before { padding-bottom: 147.69231%; } } @media (min-width: 1200px) { .thumbnail-classic-caption { padding: 30px 40px; font-size: 16px; } .thumbnail-classic-caption * + p { margin-top: 8px; } .thumbnail-classic-title { font-size: 24px; } } html:not(.tablet):not(.mobile) .thumbnail-classic-caption { opacity: 0; visibility: hidden; transition: .3s all linear; background: rgba(73, 80, 87, 0.6); } html:not(.tablet):not(.mobile) .thumbnail-classic:hover .thumbnail-classic-caption { opacity: 1; visibility: visible; } html:not(.tablet):not(.mobile) .hoverdir-item .thumbnail-classic-caption { opacity: 1; visibility: visible; transform: translate(-100%, 0); } html:not(.tablet):not(.mobile)[data-x-mode='true'] .hoverdir-item .thumbnail-classic-caption { opacity: 0; visibility: hidden; transform: none; } html:not(.tablet):not(.mobile)[data-x-mode='true'] .thumbnail-classic:hover .thumbnail-classic-caption { opacity: 1; visibility: visible; } .thumbnail-corporate { position: relative; overflow: hidden; display: flex; color: #fff; text-align: left; } .thumbnail-corporate * { color: inherit; } .thumbnail-corporate:hover { color: #fff; } .thumbnail-corporate > * { width: 100%; flex-shrink: 0; } .thumbnail-corporate-light { text-align: center; } .thumbnail-corporate-light .thumbnail-corporate-caption { display: flex; flex-direction: column; align-items: center; justify-content: center; } .thumbnail-corporate-light .thumbnail-corporate-caption > * { max-width: 180px; } .thumbnail-corporate-dummy { width: 100%; visibility: hidden; pointer-events: none; } .thumbnail-corporate-dummy::before { content: ''; display: block; width: 0; padding-bottom: 69.18919%; } .thumbnail-corporate-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .thumbnail-corporate-image { left: 0; top: 0; transform: none; object-fit: cover; width: 100%; height: 100%; object-position: center center; } } .thumbnail-corporate-caption { position: relative; z-index: 1; display: flex; flex-direction: column; width: 100%; padding: 20px; background: rgba(73, 80, 87, 0.4); } .thumbnail-corporate-caption * + p { margin-top: 10px; } .thumbnail-corporate-link { position: relative; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; width: 1.67em; height: 1.67em; text-align: center; border-radius: 50%; font-size: 24px; line-height: 1; background: #0045b6; } .thumbnail-corporate-link, .thumbnail-corporate-link:active, .thumbnail-corporate-link:focus, .thumbnail-corporate-link:hover { color: #fff; } .thumbnail-corporate-link .icon { font-size: inherit; } .thumbnail-corporate-link .icon:first-child, .thumbnail-corporate-link .icon:last-child { vertical-align: middle; transition: transform 0.3s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .thumbnail-corporate-link .icon:first-child { display: block; } .thumbnail-corporate-link .icon:last-child { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, 0, 0); } .thumbnail-corporate-link:hover { background: #4854ed; } .thumbnail-corporate-link:hover .icon:first-child { opacity: 0; transform: translate3d(0, -30%, 0); } .thumbnail-corporate-link:hover .icon:last-child { opacity: 1; transform: translate3d(0, 0, 0) translateY(-50%); } .thumbnail-corporate-title { font-size: 20px; line-height: 1.3; } * + .thumbnail-corporate-link { margin-top: 15px; } @media (max-width: 575.98px) { .thumbnail-corporate { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .thumbnail-corporate-caption { padding: 25px 20px; } } @media (min-width: 992px) { .thumbnail-corporate-lg .thumbnail-corporate-dummy::before { padding-bottom: 125.40541%; } } @media (min-width: 1200px) { .thumbnail-corporate-caption { padding: 30px 40px; } .thumbnail-corporate-caption * + p { margin-top: 8px; } .thumbnail-corporate-title { font-size: 24px; } } html:not(.tablet):not(.mobile) .thumbnail-corporate-caption { opacity: 0; visibility: hidden; transition: .3s all linear; background: rgba(73, 80, 87, 0.6); } html:not(.tablet):not(.mobile) .thumbnail-corporate-caption > * { opacity: 0; visibility: hidden; transition: .25s .11s all linear; } html:not(.tablet):not(.mobile) .thumbnail-corporate:hover .thumbnail-corporate-caption, html:not(.tablet):not(.mobile) .thumbnail-corporate:hover .thumbnail-corporate-caption > * { opacity: 1; visibility: visible; } html:not(.tablet):not(.mobile) .hoverdir-item .thumbnail-corporate-caption { opacity: 1; visibility: visible; transform: translate(-100%, -100%); } html:not(.tablet):not(.mobile)[data-x-mode='true'] .hoverdir-item .thumbnail-corporate-caption { opacity: 0; visibility: hidden; transform: none; } html:not(.tablet):not(.mobile)[data-x-mode='true'] .thumbnail-corporate:hover .thumbnail-corporate-caption, html:not(.tablet):not(.mobile)[data-x-mode='true'] .thumbnail-corporate:hover .thumbnail-corporate-caption > * { opacity: 1; visibility: visible; } .thumbnail-light { text-align: left; } .thumbnail-light-title { color: #151515; } .thumbnail-light-title a { color: inherit; } .thumbnail-light-title a:hover { color: #0045b6; } .thumbnail-light-media { position: relative; overflow: hidden; display: block; border-radius: 6px; } .thumbnail-light-media::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.3); transition: .22s ease-in-out; opacity: 0; visibility: hidden; pointer-events: none; } .thumbnail-light-media:hover::before { opacity: 1; visibility: visible; } .thumbnail-light-image { width: 100%; } @media (max-width: 575.98px) { .thumbnail-light { max-width: 360px; margin-left: auto; margin-right: auto; } } .thumbnail-thin { position: relative; overflow: hidden; padding-bottom: 100%; } .thumbnail-thin::before, .thumbnail-thin::after { pointer-events: none; } .thumbnail-thin::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; background: rgba(73, 80, 87, 0.3); } .thumbnail-thin::after { position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); z-index: 2; content: '\f504'; font-family: 'Material Design Icons'; font-size: 40px; line-height: 1; color: #fff; } a.thumbnail-thin { display: block; } .thumbnail-thin-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .thumbnail-thin-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } html:not(.tablet):not(.mobile) .thumbnail-thin::before, html:not(.tablet):not(.mobile) .thumbnail-thin::after { opacity: 0; transition: .22s ease-in; } html:not(.tablet):not(.mobile) .thumbnail-thin::before { background: rgba(73, 80, 87, 0.6); } html:not(.tablet):not(.mobile) .thumbnail-thin:hover::before, html:not(.tablet):not(.mobile) .thumbnail-thin:hover::after { opacity: 1; } .thumbnail-modern { position: relative; overflow: hidden; display: flex; text-align: left; } .thumbnail-modern * { color: inherit; } .thumbnail-modern, .thumbnail-modern:active, .thumbnail-modern:focus, .thumbnail-modern:hover { color: #fff; } .thumbnail-modern:hover { color: #fff; } .thumbnail-modern > * { width: 100%; flex-shrink: 0; } .thumbnail-modern-sm .thumbnail-modern-dummy::before { padding-bottom: 69.18919%; } .thumbnail-modern-dummy { visibility: hidden; pointer-events: none; width: 100%; } .thumbnail-modern-dummy::before { content: ''; display: block; padding-bottom: 73.84615%; } .thumbnail-modern-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; background-position: center center; background-size: cover; } @supports (object-fit: cover) { .thumbnail-modern-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .thumbnail-modern-caption { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; width: 100%; padding: 20px; background: rgba(73, 80, 87, 0.4); } .thumbnail-modern-caption .icon { font-size: 30px; line-height: 1; color: #fff; } @media (max-width: 575.98px) { .thumbnail-modern { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 992px) { .thumbnail-modern-lg .thumbnail-modern-dummy::before { padding-bottom: 147.69231%; } } html:not(.tablet):not(.mobile) .thumbnail-modern-caption { opacity: 0; visibility: hidden; transition: .3s all linear; background: rgba(73, 80, 87, 0.6); } html:not(.tablet):not(.mobile) .thumbnail-modern:hover .thumbnail-modern-caption { opacity: 1; visibility: visible; } .thumbnail-creative { position: relative; display: block; max-width: 348px; margin-left: auto; margin-right: auto; } .thumbnail-creative:hover .thumbnail-creative-image { transform: translate(0, -5px); box-shadow: 0 2px 24px 1px rgba(0, 0, 0, 0.2); } .thumbnail-creative-wrap { padding: 26px; margin: -26px; } .thumbnail-creative-image { position: relative; display: block; width: 100%; transition: .33s ease-in-out; box-shadow: 0 2px 24px 0 rgba(0, 0, 0, 0.15); } * + .thumbnail-creative-title { margin-top: 16px; } @media (min-width: 768px) { * + .thumbnail-creative-title { margin-top: 24px; } } .thumbnail-indigo { position: relative; overflow: hidden; display: block; background: #495057; box-shadow: -1px 1px 21px 0px rgba(0, 0, 0, 0.08); background-position: center top; background-size: cover; } .thumbnail-indigo::before { content: ''; display: block; opacity: 0; visibility: hidden; padding-bottom: 148.75847%; } .thumbnail-indigo-sm::before { padding-bottom: 70.65463%; } .thumbnail-indigo-sm + .thumbnail-indigo-sm { margin-top: 7.4%; } .thumbnail-indigo-caption { position: absolute; top: auto; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(255, 255, 255, 0.95); } html:not(.tablet):not(.mobile) .thumbnail-indigo-caption { top: 0; background: rgba(255, 255, 255, 0.9); } html:not(.tablet):not(.mobile) .thumbnail-indigo-caption, html:not(.tablet):not(.mobile) .thumbnail-indigo-caption > * { opacity: 0; visibility: hidden; will-change: opacity; } html:not(.tablet):not(.mobile) .thumbnail-indigo-caption { transition: .2s all linear; } html:not(.tablet):not(.mobile) .thumbnail-indigo-caption > * { transition: .2s .1s all ease-in; } html:not(.tablet):not(.mobile) .thumbnail-indigo:hover .thumbnail-indigo-caption, html:not(.tablet):not(.mobile) .thumbnail-indigo:hover .thumbnail-indigo-caption > * { opacity: 1; visibility: visible; } /* +* +* Breadcrumbs +*/ .breadcrumbs-custom { position: relative; display: flex; background-color: #495057; background-position: 20% 20%; } .breadcrumbs-custom .breadcrumbs-custom-subtitle { color: #aeb1be; } @media (min-width: 576px) { .breadcrumbs-custom { padding: 60px 0; } } @media (min-width: 1200px) { .breadcrumbs-custom { padding: 0; } } .breadcrumbs-custom-inner { display: flex; min-height: inherit; width: 100%; padding: 50px 0 25px; } .breadcrumbs-custom-container { display: flex; flex-direction: column; justify-content: space-between; } .breadcrumbs-custom-container::before { content: ''; display: block; } .breadcrumbs-custom-path { margin-left: -15px; margin-right: -15px; } .breadcrumbs-custom-path > * { padding-left: 15px; padding-right: 15px; } .breadcrumbs-custom-path li { position: relative; display: inline-block; vertical-align: middle; color: #aeb1be; } .breadcrumbs-custom-path li::after { content: ""; font-family: "FontAwesome"; position: absolute; top: 51%; right: -5px; display: inline-block; font-size: inherit; font-style: normal; color: rgba(255, 255, 255, 0.5); transform: translate3d(0, -50%, 0); } .breadcrumbs-custom-path li:last-child:after { display: none; } .breadcrumbs-custom-path a { display: inline; vertical-align: middle; } .breadcrumbs-custom-path a, .breadcrumbs-custom-path a:active, .breadcrumbs-custom-path a:focus { color: #aeb1be; } .breadcrumbs-custom-path a:hover { color: #fff; } * + .breadcrumbs-custom-path { margin-top: 25px; } .breadcrumbs-custom-subtitle + .breadcrumbs-custom-title { margin-top: 15px; } @media (min-width: 576px) { .breadcrumbs-custom-path { margin-left: -30px; margin-right: -30px; } .breadcrumbs-custom-path > * { padding-left: 30px; padding-right: 30px; } * + .breadcrumbs-custom-path { margin-top: 30px; } } @media (min-width: 768px) { .breadcrumbs-custom { min-height: 23.75vw; } .breadcrumbs-custom-inner { padding: 80px 0 30px; } .breadcrumbs-custom-subtitle + .breadcrumbs-custom-title { margin-top: 5px; } } @media (min-width: 992px) { .breadcrumbs-custom-inner { padding-bottom: 40px; } .breadcrumbs-custom-subtitle + .breadcrumbs-custom-title { margin-top: 10px; } } @media (min-width: 1600px) { .breadcrumbs-custom { min-height: 520px; } .breadcrumbs-custom-inner { padding: 130px 0 40px 0; } .breadcrumbs-custom-subtitle + .breadcrumbs-custom-title { margin-top: 20px; } } /* +* +* Pagination custom +*/ .pagination { font-size: 0; line-height: 0; margin-bottom: -10px; margin-left: -10px; } .pagination:empty { margin-bottom: 0; margin-left: 0; } .pagination > * { display: inline-block; margin: 0 0 10px 10px; } .page-item { display: inline-block; vertical-align: middle; text-align: center; font-weight: 700; } .page-link { display: inline-flex; align-items: center; justify-content: center; min-width: 48px; min-height: 48px; padding: 10px 10px; border: 0; border-radius: 6px; font-family: "Poppins", sans-serif; font-size: 14px; font-weight: 500; line-height: 1.71429; background-color: #0045b6; color: #fff; transition: all 0.3s ease-in-out; } .page-link.button-winona * { line-height: inherit; } .page-link.button-winona .content-dubbed { margin-top: -1px; } .page-link:hover, .page-link:focus, .page-link:active { color: #fff; background-color: #151515; } .page-item.active > .page-link, .page-item.active > .page-link:hover, .page-item.active > .page-link:focus, .page-item.active > .page-link:active { color: #fff; background-color: #151515; } .page-item.disabled > .page-link, .page-item.disabled > .page-link:hover, .page-item.disabled > .page-link:focus, .page-item.disabled > .page-link:active { color: #74757f; background-color: #e8e9ee; } .page-item-control .icon::before { font-family: 'FontAwesome'; font-size: 11px; line-height: 24px; } .page-item-control:first-child .icon::before { content: '\f053'; margin-left: -1px; } .page-item-control:last-child .icon::before { content: '\f054'; margin-right: -1px; } * + .pagination { margin-top: 35px; } @media (min-width: 992px) { * + .pagination { margin-top: 55px; } } @media (min-width: 1600px) { * + .pagination { margin-top: 70px; } } /* +* +* Snackbars +*/ .snackbars { padding: 9px 16px; margin-left: auto; margin-right: auto; color: #fff; text-align: left; background-color: #0045b6; box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.15); border-radius: 6px; font-size: 14px; } .snackbars .icon-xxs { font-size: 18px; } .snackbars p span:last-child { padding-left: 14px; } .snackbars-left { display: inline-block; margin-bottom: 0; } .snackbars-right { display: inline-block; float: right; text-transform: uppercase; } .snackbars-right:hover { text-decoration: underline; } @media (min-width: 576px) { .snackbars { max-width: 540px; padding: 12px 15px; font-size: 15px; } } /* +* +* Footers +*/ .footer-standard h4 + *, .footer-standard .heading-4 + * { margin-top: 14px; } .footer-standard-main { padding: 35px 0 15px; } .footer-standard-aside { padding: 30px 0; } .footer-standard-aside { margin-bottom: -18px; text-align: center; } .footer-standard-aside > * { margin-bottom: 18px; } .footer-standard-aside > * { margin-top: 0; } @media (max-width: 575.98px) { .footer-standard .box-1 { margin-left: 0; } } @media (min-width: 768px) and (max-width: 991.98px) { .footer-standard .box-1 { margin-left: 0; } } @media (min-width: 768px) { .footer-standard h4 + *, .footer-standard .heading-4 + * { margin-top: 25px; } .footer-standard-main { padding: 55px 0 20px; } .footer-standard-aside { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; text-align: center; margin-left: -20px; } .footer-standard-aside > * { margin-left: 20px; } } @media (min-width: 1200px) { .footer-standard-main { padding: 80px 0 50px; } } .footer-minimal { padding: 30px 0; } .footer-minimal-inner { margin-bottom: -18px; text-align: center; } .footer-minimal-inner > * { margin-bottom: 18px; } .footer-minimal-inner > * { margin-top: 0; } .footer-minimal-inner .rights a { padding-bottom: 2px; position: relative; } .footer-minimal-inner .rights a:before { content: ''; position: absolute; left: 0; height: 1px; bottom: 0; width: 0; display: block; transition: all ease .35s; background-color: #fff; } .footer-minimal-inner .rights a:hover { color: #fff; } .footer-minimal-inner .rights a:hover:before { content: ''; width: 100%; transition: all ease .35s; } @media (min-width: 768px) { .footer-minimal-inner { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; text-align: center; margin-left: -20px; } .footer-minimal-inner > * { margin-left: 20px; } } .footer-advanced.bg-gray-700 h1, .footer-advanced.bg-gray-700 h2, .footer-advanced.bg-gray-700 h3, .footer-advanced.bg-gray-700 h4, .footer-advanced.bg-gray-700 h5, .footer-advanced.bg-gray-700 h6, .footer-advanced.bg-gray-700 [class^='heading-'] { color: #aeb1be; } .footer-advanced h4 + *, .footer-advanced .heading-4 + * { margin-top: 14px; } .footer-advanced-main { padding: 35px 0 15px; } .footer-advanced-aside { padding: 30px 0; } .footer-advanced-layout { margin-bottom: -18px; text-align: center; } .footer-advanced-layout > * { margin-bottom: 18px; } .footer-advanced-layout > * { margin-top: 0; } @media (min-width: 768px) and (max-width: 991.98px) { .footer-advanced .box-1 { margin-left: 0; } } @media (min-width: 992px) and (max-width: 1199.98px) { .footer-advanced-text { font-size: 14px; line-height: 1.7; } } .footer-advanced-text { color: rgba(255, 255, 255, 0.3); } @media (min-width: 768px) { .footer-advanced-main { padding: 55px 0 20px; } .footer-advanced-aside { padding: 35px 0; } .footer-advanced-layout { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; text-align: center; margin-left: -20px; } .footer-advanced-layout > * { margin-left: 20px; } .footer-advanced-aside + .container { margin-top: 10px; } } @media (min-width: 1200px) { .footer-advanced h4 + *, .footer-advanced .heading-4 + * { margin-top: 35px; } .footer-advanced-main { padding: 80px 0 60px; } .footer-advanced-aside + .container { margin-top: 20px; } } .footer-linked.bg-gray-700 h1, .footer-linked.bg-gray-700 h2, .footer-linked.bg-gray-700 h3, .footer-linked.bg-gray-700 h4, .footer-linked.bg-gray-700 h5, .footer-linked.bg-gray-700 h6, .footer-linked.bg-gray-700 [class^='heading-'] { color: #aeb1be; } .footer-linked.bg-gray-700 .list { color: #fff; } .footer-linked.bg-gray-700 .list a:hover { color: rgba(255, 255, 255, 0.6); } .footer-linked h4 + *, .footer-linked .heading-4 + * { margin-top: 14px; } .footer-linked h4 + hr, .footer-linked .heading-4 + hr { margin-top: 15px; } .footer-linked * + .row { margin-top: 25px; } .footer-linked-main { padding: 35px 0 15px; } .footer-linked-aside { padding: 30px 0; } .footer-linked-layout { margin-bottom: -18px; text-align: center; } .footer-linked-layout > * { margin-bottom: 18px; } .footer-linked-layout > * { margin-top: 0; } @media (min-width: 768px) and (max-width: 991.98px) { .footer-linked .box-1 { margin-left: 0; } } @media (min-width: 992px) and (max-width: 1199.98px) { .footer-linked-text { font-size: 14px; line-height: 1.7; } } @media (min-width: 768px) { .footer-linked-main { padding: 65px 0 40px; } .footer-linked-aside { padding: 30px 0; } .footer-linked-layout { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; text-align: center; margin-left: -20px; } .footer-linked-layout > * { margin-left: 20px; } .footer-linked-aside + .container { margin-top: 10px; } } @media (min-width: 1200px) { .footer-linked h4 + *, .footer-linked .heading-4 + * { margin-top: 35px; } .footer-linked-main { padding: 90px 0 60px; } .footer-linked-aside + .container { margin-top: 20px; } } .foter-social-links .link-default { color: #fff; } .foter-social-links .link-default:hover { color: #0045b6; } .rights { color: #74757f; } .footer-minimal .rights { color: #fff; } /* +* +* Pricing tables +*/ .pricing-minimal { position: relative; z-index: 1; padding: 25px 20px; border: 1px solid #aeb1be; border-radius: 6px; text-align: left; transition: .22s; cursor: default; } .pricing-minimal:hover { border-color: #0045b6; } .pricing-minimal:hover::before { visibility: visible; transform: translate3d(0, 0, 0); } .pricing-minimal-price { font-size: 26px; line-height: 1; font-weight: 700; letter-spacing: 0; color: #495057; } .pricing-minimal-price span { display: inline; } .pricing-minimal-price-currency { font-size: .65em; vertical-align: top; } .pricing-minimal-divider { border-bottom: 1px solid #aeb1be; } * + .pricing-minimal-price { margin-top: 10px; } * + .pricing-minimal-divider { margin-top: 20px; } @media (max-width: 575.98px) { .pricing-minimal { max-width: 350px; margin-left: auto; margin-right: auto; } } @media (min-width: 576px) { .pricing-minimal { padding: 20px; } } @media (min-width: 768px) { .pricing-minimal-price { font-size: 36px; } } @media (min-width: 768px) { .pricing-minimal { padding: 30px 38px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .pricing-minimal { padding: 25px 20px; } } @media (min-width: 1200px) { .pricing-minimal { padding: 38px 40px; } } .pricing-modern { padding: 35px 20px; border: 1px solid #aeb1be; border-radius: 6px; text-align: center; } .pricing-modern * + .button { margin-top: 20px; } .pricing-group-modern { border: 1px solid #aeb1be; border-radius: 6px; } .pricing-group-modern .pricing-modern { border: 0; border-radius: 0; } .pricing-modern-rating { color: #0045b6; } .pricing-modern-rating > li { display: inline-block; width: 26px; font-size: 30px; line-height: .9; } .pricing-modern-title { font-weight: 700; } .pricing-modern-table { display: inline-table; } .pricing-modern-table td { padding: 3px 8px; color: #74757f; } .pricing-modern-table td:first-child { text-align: right; color: #495057; } .pricing-modern-table td:last-child { text-align: left; } .pricing-modern-price { font-size: 24px; font-weight: 300; line-height: 1.2; } .pricing-modern-price span { display: inline; } .pricing-modern-price-currency { font-size: .65em; vertical-align: top; } * + .pricing-group-modern { margin-top: 35px; } * + .pricing-modern-title { margin-top: 5px; } * + .pricing-modern-table { margin-top: 20px; } * + .pricing-modern-price { margin-top: 15px; } @media (max-width: 575.98px) { .pricing-group-modern { max-width: 360px; margin-left: auto; margin-right: auto; } .pricing-group-modern .pricing-modern + .pricing-modern { border-top: 1px solid #aeb1be; } } @media (min-width: 576px) and (max-width: 991.98px) { .pricing-group-modern .pricing-modern:nth-child(odd) { border-right: 1px solid #aeb1be; } .pricing-group-modern .pricing-modern:nth-child(n + 3) { border-top: 1px solid #aeb1be; } } @media (min-width: 576px) { .pricing-group-modern { display: flex; flex-wrap: wrap; } .pricing-group-modern .pricing-modern { width: 100%; max-width: 50%; } .pricing-modern { padding: 55px 20px; } * + .pricing-group-modern { margin-top: 50px; } * + .pricing-modern-table { margin-top: 25px; } * + .pricing-modern-price { margin-top: 30px; } } @media (min-width: 768px) { .pricing-modern-price { font-size: 30px; } } @media (min-width: 992px) { .pricing-group-modern .pricing-modern { max-width: 25%; border-right: 1px solid #aeb1be; } .pricing-group-modern .pricing-modern:nth-child(4n) { border-right: 0; } .pricing-group-modern .pricing-modern:nth-child(n + 5) { border-top: 1px solid #aeb1be; } } @media (min-width: 992px) and (max-width: 1199.98px) { .pricing-modern .button { display: block; padding-left: 15px; padding-right: 15px; } } @media (min-width: 1200px) { .pricing-modern-price { font-size: 36px; } * + .pricing-modern-table { margin-top: 35px; } * + .pricing-modern-price { margin-top: 40px; } } @media (min-width: 1600px) { * + .pricing-group-modern { margin-top: 75px; } } /* +* +* Profiles +*/ .profile-classic { text-align: left; } .profile-classic-main { position: relative; overflow: hidden; z-index: 1; border-radius: 6px; } .profile-classic-image { min-width: 100%; } .profile-classic-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px; background: rgba(0, 69, 182, 0.6); } .profile-classic-caption .icon-creative { color: #fff; } .profile-classic-title { color: #0045b6; } .profile-classic-title a:hover { color: #343db0; } .profile-classic-position { color: #aeb1be; } * + .profile-classic-title { margin-top: 15px; } * + .profile-classic-position { margin-top: 5px; } @media (max-width: 575.98px) { .profile-classic { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 576px) and (max-width: 767.98px) { .profile-classic-caption { padding-left: 5px; padding-right: 5px; } .profile-classic-caption .icon-sm { font-size: 18px; } .profile-classic-caption .group { margin-left: -3px; } .profile-classic-caption .group > * { margin-left: 3px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .profile-classic-caption { padding-left: 5px; padding-right: 5px; } .profile-classic-caption .icon-sm { font-size: 18px; } .profile-classic-caption .group { margin-left: -3px; } .profile-classic-caption .group > * { margin-left: 3px; } } @media (min-width: 1200px) { * + .profile-classic-title { margin-top: 20px; } } html:not(.tablet):not(.mobile) .profile-classic:hover .profile-classic-caption { opacity: 1; visibility: visible; } html:not(.tablet):not(.mobile) .profile-classic-caption { top: 0; display: flex; flex-direction: column; justify-content: flex-end; opacity: 0; visibility: hidden; transition: .33s ease-in-out; } .profile-creative { display: flex; margin-left: -15px; text-align: left; color: #74757f; } .profile-creative > * { margin-left: 15px; } .profile-creative-figure { position: relative; overflow: hidden; max-width: 80px; flex-shrink: 0; } .profile-creative-image { width: 100%; border-radius: 6px; } .profile-creative-main { flex-grow: 1; } .profile-creative-title a { color: #151515; font-weight: 300; } .profile-creative-title a:hover { color: #0045b6; } .profile-creative-position { color: #151515; } .profile-creative-contacts { color: #151515; } .profile-creative-contacts a { color: inherit; font-weight: 400; } .profile-creative-contacts a:hover { color: #0045b6; } * + .profile-creative-position { margin-top: 0; } * + .profile-creative-contacts { margin-top: 15px; } @media (min-width: 576px) { .profile-creative { margin-left: -30px; } .profile-creative > * { margin-left: 30px; } .profile-creative-figure { max-width: 22%; } } @media (min-width: 768px) { .profile-creative-figure { max-width: 29%; } .profile-creative-main { padding-right: 30px; } * + .profile-creative-contacts { margin-top: 22px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .profile-creative { margin-left: -20px; } .profile-creative > * { margin-left: 20px; } .profile-creative-figure { max-width: 25%; } .profile-creative-main { padding-right: 10px; } } .profile-minimal { position: relative; overflow: hidden; border-radius: 6px; color: #fff; text-align: left; } .profile-minimal::before { content: ''; display: block; padding-bottom: 100%; } .profile-minimal-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; max-width: none; } @supports (object-fit: cover) { .profile-minimal-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .profile-minimal-caption { position: absolute; top: 0; right: 0; bottom: 0; left: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 25px; background: rgba(73, 80, 87, 0.4); } .profile-minimal-title { color: #fff; } * + .profile-minimal-position { margin-top: 5px; } @media (max-width: 575.98px) { .profile-minimal { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 1200px) { .profile-minimal-caption { padding: 35px; } * + .profile-minimal-position { margin-top: 10px; } } html:not(.tablet):not(.mobile) .profile-minimal:hover .profile-minimal-caption { opacity: 1; visibility: visible; } html:not(.tablet):not(.mobile) .profile-minimal-caption { opacity: 0; visibility: hidden; transition: .35s ease-in; background: rgba(73, 80, 87, 0.6); } .profile-modern { text-align: left; } .profile-modern > * + * { margin-top: 35px; } .profile-modern-header { display: flex; justify-content: space-between; flex-wrap: wrap; margin-bottom: -10px; margin-left: -20px; } .profile-modern-header:empty { margin-bottom: 0; margin-left: 0; } .profile-modern-header > * { display: inline-block; margin: 0 0 10px 20px; } .profile-modern-header * + p { margin-top: 5px; } .profile-modern-figure { position: relative; overflow: hidden; border-radius: 6px; } .profile-modern-image { display: block; width: 100%; } .profile-modern-button { position: absolute; right: 30px; bottom: 30px; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; width: 1.86em; height: 1.86em; text-align: center; border-radius: 50%; font-size: 32px; line-height: 1; background: #0045b6; } .profile-modern-button, .profile-modern-button:active, .profile-modern-button:focus, .profile-modern-button:hover { color: #fff; } .profile-modern-button .icon { font-size: inherit; } .profile-modern-button .icon:first-child, .profile-modern-button .icon:last-child { vertical-align: middle; transition: transform 0.3s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .profile-modern-button .icon:first-child { display: block; } .profile-modern-button .icon:last-child { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, 0, 0); } .profile-modern-button:hover { color: #fff; background: #343db0; } .profile-modern-button:hover .icon:first-child { opacity: 0; transform: translate3d(0, -30%, 0); } .profile-modern-button:hover .icon:last-child { opacity: 1; transform: translate3d(0, 0, 0) translateY(-50%); } @media (max-width: 575.98px) { .profile-modern { max-width: 360px; margin-left: auto; margin-right: auto; } .profile-modern * + .row { margin-top: 15px; } } @media (max-width: 767.98px) { .profile-modern-figure { max-width: 330px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { .profile-modern { display: flex; align-items: flex-start; margin-left: -30px; } .profile-modern > * { margin-left: 30px; } .profile-modern > * + * { margin-top: 0; } .profile-modern-figure { max-width: 50%; flex-shrink: 0; } .profile-modern-main { flex-grow: 1; } * + .profile-modern-main { margin-top: 0; } } @media (min-width: 992px) { .profile-modern-figure { max-width: 28%; } } @media (min-width: 768px) and (max-width: 1199.98px) { .profile-modern { font-size: 14px; } .profile-modern * + .row { margin-top: 31px; } } @media (min-width: 1200px) { .profile-modern { margin-left: -60px; } .profile-modern > * { margin-left: 60px; } } .profile-thin { position: relative; display: block; padding: 30px 20px 45px; background: #f5f6fa; text-align: center; } .profile-thin * + p { margin-top: 20px; } .profile-thin * + .group { margin-top: 15px; } .profile-thin * + .button-sm { margin-top: 15px; } .profile-thin .group { margin-left: auto; margin-right: auto; } .profile-thin-aside { position: relative; display: inline-block; } .profile-thin-image { border-radius: 50%; } .profile-thin-contact-button { position: absolute; right: 0; bottom: 0; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; width: 2em; height: 2em; text-align: center; border-radius: 50%; font-size: 20px; line-height: 1; background: #0045b6; } .profile-thin-contact-button, .profile-thin-contact-button:active, .profile-thin-contact-button:focus, .profile-thin-contact-button:hover { color: #fff; } .profile-thin-contact-button .icon { font-size: inherit; } .profile-thin-contact-button .icon:first-child, .profile-thin-contact-button .icon:last-child { vertical-align: middle; transition: transform 0.3s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .profile-thin-contact-button .icon:first-child { display: block; } .profile-thin-contact-button .icon:last-child { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, 0, 0); } .profile-thin-contact-button:hover { color: #fff; background: #343db0; } .profile-thin-contact-button:hover .icon:first-child { opacity: 0; transform: translate3d(0, -30%, 0); } .profile-thin-contact-button:hover .icon:last-child { opacity: 1; transform: translate3d(0, 0, 0) translateY(-50%); } .profile-thin-title { font-size: 22px; line-height: 1.47; } * + p.profile-thin-subtitle { margin-top: 0; } * + .profile-thin-main { margin-top: 20px; } @media (min-width: 576px) and (max-width: 991.98px) { .profile-thin { display: flex; align-items: flex-start; text-align: left; } .profile-thin .group { margin-left: -15px; } .profile-thin > * + * { margin-left: 30px; } .profile-thin-aside { flex-shrink: 0; max-width: 30%; } .profile-thin-main { flex-grow: 1; } * + .profile-thin-main { margin-top: 0; } } @media (min-width: 1200px) { .profile-thin { padding: 55px 35px; } .profile-thin * + .group { margin-top: 25px; } .profile-thin * + .button-sm { margin-top: 25px; } } /* +* +* Counters +*/ .counter-minimal { position: relative; text-align: center; color: #495057; } .counter-minimal-icon { font-size: 24px; line-height: 1; color: #0045b6; } .counter-minimal-main { font-family: "Poppins", sans-serif; font-size: 36px; font-weight: 500; line-height: 1.2; letter-spacing: 0; } .counter-minimal-main > * { display: inline; font: inherit; } .counter-minimal-main span { display: inline; } * + .counter-minimal-title { margin-top: 10px; } * + .counter-minimal-main { margin-top: 15px; } @media (max-width: 575.98px) { .counter-minimal-title { font-size: 16px; line-height: 1.4; } } @media (min-width: 768px) { .counter-minimal-icon { font-size: 28px; } } @media (min-width: 768px) { .counter-minimal-title { font-size: 18px; } .counter-minimal-main { font-size: 48px; } } @media (min-width: 1200px) { * + .counter-minimal-main { margin-top: 25px; } * + .counter-minimal-title { margin-top: 18px; } } /* +* +* Layouts +*/ .layout-bordered { text-align: center; } .layout-bordered-item { border: 0 solid #d3d5db; } .layout-bordered-item-inner { display: flex; flex-direction: column; justify-content: center; max-width: 290px; padding: 0 15px; margin-left: auto; margin-right: auto; } .layout-bordered-item-inner a { color: #151515; } .layout-bordered-item-inner > * + * { margin-top: 10px; } @media (max-width: 767.98px) { .layout-bordered-item { padding: 30px 0; } .layout-bordered-item:first-child { padding-top: 0; } .layout-bordered-item:last-child { padding-bottom: 0; } .layout-bordered-item + .layout-bordered-item { border-top-width: 1px; } } @media (min-width: 768px) { .layout-bordered { display: flex; } .layout-bordered-item { flex-grow: 1; } .layout-bordered-item-inner > * + * { margin-top: 15px; } } @media (min-width: 768px) and (max-width: 991.98px) { .layout-bordered-item { font-size: 15px; } } @media (min-width: 768px) { .layout-bordered-item { flex-basis: 33.333%; border-right-width: 1px; } .layout-bordered-item:first-child { border-left-width: 1px; } } .layout-navbar-demo { position: relative; overflow: hidden; min-height: 400px; background: #e8e9ee; } .layout-navbar-demo.layout-navbar-demo-lg { min-height: 650px; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-panel { position: relative; z-index: 10; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-nav-wrap { position: absolute; z-index: 9; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-main-outer, .layout-navbar-demo .rd-navbar-fixed .rd-navbar-main-element { position: static; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-search { position: static; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-panel .rd-navbar-search-toggle { position: static; z-index: 10; } .layout-navbar-demo .rd-navbar-fixed div.rd-navbar-block { top: -1px; right: 10px; white-space: nowrap; } .layout-navbar-demo .rd-navbar-fixed div.rd-navbar-block > * { display: inline-block; margin-top: 0; white-space: normal; vertical-align: middle; } .layout-navbar-demo .rd-navbar-fixed div.rd-navbar-block > * + * { margin-left: 8px; } .layout-navbar-demo .rd-navbar-fixed div.rd-navbar-block .rd-navbar-search-toggle { margin-top: 6px; } .layout-navbar-demo .rd-navbar-fixed div.rd-navbar-popup { position: absolute; } .layout-navbar-demo .rd-navbar-fixed.rd-navbar [class*='rd-navbar-fixed-element'] { position: absolute; z-index: 1000; } .layout-navbar-demo .rd-navbar-fixed.rd-navbar .rd-search { position: absolute; z-index: 1; } .layout-navbar-demo .rd-navbar-fixed .rd-navbar-collapse-content { position: absolute; z-index: 1; } @media (min-width: 1200px) { .layout-navbar-demo { min-height: 550px; } .layout-navbar-demo.layout-navbar-demo-lg { min-height: 650px; } } .container + .layout-navbar-demo { margin-top: 20px; } .blog-layout-aside { padding: 4px 0; background: #f5f6fa; } .blog-layout-aside .rd-search .form-input { border-color: transparent; } .blog-layout-aside-item { padding: 25px 30px; } .blog-layout-aside-item .blog-layout-aside-title + * { margin-top: 15px; } .blog-layout-aside-item a { color: #151515; } .blog-layout-aside-title { font-size: 22px; line-height: 1.5; } * + .blog-layout-aside { margin-top: 55px; } @media (min-width: 768px) { * + .blog-layout-aside { margin-top: 70px; } } @media (min-width: 768px) and (max-width: 991.98px) { .blog-layout-aside { columns: 2; break-inside: avoid; } .blog-layout-aside-item { display: inline-block; width: 100%; } } @media (min-width: 992px) { .blog-layout { display: flex; align-items: flex-start; margin-left: -30px; } .blog-layout > * { margin-left: 30px; } .blog-layout-main { flex-grow: 1; max-width: 66.66667%; } .blog-layout-aside { flex-shrink: 0; max-width: 33.33333%; } * + .blog-layout-aside { margin-top: 0; } } @media (min-width: 1200px) { .blog-layout-aside-item { padding: 35px 40px; } } .blog-layout-aside .list-marked * > a { transition: all ease .35s; display: inline-block; } .blog-layout-aside .list-marked * > a:hover { text-indent: 5px; transition: all ease .35s; } /* +* +* Timeline +*/ .timeline-classic { text-align: left; } .timeline-classic-item { position: relative; overflow: hidden; padding: 0 0 70px 35px; margin-bottom: -25px; } .timeline-classic-item:last-child { padding-bottom: 0; margin-bottom: 0; } .timeline-classic-item-image { width: 100%; } .timeline-classic-item-title { font-family: "Poppins", sans-serif; font-size: 24px; line-height: 1.2; font-weight: 300; letter-spacing: 0; text-transform: uppercase; } .thumbnail-classic-item-subtitle { font-size: 16px; line-height: 1.5; color: #151515; } .timeline-classic-item-divider { position: absolute; top: 0; left: 0; display: flex; flex-grow: 1; flex-direction: column; align-items: center; padding-top: 25px; } .timeline-classic-item-divider::before { content: ''; display: block; width: 15px; height: 15px; border: 2px solid #0045b6; border-radius: 50%; } .timeline-classic-item-divider::after { content: ''; position: absolute; top: 40px; left: 50%; margin-left: -1px; width: 2px; height: 300vh; background: #0045b6; min-height: 100px; } * + .timeline-classic-item-main { margin-top: 20px; } .context-dark .timeline-classic-item-divider::before, .bg-gray-700 .timeline-classic-item-divider::before, .bg-gray-800 .timeline-classic-item-divider::before, .bg-primary .timeline-classic-item-divider::before, .bg-primary-darker .timeline-classic-item-divider::before { border-color: #fff; } .context-dark .timeline-classic-item-divider::after, .bg-gray-700 .timeline-classic-item-divider::after, .bg-gray-800 .timeline-classic-item-divider::after, .bg-primary .timeline-classic-item-divider::after, .bg-primary-darker .timeline-classic-item-divider::after { background: #fff; } .context-dark .thumbnail-classic-item-subtitle, .bg-gray-700 .thumbnail-classic-item-subtitle, .bg-gray-800 .thumbnail-classic-item-subtitle, .bg-primary .thumbnail-classic-item-subtitle, .bg-primary-darker .thumbnail-classic-item-subtitle { color: #fff; } @media (max-width: 575.98px) { .timeline-classic-item { max-width: 450px; margin-left: auto; margin-right: auto; } } @media (max-width: 767.98px) { .timeline-classic-item { flex-wrap: wrap; } .timeline-classic-item-main, .timeline-classic-item-aside { width: 100%; } } @media (min-width: 768px) { .timeline-classic-item { display: flex; padding-left: 0; padding-bottom: 90px; } .timeline-classic-item-aside { max-width: 33.33%; } .timeline-classic-item-main { max-width: 60%; } .timeline-classic-item-divider { position: relative; padding: 25px 20px 0; } .timeline-classic-item-title { font-size: 32px; } .thumbnail-classic-item-subtitle { font-size: 18px; line-height: 1.4; } * + .timeline-classic-item-main { margin-top: 0; } } @media (min-width: 992px) { .timeline-classic-item * + h4, .timeline-classic-item * + .heading-4 { margin-top: 20px; } .timeline-classic-item-title { font-size: 36px; } .thumbnail-classic-item-subtitle { font-size: 20px; line-height: 1.4; } .timeline-classic-item-aside { max-width: 37%; } .timeline-classic-item-main { max-width: 50%; } } @media (min-width: 1200px) { .timeline-classic-item-aside { max-width: 33.33%; } .timeline-classic-item-title { font-size: 48px; } } @media (min-width: 1600px) { .timeline-classic-item { padding-bottom: 125px; } .timeline-classic-item * + h4, .timeline-classic-item * + .heading-4 { margin-top: 30px; } } /* +* +* Careers +*/ .career-classic { text-align: left; } .career-classic-divider { border-bottom: 1px solid #aeb1be; } .career-classic-list { transform: translate3d(0, -10px, 0); margin-bottom: -10px; margin-left: -20px; margin-right: -20px; } .career-classic-list > * { margin-top: 10px; padding-left: 20px; padding-right: 20px; } .career-classic-list > li { display: inline-block; vertical-align: middle; white-space: nowrap; } .career-classic-list > li > * { display: inline-block; vertical-align: middle; } .career-classic-list > li > * + * { margin-left: 8px; } .career-classic-list .icon { font-size: 1.25em; color: #0045b6; } * + .career-classic-divider { margin-top: 10px; } * + .career-classic-list { margin-top: 15px; } @media (max-width: 575.98px) { .career-classic { max-width: 360px; margin-left: auto; margin-right: auto; } } @media (min-width: 768px) { * + .career-classic-divider { margin-top: 20px; } * + .career-classic-list { margin-top: 20px; } } /* +* +* Comments +*/ .comment-classic { display: flex; align-items: flex-start; text-align: left; padding-bottom: 20px; border-bottom: 1px solid #e8e9ee; } .comment-classic > * + * { margin-left: 20px; } .comment-classic-figure { position: relative; flex-shrink: 0; width: 48px; height: 48px; } .comment-classic-image { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-height: 100%; min-width: 100%; width: auto; height: auto; border-radius: 50%; max-width: none; } @supports (object-fit: cover) { .comment-classic-image { left: 0; top: 0; width: 100%; height: 100%; transform: none; object-fit: cover; object-position: center center; } } .comment-classic-main { flex-grow: 1; } .comment-classic-name { color: #0045b6; } .comment-classic-text { color: #495057; } .comment-classic-meta { display: flex; align-items: center; justify-content: space-between; } .comment-classic-meta time { color: #74757f; } .comment-classic-reply { font-size: 24px; line-height: 1; color: #aeb1be; } .comment-classic-reply::before { content: '\f549'; font-family: 'Material Design Icons'; } .comment-classic-reply:hover { color: #0045b6; } * + .comment-classic-text { margin-top: 8px; } * + .comment-classic-meta { margin-top: 5px; } @media (min-width: 576px) { .comment-classic > * + * { margin-left: 25px; } .comment-classic-reply { font-size: 28px; } } .comment-classic-group .comment-classic-group { margin-left: 25px; } .comment-classic-group * + .comment-classic-group { margin-top: 30px; } * + .comment-classic-group { margin-top: 50px; } .comment-classic-group + .comment-classic-group { margin-top: 30px; } @media (min-width: 768px) { .comment-classic-group .comment-classic-group { margin-left: 75px; } } /* +* +* Jumbotron Classic +*/ .jumbotron-classic.bg-gray-700 h6, .jumbotron-classic.bg-gray-700 .heading-6 { color: #fff; } .jumbotron-classic.parallax-container { background-position: 60% center; } .jumbotron-classic .parallax-content { width: 100%; } .jumbotron-classic-content { max-width: 650px; display: flex; flex-direction: column; justify-content: center; height: 100%; } .jumbotron-classic-content .title-docor-text:before { top: 50%; } @media (max-width: 1599.98px) { .jumbotron-classic-content { padding: 120px 0; } } @media (max-width: 767.98px) { .jumbotron-classic-content { padding: 60px 0 80px 0; } } @media (max-width: 1199.98px) { .jumbotron-classic.parallax-container::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none; background: linear-gradient(to right, rgba(73, 80, 87, 0.6), rgba(73, 80, 87, 0.2)); } } @media (min-width: 768px) { .jumbotron-classic-inner { padding: 200px 0 140px; } } @media (min-width: 1600px) { .jumbotron-classic-content { padding: 200px 0 150px; } } .jumbotron-modern { position: relative; display: flex; text-align: center; } .jumbotron-modern-inner { position: relative; z-index: 1; display: flex; align-items: center; width: 100%; padding: 50px 0; } .jumbotron-modern-content { max-width: 770px; margin-left: auto; margin-right: auto; } @media (max-width: 767.98px) { .jumbotron-modern .form-layout-1 { display: inline-block; max-width: 360px; } } @media (max-width: 1199.98px) { .jumbotron-modern::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none; background: linear-gradient(to bottom, rgba(73, 80, 87, 0.4), rgba(73, 80, 87, 0)); } } @media (min-width: 768px) { .jumbotron-modern-inner { padding: 100px 0 140px; } } @media (min-width: 1200px) { .jumbotron-modern-inner { min-height: 41.66667vw; } } .jumbotron-indigo-header { padding: 40px 0; } .jumbotron-indigo-main { padding: 10px 0 40px; } @media (min-width: 768px) { .jumbotron-indigo-header { padding: 50px 0; } .jumbotron-indigo-main { padding: 50px 0 185px; } } /* +* +* Page layout +*/ .page { position: relative; overflow: hidden; min-height: 100vh; } .page.fadeIn { animation-timing-function: ease-out; } .page.fadeOut { animation-timing-function: ease-in; } [data-x-mode] .page { opacity: 1; } html.boxed body { background: #f5f6fa url(../images/bg-pattern-boxed.png) repeat fixed; } @media (min-width: 1630px) { html.boxed .page { max-width: 1600px; margin-left: auto; margin-right: auto; box-shadow: 0 0 23px 0 rgba(1, 1, 1, 0.1); } html.boxed .rd-navbar-static { max-width: 1600px; margin-left: auto; margin-right: auto; } html.boxed .rd-navbar-static.rd-navbar--is-stuck { max-width: 1600px; width: 100%; left: calc(50% - 1600px); right: calc(50% - 1600px); } } .page-header-navbar { position: relative; z-index: 1080; } .page-header-navbar .rd-navbar-wrap { position: absolute; top: 0; left: 0; right: 0; z-index: 1080; } /* +* +* Text styling +*/ .text-italic { font-style: italic; } .text-normal { font-style: normal; } .text-underline { text-decoration: underline; } .text-strike { text-decoration: line-through; } .font-weight-thin { font-weight: 100; } .font-weight-light { font-weight: 300; } .font-weight-regular { font-weight: 400; } .font-weight-medium { font-weight: 500; } .font-weight-sbold { font-weight: 600; } .font-weight-ubold { font-weight: 900; } .text-spacing-0 { letter-spacing: 0; } /* +* +* Offsets +*/ * + p, * + .list-inline-comma { margin-top: 16px; } h3 + p, .heading-3 + p, h3 + .p, .heading-3 + .p { margin-top: 18px; } h4 + p, .heading-4 + p, h4 + .p, .heading-4 + .p { margin-top: 16px; } * + h1, * + .heading-1 { margin-top: 15px; } h3 + h1, h3 + .heading-1, .heading-3 + h1, .heading-3 + .heading-1 { margin-top: 5px; } h6 + h1, h6 + .heading-1, .heading-6 + h1, .heading-6 + .heading-1 { margin-top: 10px; } * + h4, * + .heading-4 { margin-top: 15px; } * + h5, * + .heading-5 { margin-top: 15px; } h6 + p, .heading-6 + p { margin-top: 22px; } p + h2, p + .heading-2 { margin-top: 15px; } p + p { margin-top: 14px; } img + p { margin-top: 15px; } h3 + p { font-size: 16px; } h3 + img { margin-top: 42px; } p + hr { margin-top: 35px; } * + .row { margin-top: 30px; } * + .big { margin-top: 20px; } * + .text-block { margin-top: 25px; } * + .button, * + .button-outer { margin-top: 25px; } * + .list-sm { margin-top: 25px; } * + form.form-inline { margin-top: 18px; } * + form.rd-form-small { margin-top: 25px; } html * + .offset-top-1 { margin-top: 40px; } html * + .offset-top-2 { margin-top: 50px; } html * + .offset-top-3 { margin-top: 20px; } html * + .offset-top-4 { margin-top: 18px; } html .page * + .offset-top-5 { margin-top: 40px; } h3 + .row, .heading-3 + .row, h3 + .owl-carousel, .heading-3 + .owl-carousel { margin-top: 35px; } .container + .container { margin-top: 35px; } .container + .section { margin-top: 25px; } .row + .row { margin-top: 35px; } .row + .button-lg { margin-top: 30px; } .list-sm + .group-xs { margin-top: 20px; } @media (min-width: 768px) { * + p, * + .list-inline-comma { margin-top: 20px; } * + .big { margin-top: 25px; } * + .button, * + .button-outer { margin-top: 34px; } * + .row { margin-top: 40px; } * + .text-block { margin-top: 30px; } .container + .section { margin-top: 30px; } .row + .button-lg { margin-top: 45px; } html * + .offset-top-2 { margin-top: 50px; } } @media (min-width: 992px) { * + h1, * + .heading-1 { margin-top: 22px; } h3 + p, .heading-3 + p, h3 + .p, .heading-3 + .p { margin-top: 24px; } h3 + .row, .heading-3 + .row, h3 + .owl-carousel, .heading-3 + .owl-carousel { margin-top: 55px; } html * + .offset-top-1 { margin-top: 60px; } html * + .offset-top-3 { margin-top: 30px; } html * + .offset-top-4 { margin-top: 24px; } html .page * + .offset-top-5 { margin-top: 60px; } .progress-linear + .button { margin-top: 55px; } .row + .group { margin-top: 45px; } } @media (min-width: 1200px) { * + .button, * + .button-outer { margin-top: 40px; } * + .rd-form { margin-top: 40px; } h3 + p, .heading-3 + p, h3 + .p, .heading-3 + .p { margin-top: 20px; } html * + .offset-top-1 { margin-top: 70px; } .container + .container { margin-top: 60px; } .row + .row { margin-top: 60px; } } @media (min-width: 1600px) { h3 + .row, .heading-3 + .row, h3 + .owl-carousel, .heading-3 + .owl-carousel { margin-top: 75px; } html * + .offset-top-1 { margin-top: 144px; } html * + .offset-top-2 { margin-top: 50px; } .row + .button-lg { margin-top: 70px; } } .row-0 { margin-bottom: 0px; } .row-0:empty { margin-bottom: 0; } .row-0 > * { margin-bottom: 0px; } .row-10 { margin-bottom: -10px; } .row-10:empty { margin-bottom: 0; } .row-10 > * { margin-bottom: 10px; } .row-x-10 { margin-bottom: -10px; margin-left: -5px; margin-right: -5px; } .row-x-10:empty { margin-bottom: 0; } .row-x-10 > * { margin-bottom: 10px; } .row-x-10 > [class*='col'] { padding-left: 5px; padding-right: 5px; } .row-20 { margin-bottom: -20px; } .row-20:empty { margin-bottom: 0; } .row-20 > * { margin-bottom: 20px; } .row-30 { margin-bottom: -30px; } .row-30:empty { margin-bottom: 0; } .row-30 > * { margin-bottom: 30px; } .row-50 { margin-bottom: -50px; } .row-50:empty { margin-bottom: 0; } .row-50 > * { margin-bottom: 50px; } .row-60 { margin-bottom: -50px; } .row-60:empty { margin-bottom: 0; } .row-60 > * { margin-bottom: 50px; } .row-45 { margin-bottom: -45px; } .row-45:empty { margin-bottom: 0; } .row-45 > * { margin-bottom: 45px; } @media (min-width: 768px) { .row-md-50 { margin-bottom: -50px; } .row-md-50:empty { margin-bottom: 0; } .row-md-50 > * { margin-bottom: 50px; } } @media (min-width: 992px) { .row-md-30 { margin-bottom: -30px; } .row-md-30:empty { margin-bottom: 0; } .row-md-30 > * { margin-bottom: 30px; } } @media (min-width: 1600px) { .row-xxl-70 { margin-bottom: -70px; } .row-xxl-70:empty { margin-bottom: 0; } .row-xxl-70 > * { margin-bottom: 70px; } } /* +* +* Sections +*/ .section-xs { padding: 30px 0; } .section-sm { padding: 35px 0; } .section-sm.section-first { padding-top: 65px; } .section-sm.section-last { padding-bottom: 80px; } .section-md, .section-lg, .section-xl { padding: 50px 0; } .section-1 { padding: 35px 0; } .section-collapse + .section-collapse { padding-top: 0; } .section-collapse:last-child { padding-bottom: 0; } html [class*='section-'].section-bottom-0 { padding-bottom: 0; } @media (min-width: 576px) { .section-xs { padding: 40px 0; } } @media (min-width: 768px) { .section-sm { padding: 45px 0; } .section-sm.section-first { padding-top: 90px; } .section-sm.section-last { padding-bottom: 90px; } .section-md { padding: 60px 0; } .section-lg { padding: 72px 0; } .section-lg.section-first { padding-top: 100px; } .section-lg.section-last { padding-bottom: 100px; } .section-xl { padding: 80px 0; } .section-1 { padding: 60px 0; } } @media (min-width: 992px) { .section-md { padding: 60px 0 70px; } .section-xl { padding: 100px 0; } .section-1 { padding: 80px 0; } } @media (min-width: 1200px) { .section-sm { padding: 60px 0; } .section-sm.section-first { padding-top: 80px; } .section-sm.section-last { padding-bottom: 80px; } } @media (min-width: 1600px) { .section-sm.section-first { padding-top: 120px; } .section-sm.section-last { padding-bottom: 120px; } .section-md { padding: 85px 0 95px; } .section-lg { padding: 120px 0; } .section-lg.section-first { padding-top: 120px; } .section-lg.section-last { padding-bottom: 120px; } .section-xl { padding: 160px 0; } .section-1 { padding: 90px 0 100px; } } .section-single { display: flex; } .section-single::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none; background: rgba(21, 21, 21, 0.4); } .section-single * + .rd-mailform-wrap { margin-top: 35px; } .section-single * + .countdown-wrap { margin-top: 30px; } .section-single .countdown-wrap + * { margin-top: 35px; } .section-single-dummy { visibility: hidden; pointer-events: none; } .section-single-header { padding: calc(1em + 3vh) 0 calc(1em + 2vh); } .section-single-main { padding: calc(1em + 4vh) 0; } .section-single-footer { padding: calc(1em + 2vh) 0 calc(1em + 3vh); } .section-single-inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: space-between; width: 100%; min-height: 100vh; padding-top: 6vh; } .section-single-inner > * { width: 100%; } @supports (display: grid) { .section-single-inner { display: grid; justify-items: center; align-content: space-between; grid-template-columns: 1fr; } } @media (max-width: 575.98px) { .section-single .section-single-main-content { max-width: 240px; margin-left: auto; margin-right: auto; } } @media (max-width: 767.98px) { .section-single .button-lg { font-size: 15px; padding-left: 25px; padding-right: 25px; } } @media (min-width: 768px) { .section-single::before { display: none; } .section-single-inner { padding-top: 7vh; } } @media (min-width: 1200px) { .section-single * + .countdown-wrap { margin-top: 50px; } .section-single .countdown-wrap + * { margin-top: 50px; } } .section-overlap { position: relative; padding: 40px 0; } .section-overlap .row { flex-wrap: nowrap; flex-direction: column; align-items: flex-end; } .section-overlap [class*='col'] { flex-basis: auto; } .section-overlap * + [class*='col'] { margin-top: 10px; } .section-overlap * + .col-offset-1 { margin-top: 20px; } .section-overlap-image { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-size: cover; background-position: center center; background-repeat: no-repeat; } .section-overlap-content { position: relative; z-index: 1; } @media (max-width: 767.98px) { .section-overlap-content .container { padding: 20px 15px; background: linear-gradient(to right, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.8)); } .section-overlap-content .container > * { max-width: 380px; } } @media (max-width: 767.98px) and (min-width: 576px) { .section-overlap-content .container { padding: 40px 50px; } } @media (min-width: 768px) and (max-width: 991.98px) { .section-overlap-image::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; pointer-events: none; background: linear-gradient(to left, rgba(245, 246, 250, 0.4), rgba(245, 246, 250, 0)); } } @media (min-width: 768px) { .section-overlap { padding: 75px 0; } .section-overlap * + [class*='col'] { margin-top: 20px; } .section-overlap * + .col-offset-1 { margin-top: 40px; } .section-overlap-image { background-position: 80% 50%; width: calc(50% - 60px + 35px); } } @media (min-width: 992px) { .section-overlap { padding: 100px 0; } .section-overlap-image { width: calc(50% - -80px - 45px); } .ie-10 .section-overlap .col-lg-7, .ie-11 .section-overlap .col-lg-7 { max-width: 64%; } } @media (min-width: 1200px) { .section-overlap-image { width: calc(50% - -100px - 15px); } } @media (min-width: 1600px) { .section-overlap { padding: 140px 0; } } .section-halfscreen { position: relative; } .section-halfscreen-inner { padding: 40px 0; } .section-halfscreen-image { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-size: cover; background-position: center center; background-repeat: no-repeat; } .section-halfscreen-image::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; pointer-events: none; background: linear-gradient(to right, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.4)); } .section-halfscreen-content { position: relative; z-index: 1; } @media (max-width: 767.98px) { .section-halfscreen-content { max-width: 360px; } } @media (min-width: 576px) and (max-width: 767.98px) { .section-halfscreen-image::before { opacity: .8; } } @media (min-width: 768px) { .section-halfscreen-inner { padding: 75px 0; } .section-halfscreen-image { background-position: 45% 50%; left: auto; width: calc(50% - 60px + 35px); } .section-halfscreen-image::before { display: none; } } @media (min-width: 992px) { .section-halfscreen-inner { padding: 100px 0; } .section-halfscreen-image { width: 50%; } } @media (min-width: 1200px) { .section-halfscreen { display: flex; } .section-halfscreen-inner { display: flex; align-items: center; width: 100%; min-height: 35.41667vw; padding: 90px 0; } .section-halfscreen-content { width: 100%; } } /* +* +* Grid modules +*/ .range { display: flex; flex-wrap: wrap; } .cell-inner { width: 100%; padding-right: 15px; padding-left: 15px; } .range > [class*='cell'] { position: relative; width: 100%; min-height: 1px; } .range > [class*='cell'] > .row { margin: 0; } .range > [class*='cell'].container { padding: 0; } .range > [class*='col'] { padding: 0; } @media (min-width: 576px) { .cell-sm-6 { flex: 0 0 50%; max-width: 50%; } .cell-sm-6 .cell-inner { max-width: 270px; } .range > [class*='cell-sm']:nth-child(odd) .cell-inner { margin-left: auto; } .range.flex-sm-row-reverse > [class*='cell-sm']:nth-child(odd) .cell-inner { margin-left: 0; } .range.flex-sm-row-reverse > [class*='cell-sm']:nth-child(even) .cell-inner { margin-left: auto; } .range > [class*='cell-sm'].container { margin: 0; } } @media (min-width: 768px) { .range > [class*='cell-md']:nth-child(odd) .cell-inner { margin-left: auto; } .range.flex-md-row-reverse > [class*='cell-md']:nth-child(odd) .cell-inner { margin-left: 0; } .range.flex-md-row-reverse > [class*='cell-md']:nth-child(even) .cell-inner { margin-left: auto; } .range > [class*='cell-md'].container { margin: 0; } } @media (min-width: 992px) { [class*='cell-lg'] > .row > [class*='col'] { flex-basis: 100%; } .cell-lg-5 { flex: 0 0 calc(50% - 80px); max-width: calc(50% - 80px); } .cell-lg-5 .cell-inner { max-width: 400px; } .cell-lg-6 { flex: 0 0 50%; max-width: 50%; } .cell-lg-6 .cell-inner { max-width: 480px; } .range > [class*='cell-lg']:nth-child(odd) .cell-inner { margin-left: auto; } .range.flex-lg-row-reverse > [class*='cell-lg']:nth-child(odd) .cell-inner { margin-left: 0; } .range.flex-lg-row-reverse > [class*='cell-lg']:nth-child(even) .cell-inner { margin-left: auto; } .range > [class*='cell-lg'].container { margin: 0; } } @media (min-width: 1200px) { .range > [class*='cell-xl'].container { margin: 0; } .range > [class*='cell-xl']:nth-child(odd) .cell-inner { margin-left: auto; } .range .cell-inner { flex-basis: 100%; } .cell-xl-7 { flex: 0 0 calc(50% - -100px); max-width: calc(50% - -100px); } .cell-xl-7 .cell-inner { max-width: 700px; } .cell-lg-6, .cell-xl-6 { flex: 0 0 50%; max-width: 50%; } .cell-lg-6 .cell-inner, .cell-xl-6 .cell-inner { max-width: 600px; } .cell-xl-5 { flex: 0 0 calc(50% - 100px); max-width: calc(50% - 100px); } .cell-xl-5 .cell-inner { max-width: 500px; } .cell-xl-4 { flex: 0 0 calc(50% - 200px); max-width: calc(50% - 200px); } .cell-xl-4 .cell-inner { max-width: 400px; } } @media (min-width: 1600px) { .cell-xxl-6 { flex: 0 0 50%; max-width: 50%; } .cell-xxl-6 .cell-inner { max-width: 600px; } } /* +* +* Backgrounds +*/ .context-dark, .bg-gray-700, .bg-gray-800, .bg-primary, .bg-primary-darker, .context-dark h1, .bg-gray-700 h1, .bg-gray-800 h1, .bg-primary h1, .bg-primary-darker h1, .context-dark h2, .bg-gray-700 h2, .bg-gray-800 h2, .bg-primary h2, .bg-primary-darker h2, .context-dark h3, .bg-gray-700 h3, .bg-gray-800 h3, .bg-primary h3, .bg-primary-darker h3, .context-dark h4, .bg-gray-700 h4, .bg-gray-800 h4, .bg-primary h4, .bg-primary-darker h4, .context-dark h5, .bg-gray-700 h5, .bg-gray-800 h5, .bg-primary h5, .bg-primary-darker h5, .context-dark h6, .bg-gray-700 h6, .bg-gray-800 h6, .bg-primary h6, .bg-primary-darker h6, .context-dark [class^='heading-'], .bg-gray-700 [class^='heading-'], .bg-gray-800 [class^='heading-'], .bg-primary [class^='heading-'], .bg-primary-darker [class^='heading-'] { color: #fff; } .context-dark p a, .bg-gray-700 p a, .bg-gray-800 p a, .bg-primary p a, .bg-primary-darker p a { color: inherit; } .context-dark .big, .bg-gray-700 .big, .bg-gray-800 .big, .bg-primary .big, .bg-primary-darker .big { color: #fff; } .context-dark .brand .brand-logo-dark, .bg-gray-700 .brand .brand-logo-dark, .bg-gray-800 .brand .brand-logo-dark, .bg-primary .brand .brand-logo-dark, .bg-primary-darker .brand .brand-logo-dark { display: none; } .context-dark .brand .brand-logo-light, .bg-gray-700 .brand .brand-logo-light, .bg-gray-800 .brand .brand-logo-light, .bg-primary .brand .brand-logo-light, .bg-primary-darker .brand .brand-logo-light { display: block; } .context-dark .button-primary:hover, .bg-gray-700 .button-primary:hover, .bg-gray-800 .button-primary:hover, .bg-primary .button-primary:hover, .bg-primary-darker .button-primary:hover { background: #4854ed; border-color: #4854ed; } /** +* Light Backgrounds +*/ .bg-gray-100 { background-color: #f5f6fa; } /** +* Dark Backgrounds +*/ .bg-gray-700 { color: #aeb1be; background-color: #232426; } .bg-gray-700 a { color: inherit; } .bg-gray-700 a:hover { color: #fff; } .bg-gray-700 p a:hover { color: #fff; } .bg-gray-800 { background-color: #151515; } /** +* Accent Backgrounds +*/ .bg-primary { background-color: #0045b6; } .bg-primary-darker { background-color: #29293a; } /** +* Background Image +*/ [class*='bg-'] { background-size: cover; background-position: center center; } @media (max-width: 1199.98px) { .bg-image-light { position: relative; } .bg-image-light::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(255, 255, 255, 0.1); } } .bg-image-1 { position: relative; background-position: 30% 100%; } .bg-image-1::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: #495057; opacity: .6; } .bg-image-1 > * { position: relative; z-index: 1; } @media (min-width: 576px) { .bg-image-1 { background-position: 72% 100%; } .bg-image-1::before { opacity: .2; } } @media (min-width: 768px) { .bg-image-1 { background-position: 54% 100%; } .bg-image-1::before { display: none; } } @media (min-width: 992px) { html:not(.tablet):not(.mobile) .bg-fixed { background-attachment: fixed; } } .primary-overlay { position: relative; } .primary-overlay:before { position: absolute; content: ''; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(21, 21, 21, 0.45); pointer-events: none; z-index: 1; display: block; } /* +* +* Utilities custom +*/ .height-fill { position: relative; display: flex; flex-direction: column; align-items: stretch; } .height-fill > * { flex-grow: 1; flex-shrink: 0; } .object-inline { white-space: nowrap; } .object-inline > * { display: inline-block; min-width: 20px; vertical-align: top; white-space: normal; } .object-inline.wow span { display: inline-block; } .object-inline > * + * { margin-left: 11px; } .wow-outer .object-inline span { display: inline-block; } .oh { position: relative; overflow: hidden; } .text-decoration-lines { position: relative; overflow: hidden; width: 100%; text-align: center; } .text-decoration-lines-content { position: relative; display: inline-block; padding: 0 20px; } .text-decoration-lines-content::before, .text-decoration-lines-content::after { content: ''; position: absolute; height: 1px; background: #aeb1be; top: 50%; width: 100vw; } .text-decoration-lines-content::before { left: 0; transform: translate3d(-100%, 0, 0); } .text-decoration-lines-content::after { right: 0; transform: translate3d(100%, 0, 0); } * + .text-decoration-lines { margin-top: 30px; } .text-block > * { margin-left: .125em; margin-right: .125em; } @media (min-width: 992px) { .offset-right-1 { margin-right: 40px; } } .banner { background-position: center center; background-size: cover; } .phone-frame { max-width: 296px; padding: 0 5px; margin-left: auto; margin-right: auto; box-shadow: -46.037px 40.02px 21px 0px rgba(0, 0, 0, 0.02); } @media (max-width: 767.98px) { .phone-frame { display: none; } } .tip-mark { display: inline-flex; align-items: center; justify-content: center; width: 2em; height: 2em; padding-left: 2px; margin-bottom: 5px; font-size: 12px; line-height: 2em; vertical-align: middle; text-align: center; border-radius: 50%; border: 1px solid #e8e9ee; color: #0045b6; } /* +* +* Insets +*/ @media (min-width: 576px) { .inset-left-1 { padding-left: 10px; } } @media (min-width: 768px) { .inset-left-1 { padding-left: 30px; } } @media (min-width: 992px) { .inset-left-1 { padding-left: 0; } } @media (min-width: 992px) and (max-width: 1199.98px) { .inset-left-2 { padding-left: 30px; } } @media (min-width: 576px) { .inset-right-1 { padding-right: 20px; } } @media (min-width: 768px) { .inset-right-1 { padding-right: 30px; } } @media (min-width: 992px) and (max-width: 1199.98px) { .inset-right-1 { padding-right: 0; } } @media (min-width: 992px) { .inset-right-2 { padding-right: 30px; } } @media (min-width: 1200px) { .inset-right-2 { padding-right: 0; } } @media (min-width: 992px) { .inset-right-3 { padding-left: 5px; } } @media (min-width: 1200px) { .inset-right-3 { padding-right: 0; } } /* +* +* Animate.css +*/ .animated { animation-duration: 1s; animation-fill-mode: both; opacity: 1; } .animated.infinite { animation-iteration-count: infinite; } .animated.hinge { animation-duration: 2s; } html:not(.lt-ie10) .not-animated { opacity: 0; } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } .fadeIn { animation-name: fadeIn; } @keyframes fadeInUp { 0% { opacity: 0; transform: translate3d(0, 100%, 0); } 100% { opacity: 1; transform: none; } } .fadeInUp { animation-name: fadeInUp; } @keyframes fadeInDown { 0% { opacity: 0; transform: translate3d(0, -100%, 0); } 100% { opacity: 1; transform: none; } } .fadeInDown { animation-name: fadeInDown; } @keyframes fadeInLeft { 0% { opacity: 0; transform: translate3d(-100%, 0, 0); } 100% { opacity: 1; transform: none; } } .fadeInLeft { animation-name: fadeInLeft; } @keyframes fadeInRight { 0% { opacity: 0; transform: translate3d(100%, 0, 0); } 100% { opacity: 1; transform: none; } } .fadeInRight { animation-name: fadeInRight; } @keyframes fadeOut { 0% { opacity: 1; } 100% { opacity: 0; } } .fadeOut { animation-name: fadeOut; } @keyframes slideInDown { 0% { transform: translate3d(0, -100%, 0); visibility: visible; } 100% { transform: translate3d(0, 0, 0); } } .slideInDown { animation-name: slideInDown; } @keyframes slideInUp { 0% { transform: translate3d(0, 100%, 0); visibility: visible; } 100% { transform: translate3d(0, 0, 0); } } .slideInUp { animation-name: slideInUp; } @keyframes slideInLeft { 0% { transform: translate3d(-100%, 0, 0); visibility: visible; } 100% { transform: translate3d(0, 0, 0); } } .slideInLeft { animation-name: slideInLeft; } @keyframes slideInRight { 0% { transform: translate3d(100%, 0, 0); visibility: visible; } 100% { transform: translate3d(0, 0, 0); } } .slideInRight { animation-name: slideInRight; } @keyframes slideOutDown { 0% { transform: translate3d(0, 0, 0); } 100% { visibility: hidden; transform: translate3d(0, 100%, 0); } } .slideOutDown { animation-name: slideOutDown; } @keyframes scaleInY { 0% { top: 0; bottom: 100%; } 50% { transform: scale3d(1, 1, 1); } 100% { top: 100%; bottom: auto; transform: scale3d(1, 0, 1); } } @keyframes scaleFadeIn { 0% { height: 0; } 100% { height: 100%; } } .scaleFadeIn { display: flex; overflow: hidden; animation-name: scaleFadeIn; animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); } .scaleFadeInWrap { display: flex; align-items: stretch; } .wow-outer { position: relative; overflow: hidden; } @keyframes scaleInVertical { 0% { transform: scale3d(1, 0, 1); } 100% { transform: scale3d(1, 1, 1); } } .scaleInVertical { animation-name: scaleInVertical; } @keyframes fadeInLeftSmall { 0% { opacity: 0; transform: translate3d(-20px, 0, 0); } 100% { opacity: 1; transform: none; } } .fadeInLeftSmall { animation-name: fadeInLeftSmall; } @keyframes fadeInUpSmall { 0% { opacity: 0; transform: translate3d(0, 20px, 0); } 100% { opacity: 1; transform: none; } } .fadeInUpSmall { animation-name: fadeInUpSmall; } @keyframes fadeSlideInDown { 0% { opacity: 0; transform: translate3d(0, -50%, 0); visibility: visible; } 10% { opacity: 1; } 100% { transform: translate3d(0, 0, 0); } } .fadeSlideInDown { animation-name: fadeSlideInDown; } @keyframes fadeSlideInUp { 0% { opacity: 0; transform: translate3d(0, 50%, 0); visibility: visible; } 10% { opacity: 1; } 100% { transform: translate3d(0, 0, 0); } } .fadeSlideInUp { animation-name: fadeSlideInUp; } .wow { backface-visibility: hidden; } /* +* +* Preloader +*/ .preloader { position: fixed; left: 0; top: 0; bottom: 0; right: 0; z-index: 10000; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; transition: .3s all ease; pointer-events: none; backface-visibility: hidden; } .preloader::before, .preloader::after { content: ''; position: absolute; left: 0; right: 0; transition: .2s linear; transform: translateY(0); pointer-events: none; } .preloader::before { top: 0; bottom: 50%; background: #fff; } .preloader::after { top: 50%; bottom: 0; background: #f5f6fa; } .preloader.loaded::before, .preloader.loaded::after { transition: .4s linear; } .preloader.loaded::before { transform: translateY(-100%); } .preloader.loaded::after { transform: translateY(100%); } .preloader.loaded .preloader-logo, .preloader.loaded .preloader-body { opacity: 0; visibility: hidden; transition: 0s; } .preloader-logo, .preloader-body { transition: 0s .2s; } .preloader-logo { position: relative; z-index: 10; transform: translate3d(0, -50%, 0); padding: 40px; } .preloader-body { position: absolute; top: 50%; transform: translate3d(0, -50%, 0); left: 0; right: 0; z-index: 10; text-align: center; } #loadingProgressG { width: 100vw; height: 5px; overflow: hidden; background: #e8e9ee; border-radius: 6px; margin: auto; } .loadingProgressG { background: #0045b6; margin-top: 0; margin-left: -100vw; animation-name: bounce_loadingProgressG; animation-duration: 2.5s; animation-iteration-count: infinite; animation-timing-function: linear; width: 100vw; height: 5px; transition: .5s; } @keyframes bounce_loadingProgressG { 0% { margin-left: -100vw; } 100% { margin-left: 100vw; } } /* +* +* ToTop +*/ .ui-to-top { position: fixed; right: 15px; bottom: 15px; z-index: 100; width: 48px; height: 48px; border-radius: 6px; color: #fff; background: #0045b6; overflow: hidden; text-align: center; text-decoration: none; transition: transform 0.45s ease-in-out, background 0.3s cubic-bezier(0.2, 1, 0.3, 1); transform: translate3d(0, 100px, 0); } .ui-to-top::before, .ui-to-top::after { content: '\f239'; font-family: 'Material Design Icons'; font-size: 20px; line-height: 48px; vertical-align: middle; transition: transform 0.3s cubic-bezier(0.2, 1, 0.3, 1), opacity 0.3s cubic-bezier(0.2, 1, 0.3, 1); } .ui-to-top::before { display: block; } .ui-to-top::after { position: absolute; width: 100%; top: 50%; left: 0; opacity: 0; transform: translate3d(0, 0, 0); } .ui-to-top:hover::before { opacity: 0; transform: translate3d(0, -30%, 0); } .ui-to-top:hover::after { opacity: 1; transform: translate3d(0, 0, 0) translateY(-50%); } .ui-to-top:hover { color: #fff; background: #4854ed; text-decoration: none; } .ui-to-top:focus { color: #fff; } .ui-to-top.active { transform: translate3d(0, 0, 0); } html.mobile .ui-to-top, html.tablet .ui-to-top { display: none !important; } @media (min-width: 576px) { .ui-to-top { right: 40px; bottom: 40px; } } /* +* +* RD Navbar +*/ @keyframes rd-navbar-slide-down { 0% { transform: translateY(-100%); } 100% { transform: translateY(0); } } @keyframes rd-navbar-slide-up { 0% { transform: translateY(0); } 100% { transform: translateY(-100%); } } .rd-navbar-wrap, .rd-navbar, .rd-menu, .rd-navbar-nav, .rd-navbar-panel, .rd-navbar-static .rd-menu, .rd-navbar-fixed .rd-navbar-nav-wrap, .rd-navbar-fixed .rd-navbar-submenu { transition: 0.35s all cubic-bezier(0.65, 0.05, 0.36, 1); } .rd-navbar--no-transition, .rd-navbar--no-transition * { transition: none !important; } .rd-navbar, .rd-navbar.rd-navbar--is-clone { display: none; } .rd-navbar.rd-navbar-fixed + .rd-navbar.rd-navbar--is-clone, .rd-navbar.rd-navbar-sidebar + .rd-navbar.rd-navbar--is-clone { display: none; } .rd-navbar { display: none; background: #fff; } .rd-navbar-outer { position: relative; z-index: 1080; } .rd-nav-link { font: 400 14px/20px "Poppins", sans-serif; letter-spacing: .08em; text-transform: uppercase; } .rd-navbar-toggle { display: inline-block; position: relative; width: 40px; height: 40px; line-height: 40px; cursor: pointer; color: #495057; background-color: transparent; border: none; display: none; } .rd-navbar-toggle span { position: relative; display: block; margin: auto; transition: .3s all ease; } .rd-navbar-toggle span::after, .rd-navbar-toggle span::before { content: ""; position: absolute; left: 0; top: -6px; transition: .3s all ease; } .rd-navbar-toggle span::after { top: 5px; } .rd-navbar-toggle span::after, .rd-navbar-toggle span::before, .rd-navbar-toggle span { width: 24px; height: 2px; background-color: #495057; backface-visibility: hidden; border-radius: 5; } .rd-navbar-toggle.active span { background: transparent; transition: .01s; } .rd-navbar-toggle.active span::before, .rd-navbar-toggle.active span::after { transform-origin: 50% 50%; top: 0; } .rd-navbar-toggle.active span::before { transform: rotate(45deg); } .rd-navbar-toggle.active span::after { transform: rotate(-45deg); } .rd-navbar-collapse-toggle { background: none; border: none; display: inline-block; padding: 0; outline: none; outline-offset: 0; cursor: pointer; -webkit-appearance: none; display: inline-block; position: relative; width: 40px; height: 40px; line-height: 40px; cursor: pointer; color: #495057; display: none; } .rd-navbar-collapse-toggle::-moz-focus-inner { border: none; padding: 0; } .rd-navbar-collapse-toggle span { top: 50%; margin-top: -2.5px; } .rd-navbar-collapse-toggle span, .rd-navbar-collapse-toggle span::before, .rd-navbar-collapse-toggle span::after { position: absolute; width: 5px; height: 5px; line-height: 5px; text-align: center; background: #495057; left: 50%; margin-left: -2.5px; border-radius: 50%; transition: .3s all ease; } .rd-navbar-collapse-toggle span::before, .rd-navbar-collapse-toggle span::after { content: ''; } .rd-navbar-collapse-toggle span::before { bottom: 100%; margin-bottom: 2.5px; } .rd-navbar-collapse-toggle span::after { top: 100%; margin-top: 2.5px; } .rd-navbar-collapse-toggle.active span { transform: scale(0.7); } .rd-navbar-collapse-toggle.active span::before { transform: translateY(15px); } .rd-navbar-collapse-toggle.active span::after { transform: translateY(-15px); } .rd-navbar-brand a { display: block; } .rd-navbar-search { position: relative; display: inline-flex; } .rd-navbar-search .form-input { padding-right: 50px; } .rd-navbar-search .rd-search-form-submit { position: absolute; top: 0; bottom: 0; right: 0; width: 50px; padding: 0; margin: 0; border: none; cursor: pointer; background-color: transparent; font-size: 20px; color: #aeb1be; transition: .33s; } .rd-navbar-search .rd-search-form-submit:hover { color: #0045b6; } .rd-navbar-search .rd-search-results-live { pointer-events: none; } .rd-navbar-search .rd-search-results-live > * { pointer-events: auto; } .rd-navbar-search-toggle { background: none; border: none; display: inline-block; padding: 0; outline: none; outline-offset: 0; cursor: pointer; -webkit-appearance: none; } .rd-navbar-search-toggle::-moz-focus-inner { border: none; padding: 0; } .rd-navbar-dropdown { display: none; } .rd-navbar-popup { position: absolute; padding: 15px 25px 20px 20px; width: calc(100vw - 10px); border-radius: 6px; opacity: 0; visibility: hidden; transition: .2s; overflow: hidden; background-color: #495057; } .rd-navbar-popup::before { content: ''; position: absolute; right: 0; left: 0; top: 0; bottom: 0; z-index: -1; transform: scale3d(1, 0.1, 1); transform-origin: 50% 0; background: #495057; border-radius: inherit; transition: .5s; } .rd-navbar-popup > * { position: relative; z-index: 1; opacity: 0; visibility: hidden; transition: .5s; transform-origin: 50% 0; transform: scale3d(1, 0.9, 1); } .rd-navbar-popup.active { opacity: 1; visibility: visible; } .rd-navbar-popup.active > * { opacity: 1; visibility: visible; transform: scale3d(1, 1, 1); } .rd-navbar-popup.active::before { transform: scale3d(1, 1, 1); } @media (min-width: 400px) { .rd-navbar-popup { width: 370px; } } @media (min-width: 768px) { .rd-navbar-popup { padding: 40px; } } .context-dark .rd-navbar-toggle, .bg-gray-700 .rd-navbar-toggle, .bg-gray-800 .rd-navbar-toggle, .bg-primary .rd-navbar-toggle, .bg-primary-darker .rd-navbar-toggle { display: inline-block; position: relative; width: 40px; height: 40px; line-height: 40px; cursor: pointer; color: #fff; display: none; } .context-dark .rd-navbar-toggle span, .bg-gray-700 .rd-navbar-toggle span, .bg-gray-800 .rd-navbar-toggle span, .bg-primary .rd-navbar-toggle span, .bg-primary-darker .rd-navbar-toggle span { position: relative; display: block; margin: auto; transition: .3s all ease; } .context-dark .rd-navbar-toggle span::after, .bg-gray-700 .rd-navbar-toggle span::after, .bg-gray-800 .rd-navbar-toggle span::after, .bg-primary .rd-navbar-toggle span::after, .bg-primary-darker .rd-navbar-toggle span::after, .context-dark .rd-navbar-toggle span::before, .bg-gray-700 .rd-navbar-toggle span::before, .bg-gray-800 .rd-navbar-toggle span::before, .bg-primary .rd-navbar-toggle span::before, .bg-primary-darker .rd-navbar-toggle span::before { content: ""; position: absolute; left: 0; top: -6px; transition: .3s all ease; } .context-dark .rd-navbar-toggle span::after, .bg-gray-700 .rd-navbar-toggle span::after, .bg-gray-800 .rd-navbar-toggle span::after, .bg-primary .rd-navbar-toggle span::after, .bg-primary-darker .rd-navbar-toggle span::after { top: 5px; } .context-dark .rd-navbar-toggle span::after, .bg-gray-700 .rd-navbar-toggle span::after, .bg-gray-800 .rd-navbar-toggle span::after, .bg-primary .rd-navbar-toggle span::after, .bg-primary-darker .rd-navbar-toggle span::after, .context-dark .rd-navbar-toggle span::before, .bg-gray-700 .rd-navbar-toggle span::before, .bg-gray-800 .rd-navbar-toggle span::before, .bg-primary .rd-navbar-toggle span::before, .bg-primary-darker .rd-navbar-toggle span::before, .context-dark .rd-navbar-toggle span, .bg-gray-700 .rd-navbar-toggle span, .bg-gray-800 .rd-navbar-toggle span, .bg-primary .rd-navbar-toggle span, .bg-primary-darker .rd-navbar-toggle span { width: 24px; height: 2px; background-color: #fff; backface-visibility: hidden; border-radius: 5; } .context-dark .rd-navbar-toggle.active span, .bg-gray-700 .rd-navbar-toggle.active span, .bg-gray-800 .rd-navbar-toggle.active span, .bg-primary .rd-navbar-toggle.active span, .bg-primary-darker .rd-navbar-toggle.active span { background: transparent; transition: .01s; } .context-dark .rd-navbar-toggle.active span::before, .bg-gray-700 .rd-navbar-toggle.active span::before, .bg-gray-800 .rd-navbar-toggle.active span::before, .bg-primary .rd-navbar-toggle.active span::before, .bg-primary-darker .rd-navbar-toggle.active span::before, .context-dark .rd-navbar-toggle.active span::after, .bg-gray-700 .rd-navbar-toggle.active span::after, .bg-gray-800 .rd-navbar-toggle.active span::after, .bg-primary .rd-navbar-toggle.active span::after, .bg-primary-darker .rd-navbar-toggle.active span::after { transform-origin: 50% 50%; top: 0; } .context-dark .rd-navbar-toggle.active span::before, .bg-gray-700 .rd-navbar-toggle.active span::before, .bg-gray-800 .rd-navbar-toggle.active span::before, .bg-primary .rd-navbar-toggle.active span::before, .bg-primary-darker .rd-navbar-toggle.active span::before { transform: rotate(45deg); } .context-dark .rd-navbar-toggle.active span::after, .bg-gray-700 .rd-navbar-toggle.active span::after, .bg-gray-800 .rd-navbar-toggle.active span::after, .bg-primary .rd-navbar-toggle.active span::after, .bg-primary-darker .rd-navbar-toggle.active span::after { transform: rotate(-45deg); } /* +* @subsection RD Navbar Static +*/ .rd-navbar-static { display: block; position: relative; } .rd-navbar-static .rd-nav-item { display: inline-block; } .rd-navbar-static .rd-nav-item.focus .rd-nav-link, .rd-navbar-static .rd-nav-item.opened .rd-nav-link { color: rgba(255, 255, 255, 0.5); background: transparent; } .rd-navbar-static .rd-nav-item.focus > .rd-navbar-submenu-toggle, .rd-navbar-static .rd-nav-item.opened > .rd-navbar-submenu-toggle { color: #fff; } .rd-navbar-static .rd-nav-item.active .rd-nav-link { color: rgba(255, 255, 255, 0.5); background: transparent; } .rd-navbar-static .rd-nav-item.active > .rd-navbar-submenu-toggle { color: #fff; } .rd-navbar-static .rd-nav-item.focus > .rd-navbar-submenu-toggle::before, .rd-navbar-static .rd-nav-item.opened > .rd-navbar-submenu-toggle::before, .rd-navbar-static .rd-nav-item .rd-nav-link:hover + .rd-navbar-submenu-toggle::before { transform: rotate(180deg); color: rgba(255, 255, 255, 0.5); } .rd-navbar-static .rd-nav-item > .rd-navbar-submenu-toggle { margin-left: 4px; font-family: "Material Design Icons"; font-size: 16px; cursor: pointer; color: #fff; } .rd-navbar-static .rd-nav-item > .rd-navbar-submenu-toggle::before { position: relative; display: inline-block; transition: .22s; content: '\f236'; } .rd-navbar-static .rd-nav-item > .rd-navbar-submenu-toggle:hover { color: rgba(255, 255, 255, 0.4); } .rd-navbar-static .rd-nav-item > .rd-navbar-submenu { margin-top: 20px; } .rd-navbar-static .rd-nav-item + .rd-nav-item { margin-left: 35px; } .rd-navbar-static .rd-nav-link { position: relative; display: inline-block; color: #fff; transition: .25s; padding-bottom: 2px; } .rd-navbar-static .rd-nav-link:hover { color: rgba(255, 255, 255, 0.4); } .rd-navbar-static .rd-menu { position: absolute; z-index: 15; display: block; padding: 16px 27px; margin-top: 22px; margin-left: -27px; visibility: hidden; opacity: 0; text-align: left; box-shadow: 0 5px 10px 1px rgba(20, 20, 28, 0.3); border-radius: 6px; transform: translate3d(0, 30px, 0); } .rd-navbar-static .rd-dropdown-item + .rd-dropdown-item, .rd-navbar-static .rd-megamenu-list-item + .rd-megamenu-list-item { margin-top: 7px; } .rd-navbar-static .rd-navbar-dropdown { position: absolute; left: 0; width: 290px; background: #232426; z-index: 5; } .rd-navbar-static .rd-navbar-dropdown .rd-navbar-dropdown { top: -1px; left: 100%; z-index: 2; margin-top: -15px; margin-left: 15px; transform: translate3d(30px, 0, 0); } .rd-navbar-static .rd-navbar-dropdown .rd-navbar-dropdown.rd-navbar-open-left { left: auto; right: 100%; margin-left: 0; margin-right: 15px; transform: translate3d(-30px, 0, 0); } .rd-navbar-static .rd-dropdown-item.focus .rd-dropdown-link, .rd-navbar-static .rd-dropdown-item.opened .rd-dropdown-link { color: #fff; } .rd-navbar-static .rd-dropdown-link { color: #aeb1be; } .rd-navbar-static .rd-dropdown-link:hover { color: #fff; } .rd-navbar-static .rd-navbar-dropdown li > a, .rd-navbar-static .rd-megamenu-list li > a { position: relative; left: -13px; display: flex; padding: 4px 18px 4px 0; text-align: left; } .rd-navbar-static .rd-navbar-dropdown li > a, .rd-navbar-static .rd-navbar-dropdown li > a:focus, .rd-navbar-static .rd-navbar-dropdown li > a:active, .rd-navbar-static .rd-megamenu-list li > a, .rd-navbar-static .rd-megamenu-list li > a:focus, .rd-navbar-static .rd-megamenu-list li > a:active { color: #aeb1be; } .rd-navbar-static .rd-navbar-dropdown li > a:hover, .rd-navbar-static .rd-megamenu-list li > a:hover { color: #fff; } .rd-navbar-static .rd-navbar-dropdown li > a, .rd-navbar-static .rd-navbar-dropdown li > a::before, .rd-navbar-static .rd-megamenu-list li > a, .rd-navbar-static .rd-megamenu-list li > a::before { transition: all .2s ease-in-out; } .rd-navbar-static .rd-navbar-dropdown li > a::before, .rd-navbar-static .rd-megamenu-list li > a::before { position: relative; top: -1px; left: -6px; display: inline-block; content: '\f238'; font-family: 'Material Design Icons'; font-size: inherit; line-height: inherit; color: #fff; opacity: 0; visibility: hidden; } .rd-navbar-static .rd-navbar-dropdown li.focus > a, .rd-navbar-static .rd-navbar-dropdown li.opened > a, .rd-navbar-static .rd-navbar-dropdown li > a:hover, .rd-navbar-static .rd-megamenu-list li.focus > a, .rd-navbar-static .rd-megamenu-list li.opened > a, .rd-navbar-static .rd-megamenu-list li > a:hover { left: -13px; padding-left: 18px; padding-right: 0; } .rd-navbar-static .rd-navbar-dropdown li.focus > a::before, .rd-navbar-static .rd-navbar-dropdown li.opened > a::before, .rd-navbar-static .rd-navbar-dropdown li > a:hover::before, .rd-navbar-static .rd-megamenu-list li.focus > a::before, .rd-navbar-static .rd-megamenu-list li.opened > a::before, .rd-navbar-static .rd-megamenu-list li > a:hover::before { left: -7px; opacity: 1; visibility: visible; } .rd-navbar-static .rd-navbar-megamenu { display: flex; left: 0; overflow: hidden; max-width: 700px; width: 100%; background: #232426; } @media (min-width: 768px) { .rd-navbar-static .rd-navbar-megamenu { left: 20px; } } .rd-navbar-static .rd-megamenu-item { flex-basis: 50%; flex-grow: 1; } .rd-navbar-static .rd-megamenu-item + .rd-megamenu-item { padding-left: 20px; } .rd-navbar-static .rd-megamenu-item { display: flex; } .rd-navbar-static .rd-megamenu-item .banner { position: relative; display: flex; align-items: flex-end; justify-content: center; text-align: center; min-width: 100%; padding: 35px; } .rd-navbar-static .rd-megamenu-item .banner::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(0, 0, 0, 0.1); } .rd-navbar-static .rd-megamenu-item .banner .button-primary:hover { background: #4854ed; border-color: #4854ed; } .rd-navbar-static .rd-megamenu-item .banner > * { opacity: 0; visibility: hidden; transform: translate(0, -20%); transition: .22s; } .rd-navbar-static .rd-megamenu-item .banner:hover > * { opacity: 1; visibility: visible; transform: translate(0, 0); } .rd-navbar-static .rd-megamenu-item:first-child .banner, .rd-navbar-static .rd-megamenu-item:last-child .banner { margin-top: -16px; margin-right: -27px; margin-bottom: -16px; margin-left: -27px; } .rd-navbar-static .rd-megamenu-title { padding-bottom: 10px; border-bottom: 1px solid #e8e9ee; } .rd-navbar-static .rd-megamenu-list-link { color: #aeb1be; background: transparent; } .rd-navbar-static .rd-megamenu-list-link:hover { color: #fff; background: transparent; } .rd-navbar-static * + .rd-megamenu-list { margin-top: 10px; } .rd-navbar-static .rd-navbar-submenu.focus > .rd-menu, .rd-navbar-static .rd-navbar-submenu.opened > .rd-menu { opacity: 1; visibility: visible; transform: translate3d(0, 0, 0); } .rd-navbar-static .rd-navbar-search.active .rd-search { visibility: visible; opacity: 1; } .rd-navbar-static .rd-navbar-search.active .not-empty ~ .form-input { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .rd-navbar-static .rd-navbar-search .rd-search { margin-top: 40px; } .rd-navbar-static .rd-navbar-search .form-input { padding-right: 19px; } .rd-navbar-static .rd-navbar-search .rd-search-form-submit { display: none; } .rd-navbar-static .rd-search { position: absolute; top: 100%; right: 0; width: 370px; opacity: 0; visibility: hidden; transition: .3s; margin-top: 20px; z-index: 2; } .rd-navbar-static * + .rd-navbar-search { margin-left: 38px; } .rd-navbar-static .rd-navbar-search-toggle { display: inline-flex; color: #fff; } .rd-navbar-static .rd-navbar-search-toggle span { display: inline-block; position: relative; width: 32px; height: 32px; font-size: 26px; line-height: 32px; text-align: center; cursor: pointer; background: none; border: none; outline: none; padding: 0; } .rd-navbar-static .rd-navbar-search-toggle span, .rd-navbar-static .rd-navbar-search-toggle span::before, .rd-navbar-static .rd-navbar-search-toggle span::after { transition: .3s all ease-in-out; } .rd-navbar-static .rd-navbar-search-toggle span::before, .rd-navbar-static .rd-navbar-search-toggle span::after { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } .rd-navbar-static .rd-navbar-search-toggle span::before { content: ""; transform: rotate(0deg) scale(1); opacity: 1; visibility: visible; font-family: "Material Design Icons"; } .rd-navbar-static .rd-navbar-search-toggle span::after { content: ""; transform: rotate(-90deg) scale(0.4); opacity: 0; visibility: hidden; font-family: "Material Design Icons"; } .rd-navbar-static .rd-navbar-search-toggle.active span::before { opacity: 0; visibility: hidden; transform: rotate(90deg) scale(0.4); } .rd-navbar-static .rd-navbar-search-toggle.active span::after { transform: rotate(0deg) scale(1); opacity: 1; visibility: visible; } .rd-navbar-static .rd-navbar-search-toggle:hover { color: #aeb1be; } @media (min-width: 1200px) { .rd-navbar-static .rd-nav-item + .rd-nav-item { margin-left: 45px; } } .rd-navbar-static.rd-navbar--is-clone { display: block; transform: translate3d(0, -100%, 0); } .rd-navbar-static.rd-navbar--is-clone.rd-navbar--is-stuck { transform: translate3d(0, 0, 0); } .rd-navbar-static.rd-navbar--is-stuck, .rd-navbar-static.rd-navbar--is-clone { position: fixed; left: 0; top: 0; right: 0; z-index: 1080; background: rgba(51, 51, 51, 0.7); will-change: transform; } .rd-navbar-static .rd-navbar--has-dropdown { position: relative; } /* +* +* RD Navbar Fixed +*/ .rd-navbar-fixed { display: block; width: 100%; } .rd-navbar-fixed .rd-navbar-toggle { display: inline-block; } .rd-navbar-fixed .rd-navbar-brand { position: relative; top: -2px; margin-left: 12px; font-size: 0; line-height: 0; text-align: left; } .rd-navbar-fixed .rd-navbar-brand img { max-width: 160px; max-height: 58px; width: auto; height: auto; } .rd-navbar-fixed .rd-navbar-panel { position: fixed; left: 0; top: 0; right: 0; z-index: 1080; display: flex; align-items: center; height: 56px; padding: 8px; } .rd-navbar-fixed .rd-navbar-nav-wrap { position: fixed; left: 0; top: -56px; bottom: -56px; z-index: 1079; width: 270px; padding: 112px 0 81px; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; font-size: 14px; line-height: 34px; transform: translateX(-110%); } .rd-navbar-fixed .rd-navbar-nav-wrap::-webkit-scrollbar { width: 4px; } .rd-navbar-fixed .rd-navbar-nav-wrap::-webkit-scrollbar-thumb { background: #1d73ff; border: none; border-radius: 0; opacity: .2; } .rd-navbar-fixed .rd-navbar-nav-wrap::-webkit-scrollbar-track { background: #0045b6; border: none; border-radius: 0; } .rd-navbar-fixed .rd-navbar-nav-wrap.active { transform: translateX(0); } .rd-navbar-fixed .rd-navbar-nav { display: block; margin: 15px 0; height: auto; text-align: left; } .rd-navbar-fixed .rd-nav-item { text-align: left; } .rd-navbar-fixed .rd-nav-item + .rd-nav-item { margin-top: 4px; } .rd-navbar-fixed .rd-nav-link { display: block; padding: 14px 56px 14px 18px; } .rd-navbar-fixed li.opened > .rd-navbar-dropdown { padding: 4px 0 0; } .rd-navbar-fixed li.opened > .rd-navbar-megamenu { padding-top: 15px; padding-bottom: 15px; } .rd-navbar-fixed li.opened > .rd-menu { opacity: 1; height: auto; } .rd-navbar-fixed li.opened > .rd-navbar-submenu-toggle::after { transform: rotate(180deg); } .rd-navbar-fixed .rd-menu { display: none; transition: opacity 0.3s, height 0.4s ease; opacity: 0; height: 0; overflow: hidden; } .rd-navbar-fixed .rd-navbar-submenu { position: relative; } .rd-navbar-fixed .rd-navbar-submenu .rd-navbar-dropdown .rd-navbar-submenu-toggle:after { height: 34px; line-height: 34px; } .rd-navbar-fixed .rd-navbar-submenu .rd-navbar-dropdown > li > a { padding-left: 30px; } .rd-navbar-fixed .rd-navbar-submenu .rd-navbar-dropdown li li > a, .rd-navbar-fixed .rd-navbar-submenu .rd-navbar-megamenu ul li li > a { padding-left: 46px; } .rd-navbar-fixed .rd-navbar-submenu.opened > .rd-navbar-dropdown, .rd-navbar-fixed .rd-navbar-submenu.opened > .rd-navbar-megamenu { display: block; } .rd-navbar-fixed .rd-megamenu-list > li > a, .rd-navbar-fixed .rd-navbar-dropdown > li > a { display: block; padding: 9px 56px 9px 16px; font-size: 14px; line-height: 1.5; } .rd-navbar-fixed .rd-megamenu-list > li + li, .rd-navbar-fixed .rd-navbar-dropdown > li + li { margin-top: 3px; } .rd-navbar-fixed .rd-megamenu-list > li > a { padding-left: 30px; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title { position: relative; display: block; padding: 0 18px 7px; font-size: 16px; line-height: 1.5; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title::after { content: ''; position: absolute; left: 20px; right: 20px; bottom: 0; border-bottom: 1px solid; } .rd-navbar-fixed .rd-navbar-megamenu * + .rd-megamenu-list { margin-top: 11px; } .rd-navbar-fixed .rd-navbar-megamenu * + .rd-megamenu-title { margin-top: 20px; } .rd-navbar-fixed .rd-navbar-megamenu > li + li { margin-top: 20px; } .rd-navbar-fixed .rd-navbar-submenu-toggle { cursor: pointer; } .rd-navbar-fixed .rd-navbar-submenu-toggle::after { content: '\f107'; position: absolute; top: 0; right: 0; width: 56px; height: 48px; font: 400 14px "FontAwesome"; line-height: 48px; text-align: center; transition: 0.4s all ease; z-index: 2; cursor: pointer; } .rd-navbar-fixed .rd-navbar-collapse-toggle { display: block; top: 8px; z-index: 1081; } .rd-navbar-fixed .rd-navbar-collapse-content { position: fixed; right: 0; top: 56px; z-index: 1079; transform: translate3d(0, -10px, 0); padding: 30px; width: auto; border-radius: 0 0 0 6px; text-align: left; font-size: 14px; opacity: 0; visibility: hidden; transition: .3s; } .rd-navbar-fixed .rd-navbar-collapse-content.active { transform: translate3d(0, 0, 0); opacity: 1; visibility: visible; } .rd-navbar-fixed .rd-navbar-collapse-content > * + * { margin-top: 25px; } .rd-navbar-fixed .rd-navbar-main-outer, .rd-navbar-fixed .rd-navbar-main-element { position: absolute; float: left; width: 100%; } .rd-navbar-fixed .rd-navbar-search .rd-search { position: fixed; right: 0; left: 0; top: 56px; z-index: 1079; width: 100%; opacity: 0; visibility: hidden; transition: .3s; transform: translate3d(0, -100%, 0); } .rd-navbar-fixed .rd-navbar-search .rd-search-results-live { display: none; } .rd-navbar-fixed .rd-navbar-search .form-input { padding-right: 60px; } .rd-navbar-fixed .rd-navbar-search .rd-search-form-submit { position: absolute; top: 0; bottom: 0; right: 7px; width: 50px; padding: 0; margin: 0; border: none; background-color: transparent; } .rd-navbar-fixed .rd-navbar-search.active .rd-search { opacity: 1; visibility: visible; transform: none; } .rd-navbar-fixed .rd-navbar-search-toggle { display: inline-flex; } .rd-navbar-fixed .rd-navbar-search-toggle span { display: inline-block; position: relative; width: 48px; height: 48px; font-size: 26px; line-height: 48px; text-align: center; cursor: pointer; background: none; border: none; outline: none; padding: 0; } .rd-navbar-fixed .rd-navbar-search-toggle span, .rd-navbar-fixed .rd-navbar-search-toggle span::before, .rd-navbar-fixed .rd-navbar-search-toggle span::after { transition: .3s all ease-in-out; } .rd-navbar-fixed .rd-navbar-search-toggle span::before, .rd-navbar-fixed .rd-navbar-search-toggle span::after { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } .rd-navbar-fixed .rd-navbar-search-toggle span::before { content: ""; transform: rotate(0deg) scale(1); opacity: 1; visibility: visible; font-family: "Material Design Icons"; } .rd-navbar-fixed .rd-navbar-search-toggle span::after { content: ""; transform: rotate(-90deg) scale(0.4); opacity: 0; visibility: hidden; font-family: "Material Design Icons"; } .rd-navbar-fixed .rd-navbar-search-toggle.active span::before { opacity: 0; visibility: hidden; transform: rotate(90deg) scale(0.4); } .rd-navbar-fixed .rd-navbar-search-toggle.active span::after { transform: rotate(0deg) scale(1); opacity: 1; visibility: visible; } .rd-navbar-fixed [class*='rd-navbar-fixed-element'] { position: fixed; top: 8px; z-index: 1082; } .rd-navbar-fixed .rd-navbar-fixed-element-1 { right: 0; } .rd-navbar-fixed .rd-navbar-fixed-element-2 { right: 46px; } .rd-navbar-fixed.rd-navbar--is-clone { display: none; } .rd-navbar-fixed .rd-navbar-fixed--visible { display: block; } .rd-navbar-fixed .rd-navbar-fixed--hidden { display: none; } .rd-navbar-fixed .rd-navbar-panel { color: #495057; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.05); background: #fff; } .rd-navbar-fixed .rd-navbar-nav-wrap { color: #fff; background: #fff; border-right: 1px solid #e8e9ee; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.05); } .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 { color: #fff; background: #0045b6; } .rd-navbar-fixed .rd-nav-item:hover > .rd-navbar-submenu-toggle, .rd-navbar-fixed .rd-nav-item.focus > .rd-navbar-submenu-toggle, .rd-navbar-fixed .rd-nav-item.active > .rd-navbar-submenu-toggle, .rd-navbar-fixed .rd-nav-item.opened > .rd-navbar-submenu-toggle { color: #fff; } .rd-navbar-fixed .rd-nav-link { color: #495057; } .rd-navbar-fixed .rd-megamenu-list > li > a, .rd-navbar-fixed .rd-navbar-dropdown > li > a { color: #495057; } .rd-navbar-fixed .rd-megamenu-list > li:hover > a, .rd-navbar-fixed .rd-megamenu-list > li.focus > a, .rd-navbar-fixed .rd-megamenu-list > li.active > a, .rd-navbar-fixed .rd-megamenu-list > li.opened > a, .rd-navbar-fixed .rd-navbar-dropdown > li:hover > a, .rd-navbar-fixed .rd-navbar-dropdown > li.focus > a, .rd-navbar-fixed .rd-navbar-dropdown > li.active > a, .rd-navbar-fixed .rd-navbar-dropdown > li.opened > a { color: #fff; background: #0045b6; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title { color: #aeb1be; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title::after { border-bottom: 1px solid #e8e9ee; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title a, .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title a:focus, .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title a:active { color: #fff; } .rd-navbar-fixed .rd-navbar-megamenu .rd-megamenu-title a:hover { color: #0045b6; } .rd-navbar-fixed .rd-navbar-submenu-toggle { color: #495057; } .rd-navbar-fixed .rd-navbar-search .rd-search-form-submit { color: #aeb1be; } .rd-navbar-fixed .rd-navbar-search .rd-search-form-submit:hover { color: #0045b6; } .rd-navbar-fixed .rd-navbar-search-toggle { color: #495057; } .rd-navbar-fixed .rd-navbar-search-toggle:hover { color: #0045b6; } .rd-navbar-fixed .rd-navbar-collapse-content { background-color: #fff; box-shadow: 0 0 22px -4px rgba(0, 0, 0, 0.17); } .rd-navbar-fixed .banner { position: relative; overflow: hidden; padding: 20px; margin: 10px; border-radius: 6px; text-align: center; } .rd-navbar-fixed .banner::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: rgba(73, 80, 87, 0.4); border-radius: inherit; } html.rd-navbar-fixed-linked .page { padding-top: 56px; } .context-dark .rd-navbar-fixed .rd-navbar-panel, .bg-gray-700 .rd-navbar-fixed .rd-navbar-panel, .bg-gray-800 .rd-navbar-fixed .rd-navbar-panel, .bg-primary .rd-navbar-fixed .rd-navbar-panel, .bg-primary-darker .rd-navbar-fixed .rd-navbar-panel { background: #495057; } .context-dark .rd-navbar-fixed .rd-navbar-nav-wrap, .bg-gray-700 .rd-navbar-fixed .rd-navbar-nav-wrap, .bg-gray-800 .rd-navbar-fixed .rd-navbar-nav-wrap, .bg-primary .rd-navbar-fixed .rd-navbar-nav-wrap, .bg-primary-darker .rd-navbar-fixed .rd-navbar-nav-wrap { border-right-color: #495057; background: #444b51; } .context-dark .rd-navbar-fixed .rd-nav-link, .bg-gray-700 .rd-navbar-fixed .rd-nav-link, .bg-gray-800 .rd-navbar-fixed .rd-nav-link, .bg-primary .rd-navbar-fixed .rd-nav-link, .bg-primary-darker .rd-navbar-fixed .rd-nav-link, .context-dark .rd-navbar-fixed .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-fixed .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-fixed .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-fixed .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-fixed .rd-navbar-submenu-toggle { color: #fff; } .context-dark .rd-navbar-fixed .rd-navbar-dropdown > li > a, .bg-gray-700 .rd-navbar-fixed .rd-navbar-dropdown > li > a, .bg-gray-800 .rd-navbar-fixed .rd-navbar-dropdown > li > a, .bg-primary .rd-navbar-fixed .rd-navbar-dropdown > li > a, .bg-primary-darker .rd-navbar-fixed .rd-navbar-dropdown > li > a, .context-dark .rd-navbar-fixed .rd-megamenu-list-link, .bg-gray-700 .rd-navbar-fixed .rd-megamenu-list-link, .bg-gray-800 .rd-navbar-fixed .rd-megamenu-list-link, .bg-primary .rd-navbar-fixed .rd-megamenu-list-link, .bg-primary-darker .rd-navbar-fixed .rd-megamenu-list-link { color: #e8e9ee; } /* +* +* RD Navbar Sidebar +*/ .rd-navbar-sidebar { display: block; background: #fff; } .rd-navbar-sidebar .rd-navbar-main-outer { padding-left: 15px; padding-right: 15px; } .rd-navbar-sidebar .rd-navbar-main { position: relative; display: flex; align-items: center; justify-content: space-between; padding: 22px 60px 22px 0; max-width: 1200px; margin-left: auto; margin-right: auto; transition: 0.5s cubic-bezier(0.4, 0, 0.2, 1); } .rd-navbar-sidebar .rd-navbar-main-element { display: flex; align-items: center; } .rd-navbar-sidebar .rd-navbar-main-element > * + * { margin-left: 20px; } .rd-navbar-sidebar .rd-navbar-toggle { position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: block; z-index: 1102; } .rd-navbar-sidebar .rd-navbar-panel { min-width: 200px; text-align: center; } .rd-navbar-sidebar .rd-navbar-brand img { width: auto; height: auto; max-width: 200px; max-height: 200px; } .rd-navbar-sidebar .rd-navbar-nav-wrap { position: fixed; top: 0; bottom: 0; right: 0; z-index: 1101; width: 450px; padding: 94px 0 0 0; background: #fff; transition: 0.5s cubic-bezier(0.4, 0, 0.2, 1); transform: translateX(100%); box-shadow: 0 0 13px 0 rgba(174, 177, 190, 0.16); } .rd-navbar-sidebar .rd-navbar-nav-wrap.active { transition-delay: .1s; transform: translateX(0); } .rd-navbar-sidebar .rd-navbar-nav { height: calc(100vh - 100px); overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; border-top: 1px solid #e8e9ee; } .rd-navbar-sidebar .rd-nav-item { position: relative; display: block; padding: 19px 30px 19px 40px; } .rd-navbar-sidebar .rd-nav-item.active .rd-nav-link { color: #0045b6; } .rd-navbar-sidebar .rd-nav-item .rd-nav-link, .rd-navbar-sidebar .rd-nav-item > .rd-navbar-submenu-toggle { font-size: 18px; } .rd-navbar-sidebar .rd-nav-item + .rd-nav-item { border-top: 1px solid #e8e9ee; } .rd-navbar-sidebar .rd-nav-item:last-child { border-bottom: 1px solid #e8e9ee; margin-bottom: 30px; } .rd-navbar-sidebar .rd-nav-link { max-width: calc(100% - 30px); word-break: break-all; font-size: 18px; line-height: 1.4; text-transform: none; color: #aeb1be; } .rd-navbar-sidebar .rd-nav-link:hover { color: #0045b6; } .rd-navbar-sidebar .rd-navbar-submenu .opened > .rd-navbar-submenu-toggle::after { transform: rotate(180deg); } .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-dropdown, .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-megamenu { display: none; opacity: 0; visibility: hidden; will-change: opacity, visibility; transition: opacity .2s; } .rd-navbar-sidebar .rd-navbar-submenu.opened > .rd-navbar-dropdown, .rd-navbar-sidebar .rd-navbar-submenu.opened > .rd-navbar-megamenu { display: block; opacity: 1; visibility: visible; } .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu > .rd-navbar-dropdown, .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu > .rd-navbar-megamenu { transform: translateY(30px); } .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu.opened > .rd-navbar-dropdown, .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu.opened > .rd-navbar-megamenu { transform: translateY(0); } .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu .rd-navbar-submenu > .rd-navbar-dropdown { transform: translateX(-20px); } .rd-navbar-sidebar .rd-navbar-submenu > .rd-navbar-submenu .rd-navbar-submenu.opened > .rd-navbar-dropdown { transform: translateX(0); } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-submenu-toggle, .rd-navbar-sidebar .rd-nav-item .rd-navbar--has-dropdown > .rd-navbar-submenu-toggle { position: absolute; top: 0; right: 0; padding-top: inherit; padding-right: inherit; padding-left: 10px; margin-bottom: inherit; display: inline-block; width: 30px; margin-left: 5px; text-align: center; cursor: pointer; color: #495057; } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-submenu-toggle:hover, .rd-navbar-sidebar .rd-nav-item .rd-navbar--has-dropdown > .rd-navbar-submenu-toggle:hover { color: #0045b6; } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-submenu-toggle::after, .rd-navbar-sidebar .rd-nav-item .rd-navbar--has-dropdown > .rd-navbar-submenu-toggle::after { content: '\f107'; position: relative; display: inline-block; font-family: "FontAwesome"; font-size: inherit; line-height: inherit; text-align: center; vertical-align: middle; transition: 0.4s all ease; z-index: 2; will-change: transform; } .rd-navbar-sidebar .rd-nav-item .rd-navbar--has-dropdown > .rd-navbar-submenu-toggle { font-size: 14px; color: #aeb1be; margin-left: 7px; } .rd-navbar-sidebar .rd-menu { margin-top: 31px; } .rd-navbar-sidebar .rd-navbar-dropdown, .rd-navbar-sidebar .rd-megamenu-list { font-size: 14px; } .rd-navbar-sidebar .rd-navbar-dropdown li > a, .rd-navbar-sidebar .rd-megamenu-list li > a { position: relative; left: -13px; display: flex; padding: 4px 18px 4px 0; text-align: left; } .rd-navbar-sidebar .rd-navbar-dropdown li > a, .rd-navbar-sidebar .rd-navbar-dropdown li > a:focus, .rd-navbar-sidebar .rd-navbar-dropdown li > a:active, .rd-navbar-sidebar .rd-megamenu-list li > a, .rd-navbar-sidebar .rd-megamenu-list li > a:focus, .rd-navbar-sidebar .rd-megamenu-list li > a:active { color: #aeb1be; } .rd-navbar-sidebar .rd-navbar-dropdown li > a:hover, .rd-navbar-sidebar .rd-megamenu-list li > a:hover { color: #fff; } .rd-navbar-sidebar .rd-navbar-dropdown li > a, .rd-navbar-sidebar .rd-navbar-dropdown li > a::before, .rd-navbar-sidebar .rd-megamenu-list li > a, .rd-navbar-sidebar .rd-megamenu-list li > a::before { transition: all .2s ease-in-out; } .rd-navbar-sidebar .rd-navbar-dropdown li > a::before, .rd-navbar-sidebar .rd-megamenu-list li > a::before { position: relative; top: -1px; left: -6px; display: inline-block; content: '\f238'; font-family: 'Material Design Icons'; font-size: inherit; line-height: inherit; color: #fff; opacity: 0; visibility: hidden; } .rd-navbar-sidebar .rd-navbar-dropdown li.focus > a, .rd-navbar-sidebar .rd-navbar-dropdown li.opened > a, .rd-navbar-sidebar .rd-navbar-dropdown li > a:hover, .rd-navbar-sidebar .rd-megamenu-list li.focus > a, .rd-navbar-sidebar .rd-megamenu-list li.opened > a, .rd-navbar-sidebar .rd-megamenu-list li > a:hover { left: -13px; padding-left: 18px; padding-right: 0; } .rd-navbar-sidebar .rd-navbar-dropdown li.focus > a::before, .rd-navbar-sidebar .rd-navbar-dropdown li.opened > a::before, .rd-navbar-sidebar .rd-navbar-dropdown li > a:hover::before, .rd-navbar-sidebar .rd-megamenu-list li.focus > a::before, .rd-navbar-sidebar .rd-megamenu-list li.opened > a::before, .rd-navbar-sidebar .rd-megamenu-list li > a:hover::before { left: -7px; opacity: 1; visibility: visible; } .rd-navbar-sidebar .rd-navbar-megamenu { max-width: 450px; margin-bottom: -30px; } .rd-navbar-sidebar .rd-navbar-megamenu > * { margin-bottom: 30px; } .rd-navbar-sidebar .rd-navbar-megamenu > li { display: inline-block; vertical-align: top; width: 45%; } .rd-navbar-sidebar .rd-megamenu-title { display: none; } .rd-navbar-sidebar .rd-megamenu-list { margin-top: 0; } .rd-navbar-sidebar .rd-megamenu-list > li + li { margin-top: 10px; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown > a { padding-right: 0; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown > a::before { display: none; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown > a:hover { padding-left: 0; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown.focus > a { padding-left: 0; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown.opened > .rd-navbar-submenu-toggle { color: #0045b6; } .rd-navbar-sidebar .rd-navbar-dropdown .rd-navbar--has-dropdown.opened > .rd-navbar-submenu-toggle::after { top: 1px; } .rd-navbar-sidebar .rd-navbar-dropdown > li + li { margin-top: 10px; } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-dropdown { margin-top: 25px; } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-dropdown .rd-navbar-dropdown { margin-top: 10px; padding-left: 15px; } .rd-navbar-sidebar .rd-nav-item > .rd-navbar-dropdown .rd-navbar-dropdown > li > a { font-size: 12px; } @media (min-width: 1200px) { .rd-navbar-sidebar .rd-navbar-megamenu { margin-bottom: -30px; } .rd-navbar-sidebar .rd-navbar-megamenu > * { margin-bottom: 30px; } .rd-navbar-sidebar .rd-navbar-dropdown > li + li, .rd-navbar-sidebar .rd-megamenu-list > li + li { margin-top: 18px; } } @media (min-width: 1200px) { .rd-navbar-sidebar .rd-navbar-nav-wrap { width: auto; left: calc(50% + 600px - 80px); } } .rd-navbar-sidebar.rd-navbar--is-clone { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; transform: translateY(-101%); } .rd-navbar-sidebar.rd-navbar--is-clone.rd-navbar--is-stuck { transform: translateY(0); } .rd-navbar-sidebar.rd-navbar--is-stuck { position: fixed; left: 0; right: 0; top: 0; z-index: 1000; box-shadow: 0 2px 10px 1px rgba(0, 0, 0, 0.1); } .rd-navbar-sidebar.rd-navbar--is-stuck .rd-navbar-main { padding-top: 11px; padding-bottom: 11px; } /** +* +* RD Navbar Minimal +* +*/ .rd-navbar-minimal.rd-navbar-static { border-bottom: 1px solid transparent; } .rd-navbar-minimal.rd-navbar-static .rd-navbar-main-outer { padding-left: 15px; padding-right: 15px; } .rd-navbar-minimal.rd-navbar-static .rd-navbar-main { position: relative; display: flex; align-items: center; justify-content: space-between; max-width: 1170px; padding: 20px 0; margin-left: auto; margin-right: auto; } .rd-navbar-minimal.rd-navbar-static .rd-navbar-main-element { position: relative; display: flex; align-items: center; justify-content: space-between; min-width: 690px; } .rd-navbar-minimal.rd-navbar-static .rd-navbar-brand img { width: auto; height: auto; max-width: 200px; max-height: 200px; } .rd-navbar-minimal.rd-navbar-static .rd-navbar-megamenu { margin-top: 24px; width: calc(100% + 27px + 15px); } .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle span { font-size: 20px; } @media (min-width: 1200px) { .rd-navbar-minimal.rd-navbar-static .rd-navbar-main { padding: 30px 0; } .rd-navbar-minimal.rd-navbar-static .rd-menu { margin-top: 34px; } } .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck, .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-clone { border-bottom-color: #e8e9ee; } .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-main, .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-clone .rd-navbar-main { padding: 20px 0; } .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck .rd-menu, .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-clone .rd-menu { margin-top: 24px; } .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-nav-item > .rd-navbar-submenu, .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-clone .rd-navbar-nav-item > .rd-navbar-submenu { margin-top: 17px; } .rd-navbar-minimal.rd-navbar-fixed .rd-navbar-search-toggle { position: fixed; right: 10px; top: 5px; } .rd-navbar-minimal.rd-navbar-fixed .rd-navbar-search .form-input { border-width: 1px 0 1px 0; border-radius: 0; } .rd-navbar-minimal-wide.rd-navbar-static .rd-navbar-main { padding: 54px 0; } .rd-navbar-minimal-wide.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-main { padding: 21px 0; } .context-dark .rd-navbar-minimal .rd-nav-link, .bg-gray-700 .rd-navbar-minimal .rd-nav-link, .bg-gray-800 .rd-navbar-minimal .rd-nav-link, .bg-primary .rd-navbar-minimal .rd-nav-link, .bg-primary-darker .rd-navbar-minimal .rd-nav-link, .context-dark .rd-navbar-minimal .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-minimal .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-minimal .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-minimal .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-minimal .rd-navbar-submenu-toggle, .context-dark .rd-navbar-minimal .rd-navbar-search-toggle, .bg-gray-700 .rd-navbar-minimal .rd-navbar-search-toggle, .bg-gray-800 .rd-navbar-minimal .rd-navbar-search-toggle, .bg-primary .rd-navbar-minimal .rd-navbar-search-toggle, .bg-primary-darker .rd-navbar-minimal .rd-navbar-search-toggle { color: #fff; } .context-dark .rd-navbar-minimal.rd-navbar-static, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static, .bg-primary .rd-navbar-minimal.rd-navbar-static, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static { background-color: transparent; } .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-nav-link, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-nav-link, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-nav-link, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-nav-link, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-nav-link, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .context-dark .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle:hover, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle:hover, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle:hover, .bg-primary .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle:hover, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static .rd-navbar-search-toggle:hover { color: #1d73ff; } .context-dark .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck, .bg-gray-700 .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck, .bg-gray-800 .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck, .bg-primary .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck, .bg-primary-darker .rd-navbar-minimal.rd-navbar-static.rd-navbar--is-stuck { background: #495057; border-bottom-color: #151515; } /** +* +* RD Navbar Corporate +* +*/ .rd-navbar-corporate.rd-navbar-static .rd-navbar-aside-outer, .rd-navbar-corporate.rd-navbar-static .rd-navbar-main-outer { padding-left: 15px; padding-right: 15px; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-aside, .rd-navbar-corporate.rd-navbar-static .rd-navbar-main { max-width: 1170px; margin-left: auto; margin-right: auto; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-aside { display: flex; align-items: center; justify-content: space-between; padding: 30px 0 40px; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-main-outer { background: rgba(51, 51, 51, 0.1); position: absolute; top: 100%; z-index: 10; width: 100%; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-main { position: relative; padding: 16px 0; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-nav-wrap { position: relative; display: flex; flex-direction: row-reverse; align-items: center; justify-content: space-between; min-width: 690px; } .ie-10 .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse, .ie-11 .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse { width: 100%; max-width: 870px; } .ie-10 .rd-navbar-corporate.rd-navbar-static .unit, .ie-11 .rd-navbar-corporate.rd-navbar-static .unit { flex: auto; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-panel + .rd-navbar-collapse { margin-left: 50px; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse-content { display: flex; align-items: center; justify-content: center; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse-content .button { flex-shrink: 0; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse-content > * { margin-top: 0; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse-content > * + * { margin-left: 50px; } .rd-navbar-corporate.rd-navbar-static .rd-menu { margin-top: 21px; } .rd-navbar-corporate.rd-navbar-static .rd-navbar-megamenu { margin-top: 24px; } @media (min-width: 1200px) { .rd-navbar-corporate.rd-navbar-static .rd-navbar-collapse-content > * + * { margin-left: 92px; } .rd-navbar-corporate.rd-navbar-static .rd-nav-item + .rd-nav-item { margin-left: 55px; } } .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-stuck, .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-clone { border-bottom-color: #e8e9ee; } .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-aside-outer, .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-clone .rd-navbar-aside-outer { display: none; } .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-main-outer, .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-clone .rd-navbar-main-outer { background: #232426; box-shadow: 0 5px 10px 0px rgba(20, 20, 28, 0.3); } .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-stuck .rd-navbar-nav-item > .rd-navbar-submenu, .rd-navbar-corporate.rd-navbar-static.rd-navbar--is-clone .rd-navbar-nav-item > .rd-navbar-submenu { margin-top: 17px; } .rd-navbar-corporate.rd-navbar-fixed .rd-navbar-search { margin: 20px 5px; } .rd-navbar-corporate.rd-navbar-fixed .rd-navbar-search-toggle { display: none; } .rd-navbar-corporate.rd-navbar-fixed .rd-search { position: relative; top: 0; left: 0; transform: none; opacity: 1; visibility: visible; z-index: 1; } .rd-navbar-corporate.rd-navbar-fixed .rd-navbar-nav { margin: 0; } /** +* +* RD Navbar Thin +* +*/ .rd-navbar-thin.rd-navbar-static { background-color: #495057; border-bottom: 1px solid transparent; } .rd-navbar-thin.rd-navbar-static .rd-navbar-main-outer { padding-left: 15px; padding-right: 15px; } .rd-navbar-thin.rd-navbar-static .rd-navbar-main { position: relative; display: flex; align-items: center; justify-content: space-between; max-width: 1170px; padding: 23px 0; margin-left: auto; margin-right: auto; } .rd-navbar-thin.rd-navbar-static .rd-navbar-nav-wrap { position: absolute; top: 0; bottom: 0; left: 200px; right: 80px; z-index: 102; display: flex; align-items: center; justify-content: flex-end; min-width: 690px; background: #495057; opacity: 0; visibility: hidden; transition: .44s ease-in-out; } .rd-navbar-thin.rd-navbar-static .rd-navbar-nav-wrap .rd-navbar-brand { display: none; } .rd-navbar-thin.rd-navbar-static .rd-navbar-nav-wrap.active { opacity: 1; visibility: visible; } .rd-navbar-thin.rd-navbar-static .rd-navbar-panel { display: flex; align-items: center; justify-content: space-between; width: 100%; padding-right: 50px; } .ie-10 .rd-navbar-thin.rd-navbar-static .rd-navbar-panel, .ie-11 .rd-navbar-thin.rd-navbar-static .rd-navbar-panel { justify-content: flex-start; } .ie-10 .rd-navbar-thin.rd-navbar-static .rd-navbar-block, .ie-11 .rd-navbar-thin.rd-navbar-static .rd-navbar-block { width: 100%; justify-content: flex-end; } .rd-navbar-thin.rd-navbar-static .rd-navbar-toggle { position: absolute; right: 0; top: 50%; display: block; transform: translate3d(0, -50%, 0); } .rd-navbar-thin.rd-navbar-static .rd-navbar-block { display: flex; align-items: center; } .rd-navbar-thin.rd-navbar-static .rd-navbar-brand { max-width: 300px; } .rd-navbar-thin.rd-navbar-static .rd-navbar-brand img { width: auto; height: auto; max-width: 200px; max-height: 200px; } .rd-navbar-thin.rd-navbar-static .rd-nav-link, .rd-navbar-thin.rd-navbar-static .rd-navbar-submenu-toggle, .rd-navbar-thin.rd-navbar-static .rd-navbar-search-toggle { color: #fff; } .rd-navbar-thin.rd-navbar-static .rd-nav-item.opened .rd-nav-link, .rd-navbar-thin.rd-navbar-static .rd-nav-item.focus .rd-nav-link, .rd-navbar-thin.rd-navbar-static .rd-nav-item.active .rd-nav-link, .rd-navbar-thin.rd-navbar-static .rd-nav-item.opened .rd-navbar-submenu-toggle, .rd-navbar-thin.rd-navbar-static .rd-nav-item.focus .rd-navbar-submenu-toggle, .rd-navbar-thin.rd-navbar-static .rd-nav-item.active .rd-navbar-submenu-toggle, .rd-navbar-thin.rd-navbar-static .rd-nav-link:hover, .rd-navbar-thin.rd-navbar-static .rd-nav-link:hover ~ .rd-navbar-submenu-toggle, .rd-navbar-thin.rd-navbar-static .rd-navbar-search-toggle:hover { color: #1d73ff; } .rd-navbar-thin.rd-navbar-static .rd-menu { margin-top: 27px; } .rd-navbar-thin.rd-navbar-static .rd-navbar-megamenu { margin-top: 24px; width: calc(100% + 27px + 15px); } .rd-navbar-thin.rd-navbar-static .rd-navbar-search { position: static; } .rd-navbar-thin.rd-navbar-static * + .rd-navbar-search-toggle { margin-left: 60px; } .rd-navbar-thin.rd-navbar-static .rd-search { position: absolute; top: 0; bottom: 0; left: 0; right: 0; display: flex; width: calc(100% - 70px); margin: 0; } .rd-navbar-thin.rd-navbar-static .rd-search .form-wrap { display: flex; width: 100%; background: #495057; } .rd-navbar-thin.rd-navbar-static .rd-search .form-wrap::before { content: '\f43b'; position: absolute; top: 50%; transform: translate3d(0, -50%, 0); font-family: 'Material Design Icons'; font-size: 30px; line-height: 1; color: #fff; } .rd-navbar-thin.rd-navbar-static .rd-search .form-input, .rd-navbar-thin.rd-navbar-static .rd-search .form-label { padding-left: 40px; font-size: 30px; color: #fff; } .rd-navbar-thin.rd-navbar-static .rd-search .form-input { background-color: transparent; border: 0; border-radius: 0; } .rd-navbar-thin.rd-navbar-static .rd-search .form-label { top: 50%; transform: translate3d(0, -50%, 0); } .rd-navbar-thin.rd-navbar-static .rd-search-results-live { height: 100vh; margin: 1px 0 0 0; } .rd-navbar-thin.rd-navbar-static .search-list { display: flex; flex-wrap: wrap; margin-bottom: -20px; } .rd-navbar-thin.rd-navbar-static .search-list-item { width: 25%; padding-left: 15px; padding-right: 15px; margin-bottom: 20px; } .rd-navbar-thin.rd-navbar-static .search-list-item + .search-list-item { margin-top: 0; } .rd-navbar-thin.rd-navbar-static .rd-navbar-search-toggle { position: relative; z-index: 12; } .rd-navbar-thin.rd-navbar-static .rd-navbar-popup { left: 50%; z-index: 110; transform: translateX(-50%); margin-top: 30px; } .rd-navbar-thin.rd-navbar-static .list-inline-bordered { position: relative; } @media (min-width: 992px) and (max-width: 1199.98px) { .rd-navbar-thin.rd-navbar-static .rd-nav-item + .rd-nav-item { margin-left: 30px; } } @media (min-width: 1200px) { .rd-navbar-thin.rd-navbar-static .rd-navbar-panel { padding-right: 70px; } .rd-navbar-thin.rd-navbar-static .rd-navbar-nav-wrap { left: 300px; right: 70px; } } @media (min-width: 1600px) { .rd-navbar-thin.rd-navbar-static .rd-navbar-popup { right: auto; } } .rd-navbar-thin.rd-navbar-static.rd-navbar--is-stuck, .rd-navbar-thin.rd-navbar-static.rd-navbar--is-clone { border-bottom-color: #151515; } .rd-navbar-thin.rd-navbar-static.rd-navbar--is-stuck .rd-menu, .rd-navbar-thin.rd-navbar-static.rd-navbar--is-clone .rd-menu { margin-top: 27px; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-search .form-input { border-width: 0 0 1px 0; border-radius: 0; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-search-toggle { position: fixed; top: 5px; right: 8px; color: #fff; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-search-toggle span { font-size: 20px; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-block { position: absolute; top: 14px; right: 65px; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-popup { position: fixed; right: 5px; margin-top: 20px; overflow-y: auto; max-height: calc(100vh - 56px - 10px); } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-brand { display: none; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-nav-wrap .rd-navbar-brand { display: inline-block; line-height: 0; font-size: 0; margin: 30px 20px 20px; } @media (min-width: 480px) { .rd-navbar-thin.rd-navbar-fixed .rd-navbar-brand { display: block; } .rd-navbar-thin.rd-navbar-fixed .rd-navbar-nav-wrap .rd-navbar-brand { display: none; } } /* +* +* Swiper +*/ .swiper-container { position: relative; display: flex; margin: 0 auto; overflow: hidden; list-style: none; padding: 0; /* Fix of Webkit flickering */ z-index: 1; pointer-events: none; } .swiper-container, .swiper-container * { backface-visibility: hidden; } .swiper-container-no-flexbox .swiper-slide { float: left; } .swiper-container-vertical > .swiper-wrapper { flex-direction: column; } .swiper-wrapper { position: relative; z-index: 1; display: flex; align-self: stretch; align-items: stretch; width: 100%; height: auto; min-height: inherit; transition-property: transform; box-sizing: content-box; } .swiper-container-android .swiper-slide, .swiper-wrapper { transform: translate3d(0px, 0, 0); } .swiper-container-multirow > .swiper-wrapper { flex-wrap: wrap; } .swiper-container-free-mode > .swiper-wrapper { transition-timing-function: ease-out; margin: 0 auto; } .swiper-slide { flex-shrink: 0; width: 100%; height: 100%; position: relative; background-size: cover; transition-property: transform, transform; pointer-events: auto; contain: paint; } .swiper-invisible-blank-slide { visibility: hidden; } /* Auto Height */ .swiper-container-autoheight, .swiper-container-autoheight .swiper-slide { height: auto; } .swiper-container-autoheight .swiper-wrapper { align-items: flex-start; transition-property: transform, height; } /* 3D Effects */ .swiper-container-3d { perspective: 1200px; } .swiper-container-3d .swiper-wrapper, .swiper-container-3d .swiper-slide, .swiper-container-3d .swiper-slide-shadow-left, .swiper-container-3d .swiper-slide-shadow-right, .swiper-container-3d .swiper-slide-shadow-top, .swiper-container-3d .swiper-slide-shadow-bottom, .swiper-container-3d .swiper-cube-shadow { transform-style: preserve-3d; } .swiper-container-3d .swiper-slide-shadow-left, .swiper-container-3d .swiper-slide-shadow-right, .swiper-container-3d .swiper-slide-shadow-top, .swiper-container-3d .swiper-slide-shadow-bottom { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; } .swiper-container-3d .swiper-slide-shadow-left { background-image: linear-gradient(to left, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); } .swiper-container-3d .swiper-slide-shadow-right { background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); } .swiper-container-3d .swiper-slide-shadow-top { background-image: linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); } .swiper-container-3d .swiper-slide-shadow-bottom { background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); } /* IE10 Windows Phone 8 Fixes */ .swiper-container-wp8-horizontal, .swiper-container-wp8-horizontal > .swiper-wrapper { touch-action: pan-y; } .swiper-container-wp8-vertical, .swiper-container-wp8-vertical > .swiper-wrapper { touch-action: pan-x; } .swiper-button-prev, .swiper-button-next { position: absolute; top: 50%; width: 27px; height: 44px; margin-top: -22px; z-index: 10; cursor: pointer; background-size: 27px 44px; background-position: center; background-repeat: no-repeat; } .swiper-button-prev.swiper-button-disabled, .swiper-button-next.swiper-button-disabled { opacity: 0.35; cursor: auto; pointer-events: none; } .swiper-button-lock { display: none; } .swiper-pagination { position: absolute; text-align: center; transition: 300ms opacity; transform: translate3d(0, 0, 0); z-index: 10; } .swiper-pagination.swiper-pagination-hidden { opacity: 0; } /* Common Styles */ .swiper-pagination-fraction, .swiper-pagination-custom, .swiper-container-horizontal > .swiper-pagination-bullets { bottom: 10px; left: 0; width: 100%; } /* Bullets */ .swiper-pagination-bullets-dynamic { overflow: hidden; font-size: 0; } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet { transform: scale(0.33); position: relative; } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active { transform: scale(1); } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev { transform: scale(0.66); } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev-prev { transform: scale(0.33); } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next { transform: scale(0.66); } .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next-next { transform: scale(0.33); } .swiper-pagination-bullet { width: 8px; height: 8px; display: inline-block; border-radius: 100%; background: #000; } button.swiper-pagination-bullet { border: none; margin: 0; padding: 0; box-shadow: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; } .swiper-pagination-clickable .swiper-pagination-bullet { cursor: pointer; } .swiper-pagination-bullet-active { opacity: 1; background: #007aff; } .swiper-container-vertical > .swiper-pagination-bullets { right: 10px; top: 50%; transform: translate3d(0px, -50%, 0); } .swiper-container-vertical > .swiper-pagination-bullets .swiper-pagination-bullet { margin: 6px 0; display: block; } .swiper-container-vertical > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic { top: 50%; transform: translateY(-50%); width: 8px; } .swiper-container-vertical > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet { display: inline-block; transition: 200ms transform, 200ms top; } .swiper-container-horizontal > .swiper-pagination-bullets .swiper-pagination-bullet { margin: 0 4px; } .swiper-container-horizontal > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic { left: 50%; transform: translateX(-50%); white-space: nowrap; } .swiper-container-horizontal > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet { transition: 200ms transform, 200ms left; } .swiper-container-horizontal.swiper-container-rtl > .swiper-pagination-bullets-dynamic .swiper-pagination-bullet { transition: 200ms transform, 200ms right; } /* Progress */ .swiper-pagination-progressbar { background: rgba(0, 0, 0, 0.25); position: absolute; } .swiper-pagination-progressbar .swiper-pagination-progressbar-fill { background: rgba(51, 51, 51, 0.3); position: absolute; left: 0; top: 0; width: 100%; height: 100%; transform: scale(0); transform-origin: left top; } .swiper-container-rtl .swiper-pagination-progressbar .swiper-pagination-progressbar-fill { transform-origin: right top; } .swiper-container-horizontal > .swiper-pagination-progressbar { width: 100%; height: 4px; left: 0; top: 0; } .swiper-container-vertical > .swiper-pagination-progressbar { width: 4px; height: 100%; left: 0; top: 0; } .swiper-pagination-white .swiper-pagination-bullet-active { background: #fff; } .swiper-pagination-progressbar.swiper-pagination-white { background: rgba(255, 255, 255, 0.25); } .swiper-pagination-progressbar.swiper-pagination-white .swiper-pagination-progressbar-fill { background: #fff; } .swiper-pagination-black .swiper-pagination-bullet-active { background: #000; } .swiper-pagination-progressbar.swiper-pagination-black { background: rgba(0, 0, 0, 0.25); } .swiper-pagination-progressbar.swiper-pagination-black .swiper-pagination-progressbar-fill { background: #000; } .swiper-pagination-lock { display: none; } /* Scrollbar */ .swiper-scrollbar { border-radius: 10px; position: relative; -ms-touch-action: none; background: rgba(0, 0, 0, 0.1); } .swiper-container-horizontal > .swiper-scrollbar { position: absolute; left: 1%; bottom: 3px; z-index: 50; height: 5px; width: 98%; } .swiper-container-vertical > .swiper-scrollbar { position: absolute; right: 3px; top: 1%; z-index: 50; width: 5px; height: 98%; } .swiper-scrollbar-drag { height: 100%; width: 100%; position: relative; background: rgba(0, 0, 0, 0.5); border-radius: 10px; left: 0; top: 0; } .swiper-scrollbar-cursor-drag { cursor: move; } .swiper-scrollbar-lock { display: none; } .swiper-zoom-container { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; text-align: center; } .swiper-zoom-container > img, .swiper-zoom-container > svg, .swiper-zoom-container > canvas { max-width: 100%; max-height: 100%; -o-object-fit: contain; object-fit: contain; } .swiper-slide-zoomed { cursor: move; } /* Preloader */ .swiper-lazy-preloader { width: 42px; height: 42px; position: absolute; left: 50%; top: 50%; margin-left: -21px; margin-top: -21px; z-index: 10; transform-origin: 50%; animation: swiper-preloader-spin 1s steps(12, end) infinite; } .swiper-lazy-preloader:after { display: block; content: ''; width: 100%; height: 100%; background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); background-position: 50%; background-size: 100%; background-repeat: no-repeat; } .swiper-lazy-preloader-white:after { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } @keyframes swiper-preloader-spin { 100% { transform: rotate(360deg); } } /* a11y */ .swiper-container .swiper-notification { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; z-index: -1000; } .swiper-container-fade .swiper-container-free-mode .swiper-slide { transition-timing-function: ease-out; } .swiper-container-fade .swiper-slide { pointer-events: none; transition-property: opacity; } .swiper-container-fade .swiper-slide .swiper-slide { pointer-events: none; } .swiper-container-fade .swiper-slide-active, .swiper-container-fade .swiper-slide-active .swiper-slide-active { pointer-events: auto; } .swiper-container-cube { overflow: visible; } .swiper-container-cube .swiper-slide { pointer-events: none; backface-visibility: hidden; z-index: 1; visibility: hidden; transform-origin: 0 0; width: 100%; height: 100%; } .swiper-container-cube .swiper-slide .swiper-slide { pointer-events: none; } .swiper-container-cube.swiper-container-rtl .swiper-slide { transform-origin: 100% 0; } .swiper-container-cube .swiper-slide-active, .swiper-container-cube .swiper-slide-active .swiper-slide-active { pointer-events: auto; } .swiper-container-cube .swiper-slide-active, .swiper-container-cube .swiper-slide-next, .swiper-container-cube .swiper-slide-prev, .swiper-container-cube .swiper-slide-next + .swiper-slide { pointer-events: auto; visibility: visible; } .swiper-container-cube .swiper-slide-shadow-top, .swiper-container-cube .swiper-slide-shadow-bottom, .swiper-container-cube .swiper-slide-shadow-left, .swiper-container-cube .swiper-slide-shadow-right { z-index: 0; backface-visibility: hidden; } .swiper-container-cube .swiper-cube-shadow { position: absolute; left: 0; bottom: 0px; width: 100%; height: 100%; background: #000; opacity: 0.6; filter: blur(50px); z-index: 0; } .swiper-container-flip { overflow: visible; } .swiper-container-flip .swiper-slide { pointer-events: none; backface-visibility: hidden; z-index: 1; } .swiper-container-flip .swiper-slide .swiper-slide { pointer-events: none; } .swiper-container-flip .swiper-slide-active, .swiper-container-flip .swiper-slide-active .swiper-slide-active { pointer-events: auto; } .swiper-container-flip .swiper-slide-shadow-top, .swiper-container-flip .swiper-slide-shadow-bottom, .swiper-container-flip .swiper-slide-shadow-left, .swiper-container-flip .swiper-slide-shadow-right { z-index: 0; backface-visibility: hidden; } .swiper-container-coverflow .swiper-wrapper { /* Windows 8 IE 10 fix */ -ms-perspective: 1200px; } .swiper-button-prev, .swiper-button-next { position: absolute; top: 50%; transform: translateY(-50%); display: none; font-size: 38px; line-height: 1; color: #fff; text-align: center; cursor: pointer; transition: .2s ease-in; will-change: transform; z-index: 10; } .swiper-button-prev:hover, .swiper-button-next:hover { color: #0045b6; } .swiper-button-prev::before, .swiper-button-next::before { font-family: 'Material Design Icons'; } @media (min-width: 768px) { .swiper-button-prev, .swiper-button-next { display: block; } } .swiper-button-prev.swiper-button-disabled, .swiper-button-next.swiper-button-disabled { opacity: 0.35; cursor: auto; pointer-events: none; } .swiper-button-prev { left: 30px; } .swiper-button-prev::before { content: "\f14a"; } .swiper-button-next { right: 30px; } .swiper-button-next::before { content: "\f14f"; } .swiper-slider.swiper-container-rtl .swiper-button-prev::before { content: "\f061"; } .swiper-slider.swiper-container-rtl .swiper-button-next::before { content: "\f060"; } .swiper-pagination { position: absolute; display: block; text-align: center; transition: .3s; transform: translate3d(0, 0, 0); z-index: 10; } .swiper-pagination.swiper-pagination-hidden { opacity: 0; } .swiper-pagination.swiper-pagination-clickable .swiper-pagination-bullet { cursor: pointer; } .swiper-pagination-bullet { width: 10px; height: 10px; display: inline-block; border-radius: 100%; background: rgba(255, 255, 255, 0.6); transition: .2s; } .swiper-pagination-bullet.swiper-pagination-bullet-active, .swiper-pagination-bullet:hover { background: white; } .swiper-container-vertical > .swiper-pagination { right: 10px; top: 50%; transform: translate3d(0px, -50%, 0); } .swiper-container-vertical > .swiper-pagination .swiper-pagination-bullet { margin: 5px 0; display: block; } .swiper-container-horizontal > .swiper-pagination { bottom: 20px; left: 0; width: 100%; } .swiper-container-horizontal > .swiper-pagination .swiper-pagination-bullet { margin: 0 5px; } .swiper-pagination-outer { position: absolute; left: 50%; transform: translate3d(-50%, 0, 0); z-index: 20; bottom: 5px; pointer-events: none; display: flex; justify-content: center; } .swiper-pagination-outer .swiper-pagination { pointer-events: auto; } .swiper-pagination.swiper-pagination-modern { position: relative; display: inline-block; width: auto; right: auto; left: auto; margin-left: -10px; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet, .swiper-pagination.swiper-pagination-modern .swiper-pagination-mark { width: 32px; height: 32px; border-radius: 50%; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet { display: inline-block; background: transparent; font-weight: 400; color: #e8e9ee; text-align: center; transition: .33s; line-height: 32px; margin: 0 10px; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-mark { position: absolute; top: 0; bottom: 0; z-index: -1; left: -10px; display: block; background: rgba(21, 21, 21, 0.6); transition: .4s ease-in-out; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet:not(.swiper-pagination-bullet-active):hover { font-weight: 700; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active { color: #fff; } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(1) ~ .swiper-pagination-mark { transform: translateX(20px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(2) ~ .swiper-pagination-mark { transform: translateX(72px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(3) ~ .swiper-pagination-mark { transform: translateX(124px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(4) ~ .swiper-pagination-mark { transform: translateX(176px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(5) ~ .swiper-pagination-mark { transform: translateX(228px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(6) ~ .swiper-pagination-mark { transform: translateX(280px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(7) ~ .swiper-pagination-mark { transform: translateX(332px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(8) ~ .swiper-pagination-mark { transform: translateX(384px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(9) ~ .swiper-pagination-mark { transform: translateX(436px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(10) ~ .swiper-pagination-mark { transform: translateX(488px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(11) ~ .swiper-pagination-mark { transform: translateX(540px); } .swiper-pagination.swiper-pagination-modern .swiper-pagination-bullet-active:nth-child(12) ~ .swiper-pagination-mark { transform: translateX(592px); } .context-dark .swiper-pagination .swiper-pagination-bullet, .bg-gray-700 .swiper-pagination .swiper-pagination-bullet, .bg-gray-800 .swiper-pagination .swiper-pagination-bullet, .bg-primary .swiper-pagination .swiper-pagination-bullet, .bg-primary-darker .swiper-pagination .swiper-pagination-bullet { color: #fff; } .swiper-slide { position: relative; display: flex; align-items: center; flex-shrink: 0; width: 100%; height: 100%; white-space: nowrap; background-position: 55% center; } .swiper-slide > * { width: 100%; white-space: normal; } .swiper-slide .container { width: 100%; } .swiper-slider-light { min-height: 40.41667vw; } .swiper-slider-light .swiper-slide::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: linear-gradient(to right, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)); pointer-events: none; } .swiper-slider-light[data-custom-slide-effect='inter-leave-effect'] .slide-inner { background: inherit; } .swiper-slider-light .swiper-slide-caption { position: relative; z-index: 1; max-width: 650px; padding: 40px 0 80px; } .swiper-slider-light h1 span, .swiper-slider-light h2 span, .swiper-slider-light h3 span, .swiper-slider-light h4 span, .swiper-slider-light h5 span, .swiper-slider-light h6 span, .swiper-slider-light [class^='heading-'] span { display: block; } .container + .swiper-slider-light { margin-top: 20px; } @media (min-width: 576px) { .swiper-slider-light .swiper-slide::before { background: linear-gradient(to right, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)); } .swiper-slider-light .slider-slide-caption { padding: 45px 0 95px; } } @media (min-width: 768px) { .swiper-slider-light .swiper-slide { background-position: center center; } .swiper-slider-light .swiper-slide::before { display: none; } .swiper-slider-light .swiper-slide-caption { padding: 80px 0 165px; } .swiper-slider-light .swiper-pagination-outer { bottom: 50px; } } @media (min-width: 1200px) { .swiper-slider-light .swiper-pagination-outer { bottom: 72px; } } .swiper-slider-business { min-height: 41.66667vw; } .swiper-slider-business .swiper-slide { background-position: 60% 50%; } .swiper-slider-business .swiper-slide::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: linear-gradient(to right, rgba(73, 80, 87, 0.7), rgba(73, 80, 87, 0.4)); pointer-events: none; } .swiper-slider-business .swiper-slide > * { position: relative; z-index: 1; } .swiper-slider-business .swiper-slide-caption { padding: 50px 0 100px; } .swiper-slider-business .swiper-caption-text { position: relative; overflow: hidden; max-width: 520px; padding-left: 35px; } .swiper-slider-business .swiper-caption-text p { width: 100%; } .swiper-slider-business .swiper-caption-text-inner { position: relative; overflow: hidden; } .swiper-slider-business .swiper-caption-text-sm { max-width: 420px; } .swiper-slider-business .swiper-caption-text-decoration { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; background: #0045b6; } .swiper-slider-business .swiper-slider-nav { position: absolute; left: 50%; bottom: 20px; z-index: 20; width: 100%; transform: translate3d(-50%, 0, 0); pointer-events: none; text-align: left; } .swiper-slider-business .swiper-slider-nav > * + * { margin-left: 10px; } .swiper-slider-business .wow-outer { white-space: normal; } .swiper-slider-business .wow-outer span { display: inline-block; } .swiper-slider-business .swiper-button-prev, .swiper-slider-business .swiper-button-next { position: relative; transform: none; left: auto; top: auto; right: auto; display: inline-block; pointer-events: auto; } .swiper-slider-business * + .swiper-caption-text { margin-top: 20px; } @media (min-width: 768px) { .swiper-slider-business .swiper-slide-caption { padding: 130px 0 180px; } } @media (min-width: 992px) { .swiper-slider-business .swiper-slider-nav { bottom: 40px; } } @media (min-width: 1200px) { .swiper-slider-business .swiper-slide::before { display: none; } } @media (min-width: 1600px) { .swiper-slider-business * + .swiper-caption-text { margin-top: 45px; } } .swiper-slider-minimal { min-height: 41.66667vw; } .swiper-slider-minimal .swiper-slide { background-position: 50% 50%; justify-content: flex-start; } .swiper-slider-minimal .swiper-slide::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: #000; opacity: .1; } .swiper-slider-minimal .swiper-slide > * { position: relative; z-index: 1; } .swiper-slider-minimal .swiper-slide_video { display: flex; align-items: stretch; } .swiper-slider-minimal .vide_bg { position: relative; display: flex; flex-direction: column; justify-content: center; } .swiper-slider-minimal .vide_bg::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: #000; opacity: .4; } .swiper-slider-minimal .swiper-slide-caption { position: relative; z-index: 2; padding: 80px 0 120px; } .swiper-slider-minimal .swiper-slide-text { position: relative; overflow: hidden; width: auto; } .swiper-slider-minimal .swiper-slide-text > * { position: relative; transform: translateY(100%); } .swiper-slider-minimal .swiper-slide-active .swiper-slide-text > * { transform: translateY(0); transition: .5s .3s; } @media (min-width: 1200px) { .swiper-pagination-outer { bottom: 50px; } } @media (min-width: 768px) { .swiper-slider-minimal .swiper-slide-caption { padding: 170px 0 220px; } .swiper-slider-minimal .swiper-pagination-outer { bottom: 15px; justify-content: center; } } @media (min-width: 992px) { .swiper-slider-minimal .swiper-pagination-outer { bottom: 20px; display: flex; justify-content: flex-start; } } @media (min-width: 1600px) { .swiper-slider-minimal .swiper-pagination-outer { bottom: 90px; } } [data-custom-slide-effect='inter-leave-effect'] .swiper-slide { display: flex; will-change: transform; overflow: hidden; align-items: stretch; min-height: inherit; } [data-custom-slide-effect='inter-leave-effect'] .slide-inner { display: flex; flex-direction: column; align-items: center; justify-content: center; background-size: cover; } [data-custom-slide-effect='inter-leave-effect'] .slide-inner > * { width: 100%; } .swiper-slider-tiny { min-height: 400px; } .swiper-slider-tiny .swiper-slide { position: relative; } .swiper-slider-tiny .swiper-slide::before { content: ''; position: absolute; top: 0; right: 0; bottom: 0; left: 0; pointer-events: none; background: linear-gradient(to top, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0) 40%); } .swiper-slider-tiny .swiper-slide > * { position: relative; z-index: 1; } @media (min-width: 768px) { .swiper-slider-tiny { min-height: 39.58333vw; } } @media (min-width: 992px) { .swiper-slider-tiny .swiper-pagination-outer { bottom: 65px; } } .centered-pagination .swiper-pagination-outer { justify-content: center; } /* +* +* RD Google Map +*/ .google-map { color: #000; height: 200px; } .google-map img { max-width: none !important; } @media (min-width: 576px) { .google-map { height: 250px; } } @media (min-width: 768px) { .google-map { height: 300px; } } @media (min-width: 992px) { .google-map { height: 350px; } } @media (min-width: 1200px) { .google-map { height: 766px; } } .ie-10 .google-map, .ie-11 .google-map { height: 770px; } .map_locations { display: none; } .google-map-container, .google-map { width: 100%; } .google-map-markers { display: none; } /* +* +* Search Results +*/ .rd-search-results-live { position: absolute; left: 0; right: 0; top: 100%; max-height: 600px; overflow-y: auto; margin: -3px 0 0; text-align: left; z-index: 998; } .rd-search-results-live #search-results { position: relative; padding: 10px 0 0; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; color: #495057; background: #fff; border: 1px solid #c5c6cd; border-top: 0; opacity: 0; visibility: hidden; transform-origin: 50% 0; transform: scale(1, 0.9); transition: .3s all ease; } .rd-search-results-live #search-results.active { opacity: 1; visibility: visible; transform: scale(1, 1); } .rd-search-results-live #search-results.active .form-input { border-bottom-left-radius: 0; } .rd-search-results-live .search-quick-result { padding-left: 15px; padding-right: 15px; font-family: "Poppins", sans-serif; font-size: 14px; font-weight: 500; letter-spacing: .08em; line-height: 30px; text-transform: uppercase; color: #151515; } .rd-search-results-live .search-list { margin-top: 11px; } .rd-search-results-live .search-list li:only-child { padding: 0 15px 15px; } .rd-search-results-live .search-link { color: #aeb1be; } .rd-search-results-live .search-link:hover { color: #0045b6; } .rd-search-results-live .search-error { padding: 0 0 20px; font-size: 14px; line-height: 1.6; } .rd-search-results-live .search-title { position: relative; font-size: 16px; font-weight: 400; color: #151515; } .rd-search-results-live .search-title a:hover { color: #0045b6; } .rd-search-results-live .search-link { display: block; } .rd-search-results-live .search-list-item-all { margin-top: 18px; width: 100%; } .rd-search-results-live .search-submit { position: relative; overflow: hidden; z-index: 0; display: block; padding: 10px 20px; margin: 0 -1px; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; font-family: "Poppins", sans-serif; font-size: 12px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; text-align: center; color: #fff; background: #0045b6; } .rd-search-results-live .search-submit:hover { color: #fff; background: #4854ed; } .rd-search-results-live .match { display: none; } .not-empty ~ .rd-search-results-live { visibility: visible; opacity: 1; } .rd-search-results-live p { font-size: 14px; } .rd-search-results-live .search-list-item { padding: 0 15px; } .rd-search-results-live * + p { margin-top: 3px; } .rd-search-results-live .search-list-item + .search-list-item { margin-top: 17px; } .rd-search-results .search-list { counter-reset: li; text-align: left; padding-left: 0; font-size: 18px; list-style-type: none; overflow: hidden; } .rd-search-results .search-list li div { overflow: hidden; text-overflow: ellipsis; max-width: 100%; white-space: nowrap; } .rd-search-results .search-list li:only-child::before { display: none; } .rd-search-results .search-list-item { position: relative; padding-left: 40px; font-size: 16px; color: #9b9b9b; } .rd-search-results .search-list-item::before { content: counter(li, decimal) "."; counter-increment: li; position: absolute; left: 0; top: -0.1em; line-height: inherit; } .rd-search-results .search-title { position: relative; color: #0045b6; } .rd-search-results .search-title a { color: inherit; } .rd-search-results .search-title a:hover { color: #343db0; } .rd-search-results .search { color: #fff; padding: 0 .25em; background: #0045b6; } .rd-search-results .match { padding: 5px; font-size: 12px; line-height: 1.7; letter-spacing: .1em; text-transform: uppercase; color: #000; } .rd-search-results .match em { margin: 0; font-style: normal; } .rd-search-results * + .match { margin-top: 10px; } .rd-search-results .search-list-item + .search-list-item { margin-top: 40px; } * + .rd-search-results { margin-top: 40px; } @media (min-width: 768px) { .rd-search-results .search-list-item::before { top: 4px; } .rd-search-results * + p { margin-top: 12px; } * + .rd-search-results { margin-top: 55px; } } @media (min-width: 992px) { .rd-search-results .search-list-item { padding-left: 50px; } .rd-search-results * + p { margin-top: 17px; } .rd-search-results .search-list-item + .search-list-item { margin-top: 50px; } } @media (min-width: 1200px) { * + .rd-search-results { margin-top: 70px; } } @media (min-width: 1600px) { .rd-search-results .search-list-item + .search-list-item { margin-top: 70px; } } .rd-search-classic { position: relative; } .rd-search-classic .form-wrap-inner { position: relative; } .rd-search-classic .form-input { padding-right: 50px; } .rd-search-classic .form-input, .rd-search-classic .form-label { letter-spacing: 0; } .rd-search-classic .rd-search-submit { background: none; border: none; display: inline-block; padding: 0; outline: none; outline-offset: 0; cursor: pointer; -webkit-appearance: none; position: absolute; top: 0; right: 0; bottom: 0; width: 50px; padding-top: 2px; padding-right: 3px; margin: 0; text-align: center; color: #aeb1be; font-size: 0; line-height: 0; } .rd-search-classic .rd-search-submit::-moz-focus-inner { border: none; padding: 0; } .rd-search-classic .rd-search-submit:before { position: relative; top: -1px; content: '\f43b'; font: 400 20px 'Material Design Icons'; line-height: 1; transition: .33s all ease; } .rd-search-classic .rd-search-submit:hover { color: #0045b6; } .rd-search.rd-search-inline { position: relative; } .rd-search.rd-search-inline .form-input { padding-right: 48px; } .rd-search.rd-search-inline .button-link { position: absolute; top: 50%; transform: translateY(-50%); width: 48px; right: 0; margin: 0; font-size: 21px; color: #aeb1be; transition: .33s; } .rd-search.rd-search-inline .button-link::before { display: block; margin: auto; } .rd-search.rd-search-inline .button-link:hover { color: #0045b6; } .rd-search.rd-search-inline.form-sm .form-input { padding-right: 40px; } .rd-search.rd-search-inline.form-sm .button-link { width: 40px; font-size: 18px; } .rd-search.rd-search-inline.form-lg .form-input { padding-right: 56px; } .rd-search.rd-search-inline.form-lg .button-link { width: 56px; } /* +* +* Isotope +*/ @keyframes sprite-animation { from { background-position: 0 0; } } [data-isotope-layout] { z-index: 0; display: block; overflow: hidden; min-height: 160px; margin-left: -15px; margin-right: -15px; transition: .4s all ease; } [data-isotope-layout]:after { content: ''; position: absolute; top: 50%; left: 50%; width: 64px; height: 64px; margin-top: 15px; background-image: url("../images/isotope-loader.png"); background-position: -1152px 0; animation: 0.7s sprite-animation steps(18) infinite; transition: .4s all ease; transform: translate3d(-50%, -50%, 0); } [data-isotope-layout] [class*="col-"] { display: block; opacity: 0; will-change: transform; backface-visibility: hidden; transition: .1s opacity ease-in; } [data-isotope-layout].isotope--loaded [class*="col-"] { opacity: 1; } [data-isotope-layout].isotope--loaded:after { opacity: 0; visibility: hidden; } .isotope.isotope-condensed { margin-left: 0; margin-right: 0; overflow: hidden; } .isotope.isotope-condensed [class*='col'] { padding: 0; margin: 0 0 30px 0; } @media (min-width: 576px) { .isotope.isotope-condensed [class*='col'] { margin: 0 -1px -1px 0; } } .isotope-filters-list { position: relative; font-size: 16px; margin-left: -20px; margin-bottom: -10px; } .isotope-filters-list > * { margin-left: 20px; } .isotope-filters-list::after { content: ''; position: absolute; left: 20px; right: 0; bottom: 10px; border-bottom: 1px solid #e8e9ee; } .isotope-filters-list li { display: inline-block; margin-bottom: 10px; } .isotope-filters-list a { position: relative; display: block; z-index: 1; padding-bottom: 10px; color: #74757f; } .isotope-filters-list a::after { content: ''; position: absolute; right: 0; bottom: 0; width: 0; height: 1px; transition: .4s; background: #0045b6; } .isotope-filters-list a:hover { color: #495057; } .isotope-filters-list a.active { color: #495057; } .isotope-filters-list a.active::after { right: auto; left: 0; width: 100%; } @media (min-width: 992px) { .isotope-filters-list { font-size: 18px; margin-left: -48px; } .isotope-filters-list > * { margin-left: 48px; } .isotope-filters-list::after { left: 45px; } } @media (min-width: 1200px) { .isotope-filters-list { font-size: 20px; } } @media (max-width: 767.98px) { .isotope-responsive { margin-left: -7px; margin-right: -7px; margin-bottom: -14px; } .isotope-responsive:empty { margin-bottom: 0; } .isotope-responsive > * { margin-bottom: 14px; } .isotope-responsive [class*='col'] { padding-left: 7px; padding-right: 7px; } } @media (min-width: 768px) { .isotope-responsive { margin-bottom: -30px; } .isotope-responsive:empty { margin-bottom: 0; } .isotope-responsive > * { margin-bottom: 30px; } } @media (max-width: 767.98px) { .isotope-filters-modern { position: relative; z-index: 10; } .isotope-filters-toggle { display: block; padding: 10px 20px; color: #fff; background: #0045b6; box-shadow: none; border: 0; } .isotope-filters-toggle .caret { position: relative; display: inline-block; margin-left: 5px; font-family: "FontAwesome"; font-size: 14px; transition: .22s; will-change: transform; } .isotope-filters-toggle .caret::before { content: '\f107'; } .isotope-filters-toggle.active .caret { transform: scale(1, -1); } .isotope-filters-list { position: absolute; top: 100%; left: 0; z-index: 10; min-width: 240px; margin: 5px 0 0 0; padding: 20px; border: 1px solid #e8e9ee; opacity: 0; visibility: hidden; background: #fff; transition: .3s ease-in; } .isotope-filters-list::after { display: none; } .isotope-filters-list.active { opacity: 1; visibility: visible; } .isotope-filters-list li { display: block; width: 100%; margin-left: 0; } .isotope-filters-list a { padding: 0; } .isotope-filters-list a::after { display: none; } } @media (min-width: 768px) { .isotope-filters-modern .isotope-filters-toggle { display: none; } } * + .isotope { margin-top: 30px; } @media (min-width: 768px) { * + .isotope { margin-top: 47px; } } .nav { display: block; } .tabs-custom { text-align: left; } .tabs-custom .nav-tabs { font-size: 0; line-height: 0; word-spacing: 0; border: 0; } .tabs-custom .nav-tabs:before, .tabs-custom .nav-tabs:after { display: none; } .tabs-custom .nav-item { float: none; border: 0; cursor: pointer; transition: .33s all ease; } .tabs-custom .nav-link { margin: 0; border-radius: 0; border: 0; } .tabs-custom .nav-link.active { cursor: default; border: 0; } .tab-content > .tab-pane { display: block; visibility: hidden; height: 0; overflow: hidden; } .tab-content > .active { visibility: visible; height: auto; overflow: visible; } .tabs-line .nav-tabs { position: relative; } .tabs-line .nav-link { font-size: 16px; line-height: 1.4; text-align: center; vertical-align: middle; } .tabs-line * + .tab-content { margin-top: 20px; } @media (max-width: 767.98px) { .tabs-line .nav-tabs { max-width: 100%; border: 1px solid #e8e9ee; } .tabs-line .nav-item { margin: -1px; } .tabs-line .nav-link { padding: 7px 10px; color: #74757f; background: transparent; } .tabs-line .nav-item + .nav-item .nav-link { border-top: 1px solid #e8e9ee; } .tabs-line .nav-link:hover, .tabs-line .nav-link.active { color: #fff; background: #0045b6; border-color: #0045b6; } } * + .tabs-line { margin-top: 30px; } .tabs-corporate .nav-tabs { position: relative; border: 1px solid #e5e7e9; } .tabs-corporate .nav-item { margin: -1px; } .tabs-corporate .nav-link { padding: 10px 10px; text-transform: uppercase; font-size: 12px; font-weight: 700; line-height: 1.4; color: #74757f; background: transparent; border-bottom: 1px solid #e5e7e9; text-align: center; vertical-align: middle; } .tabs-corporate .nav-link:first-child { border-top: 1px solid #e5e7e9; } .tabs-corporate .nav-link:hover, .tabs-corporate .nav-link.active { color: #fff; background: #0045b6; border-color: #0045b6; } .tabs-corporate .tab-content { padding: 30px 0 0; } @media (min-width: 768px) { * + .tabs-line { margin-top: 40px; } .tabs-horizontal.tabs-corporate .nav-tabs { position: relative; width: 100%; display: block; transform: translate3d(0, -10px, 0); margin-bottom: -10px; margin-left: -5px; margin-right: -5px; text-align: center; border: 0; will-change: transform; } .tabs-horizontal.tabs-corporate .nav-tabs > * { margin-top: 10px; padding-left: 5px; padding-right: 5px; } .tabs-horizontal.tabs-corporate .nav-item { display: inline-block; will-change: transform; } .tabs-horizontal.tabs-corporate .nav-link { display: block; position: relative; z-index: 1; min-width: 130px; letter-spacing: .1em; padding: 13px 20px; border: 2px solid #e5e7e9; } .tabs-horizontal.tabs-corporate .nav-link, .tabs-horizontal.tabs-corporate .nav-link::before { transition-timing-function: cubic-bezier(0.2, 1, 0.3, 1); } .tabs-horizontal.tabs-corporate .nav-link::before { content: ''; position: absolute; top: -1px; left: -1px; width: calc(100% + 2px); height: calc(100% + 2px); z-index: -1; opacity: 0; transform: scale3d(0.7, 1, 1); transition: transform 0.4s, opacity 0.4s; transition-timing-function: cubic-bezier(0.2, 1, 0.3, 1); background: #0045b6; } .tabs-horizontal.tabs-corporate .nav-link.active, .tabs-horizontal.tabs-corporate .nav-link:hover { color: #fff; border-color: #0045b6; background-color: transparent; } .tabs-horizontal.tabs-corporate .nav-link.active::before, .tabs-horizontal.tabs-corporate .nav-link:hover::before { opacity: 1; transform: translate3d(0, 0, 0) scale3d(1, 1, 1); } .tabs-horizontal.tabs-line .nav-tabs { transform: translateY(-20px); margin-bottom: -20px; border: 0; border-bottom: 1px solid #e8e9ee; } .tabs-horizontal.tabs-line .nav-item { display: inline-block; margin-top: 20px; } .tabs-horizontal.tabs-line .nav-item:not(:last-child) { margin-right: 52px; } .tabs-horizontal.tabs-line .nav-link { position: relative; padding: 0 0 13px 0; font-size: 20px; background-color: transparent; color: #9b9b9b; border: 0; } .tabs-horizontal.tabs-line .nav-link::after { content: ''; position: absolute; bottom: 0; right: 0; height: 1px; width: 0; background: #0045b6; transition: .22s ease; } .tabs-horizontal.tabs-line .nav-link.active, .tabs-horizontal.tabs-line .nav-link:hover { color: #495057; } .tabs-horizontal.tabs-line .nav-link.active::after { right: auto; left: 0; width: 100%; } .tabs-horizontal.tabs-line * + .tab-content { margin-top: 25px; } .tabs-vertical { display: flex; align-items: flex-start; } .tabs-vertical .nav-tabs { display: flex; flex-direction: column; align-items: stretch; flex-shrink: 0; max-width: 50%; } .tabs-vertical .nav-item { border: 0; width: 100%; text-align: left; } .tabs-vertical .nav-link.active, .tabs-vertical .nav-link:hover { box-shadow: 0 9px 21px 0 rgba(30, 30, 30, 0.13); } .tabs-vertical .tab-content { flex-grow: 1; } .tabs-vertical.tabs-corporate .nav-tabs { width: auto; min-width: 260px; border: 0; } .tabs-vertical.tabs-corporate .nav-item { margin: 0; } .tabs-vertical.tabs-corporate .nav-link { position: relative; padding: 17px 30px; border: 0; overflow: hidden; text-align: left; } .tabs-vertical.tabs-corporate .nav-item + .nav-item { margin-top: 2px; } .tabs-vertical.tabs-corporate .tab-content { padding: 0 0 0 30px; } } @media (min-width: 992px) { .tabs-horizontal.tabs-corporate .tab-content { padding: 60px 30px 0; } .tabs-vertical.tabs-corporate .tab-content { padding: 0 0 0 45px; } } .card-group-custom { margin-bottom: 0; } .card-group-custom .card-header + .collapse > .card-body, .card-group-custom .card-header + .collapse > .list-group { border-top: 0; } .card-group-custom .card + .card { margin-top: 0; } .card-title { margin-bottom: 0; } .card-header { background-color: transparent; } .card-custom { display: block; margin: 0; background: inherit; border: 0; border-radius: 0; box-shadow: none; text-align: left; } .card-custom a { display: block; } .card-custom .card-header { padding: 0; border-bottom: 0; border-top-left-radius: 0; border-top-right-radius: 0; } .card-custom .card-body { padding: 0; border: 0; } * + .card-group-custom { margin-top: 30px; } @media (min-width: 768px) { * + .card-group-custom { margin-top: 40px; } } /* +* Card corporate +*/ .card-corporate { border-top: 1px solid #f5f6fa; text-align: left; } .card-corporate:last-child { border-bottom: 1px solid #f5f6fa; } .card-corporate .card-collapse { background: #fff; } .card-corporate .card-title a { position: relative; z-index: 1; padding: 15px 40px 15px 70px; font-size: 17px; line-height: 1.5; letter-spacing: 0; color: #495057; transition: 1.3s all ease; } .card-corporate .card-title a[aria-expanded='true'], .card-corporate .card-title a:hover { color: #0045b6; } .card-corporate .card-title a .card-arrow { transition: .33s; } .card-corporate .card-title a.collapsed { color: #495057; border-bottom-width: 0; } .card-corporate .card-title a.collapsed .card-arrow::before { opacity: 1; } .card-corporate .card-title a.collapsed .card-arrow::after { opacity: 0; } .card-corporate .card-arrow { position: absolute; top: 16px; left: 13px; z-index: 2; width: 20px; height: 20px; color: #0045b6; transition: .33s all ease; text-align: center; } .card-corporate .card-arrow::before, .card-corporate .card-arrow::after { position: absolute; z-index: 4; font-family: 'Material Design Icons'; font-size: 24px; line-height: 1; transition: .22s; } .card-corporate .card-arrow::before { content: '\f505'; opacity: 0; } .card-corporate .card-arrow::after { content: '\f466'; opacity: 1; } .card-corporate .card-collapse { position: relative; z-index: 1; color: #74757f; } .card-corporate .card-body { padding: 2px 44px 25px 70px; } @media (max-width: 767.98px) { .card-corporate .card-arrow { left: 0; } .card-corporate .card-title a, .card-corporate .card-body { padding-left: 50px; } } @media (min-width: 768px) { .card-corporate .card-title a { font-size: 20px; line-height: 1.4; } .card-corporate .card-arrow { top: 18px; } } /* +* +* Tooltop Custom +*/ .tooltip { font-size: 16px; line-height: 1.2; } .tooltip-inner { background-color: #0045b6; } .bs-tooltip-top .arrow::before { border-top-color: #0045b6; } .bs-tooltip-right .arrow::before { border-right-color: #0045b6; } .bs-tooltip-bottom .arrow::before { border-bottom-color: #0045b6; } .bs-tooltip-left .arrow::before { border-left-color: #0045b6; } @media (min-width: 1200px) { .tooltip-inner { padding: 9px 18px; } } /* +* +* Modal Custom +*/ .modal { display: flex !important; padding: 30px 15px 7vh; visibility: hidden; } .modal .close { cursor: pointer; color: #74757f; transition: .22s; } .modal .close span::before { content: '\f24c'; font-family: 'Material Design Icons'; } .modal .close:hover { color: #495057; } .modal.show { visibility: visible; } .modal.fade .modal-dialog { transform: translate3d(0, -30px, 0); } .modal.show .modal-dialog { transform: translate3d(0, 0, 0); } .modal-dialog { display: flex; align-items: flex-start; justify-content: center; margin: 0 auto; } .modal-content { height: 100%; border-radius: 6px; } .modal-body { height: 100%; overflow-y: auto; } @media (min-width: 768px) { .modal-header, .modal-body { padding-left: 30px; padding-right: 30px; } .modal-header { padding-top: 20px; padding-bottom: 20px; } .modal-body { padding-top: 30px; padding-bottom: 30px; } .modal-dialog { max-width: 800px; } } /* +* +* Progress Bars +*/ .progress-linear { position: relative; text-align: left; } .progress-linear .progress-header { display: flex; align-items: center; justify-content: space-between; transform: translate3d(0, -10px, 0); margin-bottom: -10px; margin-left: -5px; margin-right: -5px; } .progress-linear .progress-header > * { margin-top: 10px; padding-left: 5px; padding-right: 5px; } .progress-linear .progress-bar-linear-wrap { height: 2px; background: #f5f6fa; } .progress-linear .progress-bar-linear { width: 0; height: inherit; background: #0045b6; transition: .5s all ease-in-out; } .progress-linear .progress-value::after { content: '\0025'; } * + .progress-linear { margin-top: 25px; } * + .progress-linear-outer { margin-top: 25px; } * + .progress-bar-linear-wrap { margin-top: 8px; } .progress-linear + .progress-linear { margin-top: 20px; } .progress-linear-outer + .progress-linear-outer { margin-top: 20px; } @media (min-width: 768px) { * + .progress-linear { margin-top: 40px; } * + .progress-linear-outer { margin-top: 40px; } } @media (min-width: 1200px) { .progress-linear + .progress-linear { margin-top: 34px; } .progress-linear-outer + .progress-linear-outer { margin-top: 34px; } } /* +* +* Time Circles +*/ .time_circles { position: relative; width: 100%; } .time_circles > div { position: absolute; top: 0 !important; bottom: 0; display: flex; align-items: center; flex-wrap: wrap; justify-content: center; margin-top: -10px; text-align: center; } @media (min-width: 1200px) { #DateCountdown { width: 100%; } } .time_circles > div > h4 { position: absolute; left: 0; top: calc(100% + 14px); right: 0; transform: translateY(-100%); margin: 0; padding: 0; text-align: center; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px !important; line-height: 1.2 !important; color: #868e96; } .time_circles > div > h4 + * { margin-top: 0; } @media (min-width: 576px) { .time_circles > div > h4 { font-size: 16px !important; } } .time_circles > div > span { display: block; text-align: center; letter-spacing: 0; font-size: 24px !important; font-weight: 700; line-height: 1 !important; color: #111111; } .context-dark .time_circles > div > span, .bg-gray-700 .time_circles > div > span, .bg-gray-800 .time_circles > div > span, .bg-primary .time_circles > div > span, .bg-primary-darker .time_circles > div > span { color: #fff; } .context-dark .time_circles > div > h4, .bg-gray-700 .time_circles > div > h4, .bg-gray-800 .time_circles > div > h4, .bg-primary .time_circles > div > h4, .bg-primary-darker .time_circles > div > h4 { color: #fff; } .countdown-wrap { max-width: 530px; padding-bottom: 20px; } * + .countdown-wrap { margin-top: 35px; } .countdown-wrap + .button { margin-top: 40px; } /* +* jQuery mousewheel plugin +*/ /* +------------------------------------------------------------------------------------------------------------------------ +1. BASIC STYLE +------------------------------------------------------------------------------------------------------------------------ +*/ .mCustomScrollbar { -ms-touch-action: pinch-zoom; touch-action: pinch-zoom; /* direct pointer events to js */ } .mCustomScrollbar.mCS_no_scrollbar, .mCustomScrollbar.mCS_touch_action { -ms-touch-action: auto; touch-action: auto; } .mCustomScrollBox { /* contains plugin's markup */ position: relative; overflow: hidden; height: 100%; max-width: 100%; outline: none; direction: ltr; } .mCSB_container { /* contains the original content */ overflow: hidden; width: auto; height: auto; } /* +------------------------------------------------------------------------------------------------------------------------ +2. VERTICAL SCROLLBAR +y-axis +------------------------------------------------------------------------------------------------------------------------ +*/ .mCSB_inside > .mCSB_container { margin-right: 30px; } .mCSB_container.mCS_no_scrollbar_y.mCS_y_hidden { margin-right: 0; } /* non-visible scrollbar */ .mCS-dir-rtl-custom > .mCSB_inside > .mCSB_container { /* RTL direction/left-side scrollbar */ margin-right: 0; margin-left: 30px; } .mCS-dir-rtl-custom > .mCSB_inside > .mCSB_container.mCS_no_scrollbar_y.mCS_y_hidden { margin-left: 0; } /* RTL direction/left-side scrollbar */ .mCSB_scrollTools { /* contains scrollbar markup (draggable element, dragger rail, buttons etc.) */ position: absolute; width: 16px; height: auto; left: auto; top: 0; right: 0; bottom: 0; } .mCSB_outside + .mCSB_scrollTools { right: -26px; } /* scrollbar position: outside */ .mCS-dir-rtl-custom > .mCSB_inside > .mCSB_scrollTools, .mCS-dir-rtl-custom > .mCSB_outside + .mCSB_scrollTools { /* RTL direction/left-side scrollbar */ right: auto; left: 0; } .mCS-dir-rtl-custom > .mCSB_outside + .mCSB_scrollTools { left: -26px; } /* RTL direction/left-side scrollbar (scrollbar position: outside) */ .mCSB_scrollTools .mCSB_draggerContainer { /* contains the draggable element and dragger rail markup */ position: absolute; top: 0; left: 0; bottom: 0; right: 0; height: auto; } .mCSB_scrollTools a + .mCSB_draggerContainer { margin: 20px 0; } .mCSB_scrollTools .mCSB_draggerRail { width: 16px; height: 100%; margin: 0 auto; border-radius: 0; } .mCSB_scrollTools .mCSB_dragger { /* the draggable element */ cursor: pointer; width: 100%; height: 30px; /* minimum dragger height */ z-index: 1; } .mCSB_scrollTools .mCSB_dragger .mCSB_dragger_bar { /* the dragger element */ position: relative; width: 16px; height: 100%; margin: 0 auto; border-radius: 0; text-align: center; } .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_dragger.mCSB_dragger_onDrag_expanded .mCSB_dragger_bar, .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_draggerContainer:hover .mCSB_dragger .mCSB_dragger_bar { width: 12px; /* auto-expanded scrollbar */ } .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_dragger.mCSB_dragger_onDrag_expanded + .mCSB_draggerRail, .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_draggerContainer:hover .mCSB_draggerRail { width: 8px; /* auto-expanded scrollbar */ } .mCSB_scrollTools .mCSB_buttonUp, .mCSB_scrollTools .mCSB_buttonDown { display: block; position: absolute; height: 20px; width: 100%; overflow: hidden; margin: 0 auto; cursor: pointer; } .mCSB_scrollTools .mCSB_buttonDown { bottom: 0; } /* +------------------------------------------------------------------------------------------------------------------------ +3. HORIZONTAL SCROLLBAR +x-axis +------------------------------------------------------------------------------------------------------------------------ +*/ .mCSB_horizontal.mCSB_inside > .mCSB_container { margin-right: 0; margin-bottom: 30px; } .mCSB_horizontal.mCSB_outside > .mCSB_container { min-height: 100%; } .mCSB_horizontal > .mCSB_container.mCS_no_scrollbar_x.mCS_x_hidden { margin-bottom: 0; } /* non-visible scrollbar */ .mCSB_scrollTools.mCSB_scrollTools_horizontal { width: auto; height: 16px; top: auto; right: 0; bottom: 0; left: 0; } .mCustomScrollBox + .mCSB_scrollTools.mCSB_scrollTools_horizontal, .mCustomScrollBox + .mCSB_scrollTools + .mCSB_scrollTools.mCSB_scrollTools_horizontal { bottom: -26px; } /* scrollbar position: outside */ .mCSB_scrollTools.mCSB_scrollTools_horizontal a + .mCSB_draggerContainer { margin: 0 20px; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_draggerRail { width: 100%; height: 2px; margin: 7px 0; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_dragger { width: 30px; /* minimum dragger width */ height: 100%; left: 0; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_dragger .mCSB_dragger_bar { width: 100%; height: 4px; margin: 6px auto; } .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_dragger.mCSB_dragger_onDrag_expanded .mCSB_dragger_bar, .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_draggerContainer:hover .mCSB_dragger .mCSB_dragger_bar { height: 12px; /* auto-expanded scrollbar */ margin: 2px auto; } .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_dragger.mCSB_dragger_onDrag_expanded + .mCSB_draggerRail, .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_draggerContainer:hover .mCSB_draggerRail { height: 8px; /* auto-expanded scrollbar */ margin: 4px 0; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_buttonLeft, .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_buttonRight { display: block; position: absolute; width: 20px; height: 100%; overflow: hidden; margin: 0 auto; cursor: pointer; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_buttonLeft { left: 0; } .mCSB_scrollTools.mCSB_scrollTools_horizontal .mCSB_buttonRight { right: 0; } /* +------------------------------------------------------------------------------------------------------------------------ +4. VERTICAL AND HORIZONTAL SCROLLBARS +yx-axis +------------------------------------------------------------------------------------------------------------------------ +*/ .mCSB_container_wrapper { position: absolute; height: auto; width: auto; overflow: hidden; top: 0; left: 0; right: 0; bottom: 0; margin-right: 30px; margin-bottom: 30px; } .mCSB_container_wrapper > .mCSB_container { padding-right: 30px; padding-bottom: 30px; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } .mCSB_vertical_horizontal > .mCSB_scrollTools.mCSB_scrollTools_vertical { bottom: 20px; } .mCSB_vertical_horizontal > .mCSB_scrollTools.mCSB_scrollTools_horizontal { right: 20px; } /* non-visible horizontal scrollbar */ .mCSB_container_wrapper.mCS_no_scrollbar_x.mCS_x_hidden + .mCSB_scrollTools.mCSB_scrollTools_vertical { bottom: 0; } /* non-visible vertical scrollbar/RTL direction/left-side scrollbar */ .mCSB_container_wrapper.mCS_no_scrollbar_y.mCS_y_hidden + .mCSB_scrollTools ~ .mCSB_scrollTools.mCSB_scrollTools_horizontal, .mCS-dir-rtl-custom > .mCustomScrollBox.mCSB_vertical_horizontal.mCSB_inside > .mCSB_scrollTools.mCSB_scrollTools_horizontal { right: 0; } /* RTL direction/left-side scrollbar */ .mCS-dir-rtl-custom > .mCustomScrollBox.mCSB_vertical_horizontal.mCSB_inside > .mCSB_scrollTools.mCSB_scrollTools_horizontal { left: 20px; } /* non-visible scrollbar/RTL direction/left-side scrollbar */ .mCS-dir-rtl-custom > .mCustomScrollBox.mCSB_vertical_horizontal.mCSB_inside > .mCSB_container_wrapper.mCS_no_scrollbar_y.mCS_y_hidden + .mCSB_scrollTools ~ .mCSB_scrollTools.mCSB_scrollTools_horizontal { left: 0; } .mCS-dir-rtl-custom > .mCSB_inside > .mCSB_container_wrapper { /* RTL direction/left-side scrollbar */ margin-right: 0; margin-left: 30px; } .mCSB_container_wrapper.mCS_no_scrollbar_y.mCS_y_hidden > .mCSB_container { padding-right: 0; } .mCSB_container_wrapper.mCS_no_scrollbar_x.mCS_x_hidden > .mCSB_container { padding-bottom: 0; } .mCustomScrollBox.mCSB_vertical_horizontal.mCSB_inside > .mCSB_container_wrapper.mCS_no_scrollbar_y.mCS_y_hidden { margin-right: 0; /* non-visible scrollbar */ margin-left: 0; } /* non-visible horizontal scrollbar */ .mCustomScrollBox.mCSB_vertical_horizontal.mCSB_inside > .mCSB_container_wrapper.mCS_no_scrollbar_x.mCS_x_hidden { margin-bottom: 0; } /* +------------------------------------------------------------------------------------------------------------------------ +5. TRANSITIONS +------------------------------------------------------------------------------------------------------------------------ +*/ .mCSB_scrollTools, .mCSB_scrollTools .mCSB_dragger .mCSB_dragger_bar, .mCSB_scrollTools .mCSB_buttonUp, .mCSB_scrollTools .mCSB_buttonDown, .mCSB_scrollTools .mCSB_buttonLeft, .mCSB_scrollTools .mCSB_buttonRight { -webkit-transition: opacity .2s ease-in-out, background-color .2s ease-in-out; -moz-transition: opacity .2s ease-in-out, background-color .2s ease-in-out; -o-transition: opacity .2s ease-in-out, background-color .2s ease-in-out; transition: opacity .2s ease-in-out, background-color .2s ease-in-out; } .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_dragger_bar, .mCSB_scrollTools_vertical.mCSB_scrollTools_onDrag_expand .mCSB_draggerRail, .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_dragger_bar, .mCSB_scrollTools_horizontal.mCSB_scrollTools_onDrag_expand .mCSB_draggerRail { -webkit-transition: width .2s ease-out .2s, height .2s ease-out .2s, margin-left .2s ease-out .2s, margin-right .2s ease-out .2s, margin-top .2s ease-out .2s, margin-bottom .2s ease-out .2s, opacity .2s ease-in-out, background-color .2s ease-in-out; -moz-transition: width .2s ease-out .2s, height .2s ease-out .2s, margin-left .2s ease-out .2s, margin-right .2s ease-out .2s, margin-top .2s ease-out .2s, margin-bottom .2s ease-out .2s, opacity .2s ease-in-out, background-color .2s ease-in-out; -o-transition: width .2s ease-out .2s, height .2s ease-out .2s, margin-left .2s ease-out .2s, margin-right .2s ease-out .2s, margin-top .2s ease-out .2s, margin-bottom .2s ease-out .2s, opacity .2s ease-in-out, background-color .2s ease-in-out; transition: width .2s ease-out .2s, height .2s ease-out .2s, margin-left .2s ease-out .2s, margin-right .2s ease-out .2s, margin-top .2s ease-out .2s, margin-bottom .2s ease-out .2s, opacity .2s ease-in-out, background-color .2s ease-in-out; } /* +------------------------------------------------------------------------------------------------------------------------ +6. SCROLLBAR COLORS, OPACITY AND BACKGROUNDS +------------------------------------------------------------------------------------------------------------------------ +*/ /* +---------------------------------------- +6.1 THEMES +---------------------------------------- +*/ /* default theme ("light") */ .mCSB_scrollTools .mCSB_draggerRail { background-color: transparent; } .mCSB_scrollTools .mCSB_dragger .mCSB_dragger_bar { background-color: #f2f3f8; } .mCSB_scrollTools .mCSB_dragger:hover .mCSB_dragger_bar, .mCSB_scrollTools .mCSB_dragger:active .mCSB_dragger_bar, .mCSB_scrollTools .mCSB_dragger.mCSB_dragger_onDrag .mCSB_dragger_bar { background-color: #d1d4e6; } .mCSB_scrollTools .mCSB_buttonUp, .mCSB_scrollTools .mCSB_buttonDown, .mCSB_scrollTools .mCSB_buttonLeft, .mCSB_scrollTools .mCSB_buttonRight { background-image: url(../images/mCSB_buttons.png); /* css sprites */ background-repeat: no-repeat; opacity: 0.4; } .mCSB_scrollTools .mCSB_buttonUp { background-position: 0 0; } .mCSB_scrollTools .mCSB_buttonDown { background-position: 0 -20px; } .mCSB_scrollTools .mCSB_buttonLeft { background-position: 0 -40px; } .mCSB_scrollTools .mCSB_buttonRight { background-position: 0 -56px; } .mCSB_scrollTools .mCSB_buttonUp:hover, .mCSB_scrollTools .mCSB_buttonDown:hover, .mCSB_scrollTools .mCSB_buttonLeft:hover, .mCSB_scrollTools .mCSB_buttonRight:hover { opacity: 0.75; } .mCSB_scrollTools .mCSB_buttonUp:active, .mCSB_scrollTools .mCSB_buttonDown:active, .mCSB_scrollTools .mCSB_buttonLeft:active, .mCSB_scrollTools .mCSB_buttonRight:active { opacity: 0.9; } /* theme: "dark" */ .mCS-dark.mCSB_scrollTools .mCSB_draggerRail { background-color: #000000; background-color: rgba(0, 0, 0, 0.15); } .mCS-dark.mCSB_scrollTools .mCSB_dragger .mCSB_dragger_bar { background-color: #000000; background-color: rgba(0, 0, 0, 0.75); } .mCS-dark.mCSB_scrollTools .mCSB_dragger:hover .mCSB_dragger_bar { background-color: rgba(0, 0, 0, 0.85); } .mCS-dark.mCSB_scrollTools .mCSB_dragger:active .mCSB_dragger_bar, .mCS-dark.mCSB_scrollTools .mCSB_dragger.mCSB_dragger_onDrag .mCSB_dragger_bar { background-color: rgba(0, 0, 0, 0.9); } .mCS-dark.mCSB_scrollTools .mCSB_buttonUp { background-position: -80px 0; } .mCS-dark.mCSB_scrollTools .mCSB_buttonDown { background-position: -80px -20px; } .mCS-dark.mCSB_scrollTools .mCSB_buttonLeft { background-position: -80px -40px; } .mCS-dark.mCSB_scrollTools .mCSB_buttonRight { background-position: -80px -56px; } /* +* +* Owl Carousel +*/ .owl-carousel .animated { animation-duration: 1000ms; animation-fill-mode: both; } .owl-carousel .owl-animated-in { z-index: 0; } .owl-carousel .owl-animated-out { z-index: 1; } .owl-carousel .fadeOut { animation-name: fadeOut; } @keyframes fadeOut { 0% { opacity: 1; } 100% { opacity: 0; } } /* + * Owl Carousel - Auto Height Plugin + */ .owl-height { transition: height 500ms ease-in-out; } /* + * Core Owl Carousel CSS File + */ .owl-carousel { display: none; width: 100%; -webkit-tap-highlight-color: transparent; /* position relative and z-index fix webkit rendering fonts issue */ position: relative; z-index: 1; } .owl-carousel .owl-stage { position: relative; -ms-touch-action: pan-Y; } .owl-carousel .owl-stage:after { content: "."; display: block; clear: both; visibility: hidden; line-height: 0; height: 0; } .owl-carousel .owl-stage-outer { position: relative; overflow: hidden; /* fix for flashing background */ -webkit-transform: translate3d(0px, 0px, 0px); } .owl-carousel .owl-controls .owl-nav .owl-prev, .owl-carousel .owl-controls .owl-nav .owl-next, .owl-carousel .owl-controls .owl-dot { cursor: pointer; user-select: none; } .owl-carousel.owl-loaded { display: block; } .owl-carousel.owl-loading { opacity: 0; display: block; } .owl-carousel.owl-hidden { opacity: 0; } .owl-carousel .owl-refresh .owl-item { display: none; } .owl-carousel .owl-item { position: relative; min-height: 1px; float: left; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; } .owl-carousel .owl-grab { cursor: move; cursor: -webkit-grab; cursor: grab; } .owl-carousel.owl-rtl { direction: rtl; } .owl-carousel.owl-rtl .owl-item { float: right; } /* No Js */ .no-js .owl-carousel { display: block; } /* + * Owl Carousel - Lazy Load Plugin + */ .owl-carousel .owl-item .owl-lazy { opacity: 0; transition: opacity 400ms ease; } /* + * Owl Carousel - Video Plugin + */ .owl-carousel .owl-video-wrapper { position: relative; height: 100%; background: #000000; } .owl-carousel .owl-video-play-icon { position: absolute; height: 80px; width: 80px; left: 50%; top: 50%; margin-left: -40px; margin-top: -40px; font: 400 40px/80px 'FontAwesome'; cursor: pointer; z-index: 1; transition: scale 100ms ease; } .owl-carousel .owl-video-play-icon:before { content: '\f144'; } .owl-carousel .owl-video-play-icon:hover { transform: scale(1.3); } .owl-carousel .owl-video-playing .owl-video-tn, .owl-carousel .owl-video-playing .owl-video-play-icon { display: none; } .owl-carousel .owl-video-tn { opacity: 0; height: 100%; background-position: center center; background-repeat: no-repeat; background-size: contain; transition: opacity 400ms ease; } .owl-carousel .owl-video-frame { position: relative; z-index: 1; } /* + * Owl Navigation + */ .owl-nav.disabled { display: none !important; } .owl-prev, .owl-next { position: absolute; top: 50%; transform: translateY(-50%); font: 400 46px/50px 'FontAwesome'; color: #aeb1be; } .owl-prev:hover, .owl-next:hover { color: #0045b6; } .owl-prev { left: 0; } .owl-prev::before { content: '\f104'; } .owl-next { right: 0; } .owl-next::before { content: '\f105'; } /* + * Owl Pagination + */ .owl-dots { margin-top: 10px; } .owl-dots.disabled { display: none !important; } @media (min-width: 576px) { .owl-dots { margin-top: 25px; } } button.owl-dot { background: none; border: none; display: inline-block; padding: 0; outline: none; outline-offset: 0; cursor: pointer; -webkit-appearance: none; } button.owl-dot::-moz-focus-inner { border: none; padding: 0; } .owl-dot { position: relative; display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; width: 14px; height: 14px; border-radius: 50%; margin-left: 14px; margin-right: 14px; text-align: center; outline: none; cursor: pointer; } .owl-dot::before { content: ''; display: block; width: 100%; height: 100%; transform: scale3d(0.56, 0.56, 0.56); background: #e8e9ee; border-radius: inherit; transition: .2s; } .owl-dot:only-child { display: none; } .owl-dot:hover::before, .owl-dot.active::before { transform: scale3d(1, 1, 1); background-color: #0045b6; } * + .owl-carousel { margin-top: 30px; } .owl-carousel-1 { text-align: center; } .owl-carousel-1 .owl-stage { position: relative; display: flex; align-items: center; } .owl-carousel-centered-pagination .owl-dots { text-align: center; } .owl-carousel-indigo { position: relative; overflow: hidden; max-width: 100vw; padding: 0 15px; margin-left: auto; margin-right: auto; pointer-events: none; } .owl-carousel-indigo, .owl-carousel-indigo * { backface-visibility: hidden; } .owl-carousel-indigo .owl-stage-outer { padding: 23px; margin: -23px; } .owl-carousel-indigo .owl-item.active { pointer-events: auto; } .owl-carousel-indigo .owl-dots { pointer-events: auto; } .owl-carousel-indigo * + .owl-dots { margin-top: 25px; } @media (max-width: 575.98px) { .owl-carousel-indigo .owl-dot { width: 11px; height: 11px; margin: 0 6px; } } @media (min-width: 768px) { .owl-carousel-indigo { padding: 0 0; } .owl-carousel-indigo * + .owl-dots { margin-top: 60px; } } .owl-carousel-dots-space .owl-dots { margin-bottom: 25px; } .lg-sub-html, .lg-toolbar { background-color: rgba(0, 0, 0, 0.45); } @font-face { font-family: lg; src: url(../fonts/lg.eot?n1z373); src: url(../fonts/lg.eot?#iefixn1z373) format("embedded-opentype"), url(../fonts/lg.woff?n1z373) format("woff"), url(../fonts/lg.ttf?n1z373) format("truetype"), url(../fonts/lg.svg?n1z373#lg) format("svg"); font-weight: 400; font-style: normal; } .lg-icon { font-family: lg; speak: none; font-style: normal; font-weight: 400; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .lg-actions .lg-next, .lg-actions .lg-prev { background-color: rgba(0, 0, 0, 0.45); border-radius: 2px; color: #999; cursor: pointer; display: block; font-size: 22px; margin-top: -10px; padding: 8px 10px 9px; position: absolute; top: 50%; z-index: 1180; border: none; outline: 0; } .lg-actions .lg-next.disabled, .lg-actions .lg-prev.disabled { pointer-events: none; opacity: .5; } .lg-actions .lg-next:hover, .lg-actions .lg-prev:hover { color: #fff; } .lg-actions .lg-next { right: 20px; } .lg-actions .lg-next:before { content: "\e095"; } .lg-actions .lg-prev { left: 20px; } .lg-actions .lg-prev:after { content: "\e094"; } @-webkit-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-moz-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-ms-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-webkit-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @-moz-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @-ms-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } .lg-outer.lg-right-end .lg-object { -webkit-animation: lg-right-end .3s; -o-animation: lg-right-end .3s; animation: lg-right-end .3s; position: relative; } .lg-outer.lg-left-end .lg-object { -webkit-animation: lg-left-end .3s; -o-animation: lg-left-end .3s; animation: lg-left-end .3s; position: relative; } .lg-toolbar { z-index: 1182; left: 0; position: absolute; top: 0; width: 100%; } .lg-toolbar .lg-icon { color: #999; cursor: pointer; float: right; font-size: 24px; height: 47px; line-height: 27px; padding: 10px 0; text-align: center; width: 50px; text-decoration: none !important; outline: 0; transition: color .2s linear; } @media (max-width: 575.98px) { .lg-toolbar .lg-icon { font-size: 21px; width: 36px; } } @media (max-width: 575.98px) { #lg-counter { font-size: 14px; line-height: 27px; } } .lg-toolbar .lg-icon:hover { color: #fff; } .lg-toolbar .lg-close:after { content: "\e070"; } .lg-toolbar .lg-download:after { content: "\e0f2"; } .lg-sub-html { bottom: 0; color: #eee; font-size: 16px; left: 0; padding: 10px 40px; position: fixed; right: 0; text-align: center; z-index: 1180; } .lg-sub-html h4 { margin: 0; font-size: 13px; font-weight: 700; } .lg-sub-html p { font-size: 12px; margin: 5px 0 0; } #lg-counter { color: #999; display: inline-block; font-size: 16px; padding-left: 20px; padding-top: 12px; vertical-align: middle; } .lg-next, .lg-prev, .lg-toolbar { opacity: 1; transition: transform 0.35s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.35s cubic-bezier(0, 0, 0.25, 1) 0s, color 0.2s linear; } .lg-hide-items .lg-prev { opacity: 0; transform: translate3d(-10px, 0, 0); } .lg-hide-items .lg-next { opacity: 0; transform: translate3d(10px, 0, 0); } .lg-hide-items .lg-toolbar { opacity: 0; transform: translate3d(0, -10px, 0); } body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-object { transform: scale3d(0.5, 0.5, 0.5); opacity: 0; transition: transform 250ms cubic-bezier(0, 0, 0.25, 1) 0s, opacity 250ms cubic-bezier(0, 0, 0.25, 1) !important; -moz-transform-origin: 50% 50%; -ms-transform-origin: 50% 50%; transform-origin: 50% 50%; } body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item.lg-complete .lg-object { transform: scale3d(1, 1, 1); opacity: 1; } .lg-outer .lg-thumb-outer { background-color: #0d0a0a; bottom: 0; position: absolute; width: 100%; z-index: 1180; max-height: 350px; transform: translate3d(0, 100%, 0); transition: transform 0.25s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer .lg-thumb-outer.lg-grab .lg-thumb-item { cursor: -webkit-grab; cursor: -moz-grab; cursor: -o-grab; cursor: -ms-grab; cursor: grab; } .lg-outer .lg-thumb-outer.lg-grabbing .lg-thumb-item { cursor: move; cursor: -webkit-grabbing; cursor: -moz-grabbing; cursor: -o-grabbing; cursor: -ms-grabbing; cursor: grabbing; } .lg-outer .lg-thumb-outer.lg-dragging .lg-thumb { transition-duration: 0s !important; } .lg-outer.lg-thumb-open .lg-thumb-outer { transform: translate3d(0, 0, 0); } .lg-outer .lg-thumb { padding: 10px 0; height: 100%; margin-bottom: -5px; margin-left: auto; margin-right: auto; } .lg-outer .lg-thumb-item { cursor: pointer; float: left; overflow: hidden; height: 100%; border: 2px solid #fff; border-radius: 4px; margin-bottom: 5px; } @media (min-width: 1025px) { .lg-outer .lg-thumb-item { transition: border-color .25s ease; } } .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { border-color: #a90707; } .lg-outer .lg-thumb-item img { width: 100%; height: 100%; object-fit: cover; } .lg-outer.lg-has-thumb .lg-item { padding-bottom: 120px; } .lg-outer.lg-can-toggle .lg-item { padding-bottom: 0; } .lg-outer.lg-pull-caption-up .lg-sub-html { transition: bottom .25s ease; } .lg-outer.lg-pull-caption-up.lg-thumb-open .lg-sub-html { bottom: 100px; } .lg-outer .lg-toogle-thumb { background-color: #0d0a0a; border-radius: 2px 2px 0 0; color: #999; cursor: pointer; font-size: 24px; height: 39px; line-height: 27px; padding: 5px 0; position: absolute; right: 20px; text-align: center; top: -39px; width: 50px; } .lg-outer .lg-toogle-thumb:hover, .lg-outer.lg-dropdown-active #lg-share { color: #fff; } .lg-outer .lg-toogle-thumb:after { content: "\e1ff"; } .lg-outer .lg-video-cont { display: inline-block; vertical-align: middle; max-width: 1140px; max-height: 100%; width: 100%; padding: 0 5px; } .lg-outer .lg-video { width: 100%; height: 0; padding-bottom: 56.25%; overflow: hidden; position: relative; } .lg-outer .lg-video .lg-object { display: inline-block; position: absolute; top: 0; left: 0; width: 100% !important; height: 100% !important; } .lg-outer .lg-video .lg-video-play { width: 84px; height: 59px; position: absolute; left: 50%; top: 50%; margin-left: -42px; margin-top: -30px; z-index: 1180; cursor: pointer; } .lg-outer .lg-has-iframe .lg-video { -webkit-overflow-scrolling: touch; overflow: auto; } .lg-outer .lg-has-vimeo .lg-video-play { background: url(../images/vimeo-play.png) no-repeat; } .lg-outer .lg-has-vimeo:hover .lg-video-play { background: url(../images/vimeo-play.png) 0 -58px no-repeat; } .lg-outer .lg-has-html5 .lg-video-play { background: url(../images/video-play.png) no-repeat; height: 64px; margin-left: -32px; margin-top: -32px; width: 64px; opacity: .8; } .lg-outer .lg-has-html5:hover .lg-video-play { opacity: 1; } .lg-outer .lg-has-youtube .lg-video-play { background: url(../images/youtube-play.png) no-repeat; } .lg-outer .lg-has-youtube:hover .lg-video-play { background: url(../images/youtube-play.png) 0 -60px no-repeat; } .lg-outer .lg-video-object { width: 100% !important; height: 100% !important; position: absolute; top: 0; left: 0; } .lg-outer .lg-has-video .lg-video-object { visibility: hidden; } .lg-outer .lg-has-video.lg-video-playing .lg-object, .lg-outer .lg-has-video.lg-video-playing .lg-video-play { display: none; } .lg-outer .lg-has-video.lg-video-playing .lg-video-object { visibility: visible; } .lg-progress-bar { background-color: #333; height: 5px; left: 0; position: absolute; top: 0; width: 100%; z-index: 1183; opacity: 0; transition: opacity 80ms ease 0s; } .lg-progress-bar .lg-progress { background-color: #a90707; height: 5px; width: 0; } .lg-progress-bar.lg-start .lg-progress { width: 100%; } .lg-show-autoplay .lg-progress-bar { opacity: 1; } .lg-autoplay-button:after { content: "\e01d"; } .lg-show-autoplay .lg-autoplay-button:after { content: "\e01a"; } .lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-image, .lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition-duration: 0s; } .lg-outer.lg-use-transition-for-zoom .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer.lg-use-left-for-zoom .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition: left 0.3s cubic-bezier(0, 0, 0.25, 1) 0s, top 0.3s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transform: translate3d(0, 0, 0); -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } .lg-outer .lg-item.lg-complete.lg-zoomable .lg-image { transform: scale3d(1, 1, 1); transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.15s !important; -moz-transform-origin: 0 0; -ms-transform-origin: 0 0; transform-origin: 0 0; -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } #lg-zoom-in:after { content: "\e311"; } #lg-actual-size { font-size: 20px; } #lg-actual-size:after { content: "\e033"; } #lg-zoom-out { opacity: .5; pointer-events: none; } #lg-zoom-out:after { content: "\e312"; } .lg-zoomed #lg-zoom-out { opacity: 1; pointer-events: auto; } .lg-outer .lg-pager-outer { bottom: 60px; left: 0; position: absolute; right: 0; text-align: center; z-index: 1180; height: 10px; } .lg-outer .lg-pager-outer.lg-pager-hover .lg-pager-cont { overflow: visible; } .lg-outer .lg-pager-cont { cursor: pointer; display: inline-block; overflow: hidden; position: relative; vertical-align: top; margin: 0 5px; } .lg-outer .lg-pager-cont:hover .lg-pager-thumb-cont { opacity: 1; transform: translate3d(0, 0, 0); } .lg-outer .lg-pager-cont.lg-pager-active .lg-pager { box-shadow: 0 0 0 2px #fff inset; } .lg-outer .lg-pager-thumb-cont { background-color: #fff; color: #fff; bottom: 100%; height: 83px; left: 0; margin-bottom: 20px; margin-left: -60px; opacity: 0; padding: 5px; position: absolute; width: 120px; border-radius: 3px; transition: opacity .15s ease 0s, transform .15s ease 0s; transform: translate3d(0, 5px, 0); } .lg-outer .lg-pager-thumb-cont img { width: 100%; height: 100%; } .lg-outer .lg-pager { background-color: rgba(255, 255, 255, 0.5); border-radius: 50%; box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.7) inset; display: block; height: 12px; transition: box-shadow .3s ease 0s; width: 12px; } .lg-outer .lg-pager:focus, .lg-outer .lg-pager:hover { box-shadow: 0 0 0 8px #fff inset; } .lg-outer .lg-caret { border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 10px dashed; bottom: -10px; display: inline-block; height: 0; left: 50%; margin-left: -5px; position: absolute; vertical-align: middle; width: 0; } .lg-fullscreen:after { content: "\e20c"; } .lg-fullscreen-on .lg-fullscreen:after { content: "\e20d"; } .lg-outer #lg-dropdown-overlay { background-color: rgba(0, 0, 0, 0.25); bottom: 0; cursor: default; left: 0; position: fixed; right: 0; top: 0; z-index: 1181; opacity: 0; visibility: hidden; transition: visibility 0s linear .18s, opacity .18s linear 0s; } .lg-outer.lg-dropdown-active #lg-dropdown-overlay, .lg-outer.lg-dropdown-active .lg-dropdown { transition-delay: 0s; -moz-transform: translate3d(0, 0, 0); -ms-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); opacity: 1; visibility: visible; } .lg-outer .lg-dropdown { background-color: #fff; border-radius: 2px; font-size: 14px; list-style-type: none; margin: 0; padding: 10px 0; position: absolute; right: 0; text-align: left; top: 50px; opacity: 0; visibility: hidden; -moz-transform: translate3d(0, 5px, 0); -ms-transform: translate3d(0, 5px, 0); transform: translate3d(0, 5px, 0); transition: transform .18s linear 0s, visibility 0s linear .5s, opacity .18s linear 0s; } .lg-outer .lg-dropdown:after { content: ""; display: block; height: 0; width: 0; position: absolute; border: 8px solid transparent; border-bottom-color: #fff; right: 16px; top: -16px; } .lg-outer .lg-dropdown > li:last-child { margin-bottom: 0; } .lg-outer .lg-dropdown > li:hover .lg-icon, .lg-outer .lg-dropdown > li:hover a { color: #333; } .lg-outer .lg-dropdown a { color: #333; display: block; white-space: pre; padding: 4px 12px; font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 12px; } .lg-outer .lg-dropdown a:hover { background-color: rgba(0, 0, 0, 0.07); } .lg-outer .lg-dropdown .lg-dropdown-text { display: inline-block; line-height: 1; margin-top: -3px; vertical-align: middle; } .lg-outer .lg-dropdown .lg-icon { color: #333; display: inline-block; float: none; font-size: 20px; height: auto; line-height: 1; margin-right: 8px; padding: 0; vertical-align: middle; width: auto; } .lg-outer, .lg-outer .lg, .lg-outer .lg-inner { width: 100%; height: 100%; } .lg-outer #lg-share { position: relative; } .lg-outer #lg-share:after { content: "\e80d"; } .lg-outer #lg-share-facebook .lg-icon { color: #3b5998; } .lg-outer #lg-share-facebook .lg-icon:after { content: "\e901"; } .lg-outer #lg-share-twitter .lg-icon { color: #00aced; } .lg-outer #lg-share-twitter .lg-icon:after { content: "\e904"; } .lg-outer #lg-share-googleplus .lg-icon { color: #dd4b39; } .lg-outer #lg-share-googleplus .lg-icon:after { content: "\e902"; } .lg-outer #lg-share-pinterest .lg-icon { color: #cb2027; } .lg-outer #lg-share-pinterest .lg-icon:after { content: "\e903"; } .lg-group:after { content: ""; display: table; clear: both; } .lg-outer { position: fixed; top: 0; left: 0; z-index: 1150; text-align: left; opacity: 0; transition: opacity .15s ease 0s; } .lg-outer * { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } .lg-outer.lg-visible { opacity: 1; } .lg-outer.lg-css3 .lg-item.lg-current, .lg-outer.lg-css3 .lg-item.lg-next-slide, .lg-outer.lg-css3 .lg-item.lg-prev-slide { transition-duration: inherit !important; transition-timing-function: inherit !important; } .lg-outer.lg-css3.lg-dragging .lg-item.lg-current, .lg-outer.lg-css3.lg-dragging .lg-item.lg-next-slide, .lg-outer.lg-css3.lg-dragging .lg-item.lg-prev-slide { transition-duration: 0s !important; opacity: 1; } .lg-outer.lg-grab img.lg-object { cursor: -webkit-grab; cursor: -moz-grab; cursor: -o-grab; cursor: -ms-grab; cursor: grab; } .lg-outer.lg-grabbing img.lg-object { cursor: move; cursor: -webkit-grabbing; cursor: -moz-grabbing; cursor: -o-grabbing; cursor: -ms-grabbing; cursor: grabbing; } .lg-outer .lg { position: relative; overflow: hidden; margin-left: auto; margin-right: auto; max-width: 100%; max-height: 100%; } .lg-outer .lg-inner { position: absolute; left: 0; top: 0; white-space: nowrap; } .lg-outer .lg-item { background: url(../images/loading.gif) center center no-repeat; display: none !important; } .lg-outer.lg-css .lg-current, .lg-outer.lg-css3 .lg-current, .lg-outer.lg-css3 .lg-next-slide, .lg-outer.lg-css3 .lg-prev-slide { display: inline-block !important; } .lg-outer .lg-img-wrap, .lg-outer .lg-item { display: inline-block; text-align: center; position: absolute; width: 100%; height: 100%; } .lg-outer .lg-img-wrap:before, .lg-outer .lg-item:before { content: ""; display: inline-block; height: 50%; width: 1px; margin-right: -1px; } .lg-outer .lg-img-wrap { position: absolute; padding: 0 5px; left: 0; right: 0; top: 0; bottom: 0; } .lg-outer .lg-item.lg-complete { background-image: none; } .lg-outer .lg-item.lg-current { z-index: 1160; } .lg-outer .lg-image { display: inline-block; vertical-align: middle; max-width: 100%; max-height: 100%; width: auto !important; height: auto !important; } .lg-outer.lg-show-after-load .lg-item .lg-object, .lg-outer.lg-show-after-load .lg-item .lg-video-play { opacity: 0; transition: opacity .15s ease 0s; } .lg-outer.lg-show-after-load .lg-item.lg-complete .lg-object, .lg-outer.lg-show-after-load .lg-item.lg-complete .lg-video-play { opacity: 1; } .lg-outer .lg-empty-html, .lg-outer.lg-hide-download #lg-download { display: none; } .lg-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1140; background-color: #000; opacity: 0; transition: opacity .15s ease 0s; } .lg-css3.lg-no-trans .lg-current, .lg-css3.lg-no-trans .lg-next-slide, .lg-css3.lg-no-trans .lg-prev-slide { transition: none 0s ease 0s !important; } .lg-css3.lg-use-css3 .lg-item, .lg-css3.lg-use-left .lg-item { -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } .lg-css3.lg-fade .lg-item { opacity: 0; } .lg-css3.lg-fade .lg-item.lg-current { opacity: 1; } .lg-css3.lg-fade .lg-item.lg-current, .lg-css3.lg-fade .lg-item.lg-next-slide, .lg-css3.lg-fade .lg-item.lg-prev-slide { transition: opacity .1s ease 0s; } .lg-css3.lg-slide.lg-use-css3 .lg-item { opacity: 0; } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide { transform: translate3d(-100%, 0, 0); } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide { transform: translate3d(100%, 0, 0); } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current { transform: translate3d(0, 0, 0); opacity: 1; } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current, .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide, .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide { transition: transform 1s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.1s ease 0s; } .lg-css3.lg-slide.lg-use-left .lg-item { opacity: 0; position: absolute; left: 0; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-prev-slide { left: -100%; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-next-slide { left: 100%; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-current { left: 0; opacity: 1; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-current, .lg-css3.lg-slide.lg-use-left .lg-item.lg-next-slide, .lg-css3.lg-slide.lg-use-left .lg-item.lg-prev-slide { transition: left 1s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.1s ease 0s; } .lg-sub-html, .lg-toolbar { background-color: rgba(0, 0, 0, 0.45); } @font-face { font-family: lg; src: url(../fonts/lg.eot?n1z373); src: url(../fonts/lg.eot?#iefixn1z373) format("embedded-opentype"), url(../fonts/lg.woff?n1z373) format("woff"), url(../fonts/lg.ttf?n1z373) format("truetype"), url(../fonts/lg.svg?n1z373#lg) format("svg"); font-weight: 400; font-style: normal; } .lg-icon { font-family: lg; speak: none; font-style: normal; font-weight: 400; font-variant: normal; text-transform: none; line-height: 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .lg-actions .lg-next, .lg-actions .lg-prev { background-color: rgba(0, 0, 0, 0.45); border-radius: 2px; color: #999; cursor: pointer; display: block; font-size: 22px; margin-top: -10px; padding: 8px 10px 9px; position: absolute; top: 50%; z-index: 1180; border: none; outline: 0; } .lg-actions .lg-next.disabled, .lg-actions .lg-prev.disabled { pointer-events: none; opacity: .5; } .lg-actions .lg-next:hover, .lg-actions .lg-prev:hover { color: #fff; } .lg-actions .lg-next { right: 20px; } .lg-actions .lg-next:before { content: "\e095"; } .lg-actions .lg-prev { left: 20px; } .lg-actions .lg-prev:after { content: "\e094"; } @-webkit-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-moz-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-ms-keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @keyframes lg-right-end { 0%, 100% { left: 0; } 50% { left: -30px; } } @-webkit-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @-moz-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @-ms-keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } @keyframes lg-left-end { 0%, 100% { left: 0; } 50% { left: 30px; } } .lg-outer.lg-right-end .lg-object { -webkit-animation: lg-right-end .3s; -o-animation: lg-right-end .3s; animation: lg-right-end .3s; position: relative; } .lg-outer.lg-left-end .lg-object { -webkit-animation: lg-left-end .3s; -o-animation: lg-left-end .3s; animation: lg-left-end .3s; position: relative; } .lg-toolbar { z-index: 1182; left: 0; position: absolute; top: 0; width: 100%; } .lg-toolbar .lg-icon:hover { color: #fff; } .lg-toolbar .lg-close:after { content: "\e070"; } .lg-toolbar .lg-download:after { content: "\e0f2"; } .lg-sub-html { bottom: 0; color: #eee; font-size: 16px; left: 0; padding: 10px 40px; position: fixed; right: 0; text-align: center; z-index: 1180; } .lg-sub-html h4 { margin: 0; font-size: 13px; font-weight: 700; } .lg-sub-html p { font-size: 12px; margin: 5px 0 0; } #lg-counter { color: #999; display: inline-block; font-size: 16px; padding-left: 20px; padding-top: 12px; vertical-align: middle; } .lg-next, .lg-prev, .lg-toolbar { opacity: 1; transition: transform 0.35s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.35s cubic-bezier(0, 0, 0.25, 1) 0s, color 0.2s linear; } .lg-hide-items .lg-prev { opacity: 0; transform: translate3d(-10px, 0, 0); } .lg-hide-items .lg-next { opacity: 0; transform: translate3d(10px, 0, 0); } .lg-hide-items .lg-toolbar { opacity: 0; transform: translate3d(0, -10px, 0); } body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-object { transform: scale3d(0.5, 0.5, 0.5); opacity: 0; transition: transform 250ms cubic-bezier(0, 0, 0.25, 1) 0s, opacity 250ms cubic-bezier(0, 0, 0.25, 1) !important; -moz-transform-origin: 50% 50%; -ms-transform-origin: 50% 50%; transform-origin: 50% 50%; } body:not(.lg-from-hash) .lg-outer.lg-start-zoom .lg-item.lg-complete .lg-object { transform: scale3d(1, 1, 1); opacity: 1; } .lg-outer .lg-thumb-outer { background-color: #0d0a0a; bottom: 0; position: absolute; width: 100%; z-index: 1180; max-height: 350px; transform: translate3d(0, 100%, 0); transition: transform 0.25s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer .lg-thumb-outer.lg-grab .lg-thumb-item { cursor: -webkit-grab; cursor: -moz-grab; cursor: -o-grab; cursor: -ms-grab; cursor: grab; } .lg-outer .lg-thumb-outer.lg-grabbing .lg-thumb-item { cursor: move; cursor: -webkit-grabbing; cursor: -moz-grabbing; cursor: -o-grabbing; cursor: -ms-grabbing; cursor: grabbing; } .lg-outer .lg-thumb-outer.lg-dragging .lg-thumb { transition-duration: 0s !important; } .lg-outer.lg-thumb-open .lg-thumb-outer { transform: translate3d(0, 0, 0); } .lg-outer .lg-thumb { padding: 10px 0; height: 100%; margin-bottom: -5px; } .lg-outer .lg-thumb-item { cursor: pointer; float: left; overflow: hidden; height: 100%; border: 2px solid rgba(255, 255, 255, 0.4); border-radius: 0; margin-bottom: 5px; } @media (min-width: 1025px) { .lg-outer .lg-thumb-item { transition: border-color .25s ease; } } .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { border-color: #0045b6; } .lg-outer .lg-thumb-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; } .lg-outer.lg-has-thumb .lg-item { padding-bottom: 120px; } .lg-outer.lg-can-toggle .lg-item { padding-bottom: 0; } .lg-outer.lg-pull-caption-up .lg-sub-html { transition: bottom .25s ease; } .lg-outer.lg-pull-caption-up.lg-thumb-open .lg-sub-html { bottom: 100px; } .lg-outer .lg-toogle-thumb { background-color: #0d0a0a; border-radius: 2px 2px 0 0; color: #999; cursor: pointer; font-size: 24px; height: 39px; line-height: 27px; padding: 5px 0; position: absolute; right: 20px; text-align: center; top: -39px; width: 50px; } .lg-outer .lg-toogle-thumb:hover, .lg-outer.lg-dropdown-active #lg-share { color: #fff; } .lg-outer .lg-toogle-thumb:after { content: "\e1ff"; } .lg-outer .lg-video-cont { display: inline-block; vertical-align: middle; max-width: 1140px; max-height: 100%; width: 100%; padding: 0 5px; } .lg-outer .lg-video { width: 100%; height: 0; padding-bottom: 56.25%; overflow: hidden; position: relative; } .lg-outer .lg-video .lg-object { display: inline-block; position: absolute; top: 0; left: 0; width: 100% !important; height: 100% !important; } .lg-outer .lg-video .lg-video-play { width: 84px; height: 59px; position: absolute; left: 50%; top: 50%; margin-left: -42px; margin-top: -30px; z-index: 1180; cursor: pointer; } .lg-outer .lg-has-iframe .lg-video { -webkit-overflow-scrolling: touch; overflow: auto; } .lg-outer .lg-has-vimeo .lg-video-play { background: url(../images/vimeo-play.png) no-repeat; } .lg-outer .lg-has-vimeo:hover .lg-video-play { background: url(../images/vimeo-play.png) 0 -58px no-repeat; } .lg-outer .lg-has-html5 .lg-video-play { background: url(../images/video-play.png) no-repeat; height: 64px; margin-left: -32px; margin-top: -32px; width: 64px; opacity: .8; } .lg-outer .lg-has-html5:hover .lg-video-play { opacity: 1; } .lg-outer .lg-has-youtube .lg-video-play { background: url(../images/youtube-play.png) no-repeat; } .lg-outer .lg-has-youtube:hover .lg-video-play { background: url(../images/youtube-play.png) 0 -60px no-repeat; } .lg-outer .lg-video-object { width: 100% !important; height: 100% !important; position: absolute; top: 0; left: 0; } .lg-outer .lg-has-video .lg-video-object { visibility: hidden; } .lg-outer .lg-has-video.lg-video-playing .lg-object, .lg-outer .lg-has-video.lg-video-playing .lg-video-play { display: none; } .lg-outer .lg-has-video.lg-video-playing .lg-video-object { visibility: visible; } .lg-progress-bar { background-color: #333; height: 5px; left: 0; position: absolute; top: 0; width: 100%; z-index: 1183; opacity: 0; transition: opacity 80ms ease 0s; } .lg-progress-bar .lg-progress { background-color: #0045b6; height: 5px; width: 0; } .lg-progress-bar.lg-start .lg-progress { width: 100%; } .lg-show-autoplay .lg-progress-bar { opacity: 1; } .lg-autoplay-button:after { content: "\e01d"; } .lg-show-autoplay .lg-autoplay-button:after { content: "\e01a"; } .lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-image, .lg-outer.lg-css3.lg-zoom-dragging .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition-duration: 0s; } .lg-outer.lg-use-transition-for-zoom .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer.lg-use-left-for-zoom .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transition: left 0.3s cubic-bezier(0, 0, 0.25, 1) 0s, top 0.3s cubic-bezier(0, 0, 0.25, 1) 0s; } .lg-outer .lg-item.lg-complete.lg-zoomable .lg-img-wrap { transform: translate3d(0, 0, 0); -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } .lg-outer .lg-item.lg-complete.lg-zoomable .lg-image { transform: scale3d(1, 1, 1); transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.15s !important; -moz-transform-origin: 0 0; -ms-transform-origin: 0 0; transform-origin: 0 0; -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } #lg-zoom-in:after { content: "\e311"; } #lg-actual-size { font-size: 20px; } #lg-actual-size:after { content: "\e033"; } #lg-zoom-out { opacity: .5; pointer-events: none; } #lg-zoom-out:after { content: "\e312"; } .lg-zoomed #lg-zoom-out { opacity: 1; pointer-events: auto; } .lg-outer .lg-pager-outer { bottom: 60px; left: 0; position: absolute; right: 0; text-align: center; z-index: 1180; height: 10px; } .lg-outer .lg-pager-outer.lg-pager-hover .lg-pager-cont { overflow: visible; } .lg-outer .lg-pager-cont { cursor: pointer; display: inline-block; overflow: hidden; position: relative; vertical-align: top; margin: 0 5px; } .lg-outer .lg-pager-cont:hover .lg-pager-thumb-cont { opacity: 1; transform: translate3d(0, 0, 0); } .lg-outer .lg-pager-cont.lg-pager-active .lg-pager { box-shadow: 0 0 0 2px #fff inset; } .lg-outer .lg-pager-thumb-cont { background-color: #fff; color: #fff; bottom: 100%; height: 83px; left: 0; margin-bottom: 20px; margin-left: -60px; opacity: 0; padding: 5px; position: absolute; width: 120px; border-radius: 3px; transition: opacity .15s ease 0s, transform .15s ease 0s; transform: translate3d(0, 5px, 0); } .lg-outer .lg-pager-thumb-cont img { width: 100%; height: 100%; } .lg-outer .lg-pager { background-color: rgba(255, 255, 255, 0.5); border-radius: 50%; box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.7) inset; display: block; height: 12px; transition: box-shadow .3s ease 0s; width: 12px; } .lg-outer .lg-pager:focus, .lg-outer .lg-pager:hover { box-shadow: 0 0 0 8px #fff inset; } .lg-outer .lg-caret { border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 10px dashed; bottom: -10px; display: inline-block; height: 0; left: 50%; margin-left: -5px; position: absolute; vertical-align: middle; width: 0; } .lg-fullscreen:after { content: "\e20c"; } .lg-fullscreen-on .lg-fullscreen:after { content: "\e20d"; } .lg-outer #lg-dropdown-overlay { background-color: rgba(0, 0, 0, 0.25); bottom: 0; cursor: default; left: 0; position: fixed; right: 0; top: 0; z-index: 1181; opacity: 0; visibility: hidden; transition: visibility 0s linear .18s, opacity .18s linear 0s; } .lg-outer.lg-dropdown-active #lg-dropdown-overlay, .lg-outer.lg-dropdown-active .lg-dropdown { transition-delay: 0s; -moz-transform: translate3d(0, 0, 0); -ms-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); opacity: 1; visibility: visible; } .lg-outer .lg-dropdown { background-color: #fff; border-radius: 2px; font-size: 14px; list-style-type: none; margin: 0; padding: 10px 0; position: absolute; right: 0; text-align: left; top: 50px; opacity: 0; visibility: hidden; -moz-transform: translate3d(0, 5px, 0); -ms-transform: translate3d(0, 5px, 0); transform: translate3d(0, 5px, 0); transition: transform .18s linear 0s, visibility 0s linear .5s, opacity .18s linear 0s; } .lg-outer .lg-dropdown:after { content: ""; display: block; height: 0; width: 0; position: absolute; border: 8px solid transparent; border-bottom-color: #fff; right: 16px; top: -16px; } .lg-outer .lg-dropdown > li:last-child { margin-bottom: 0; } .lg-outer .lg-dropdown > li:hover .lg-icon, .lg-outer .lg-dropdown > li:hover a { color: #333; } .lg-outer .lg-dropdown a { color: #333; display: block; white-space: pre; padding: 4px 12px; font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 12px; } .lg-outer .lg-dropdown a:hover { background-color: rgba(0, 0, 0, 0.07); } .lg-outer .lg-dropdown .lg-dropdown-text { display: inline-block; line-height: 1; margin-top: -3px; vertical-align: middle; } .lg-outer .lg-dropdown .lg-icon { color: #333; display: inline-block; float: none; font-size: 20px; height: auto; line-height: 1; margin-right: 8px; padding: 0; vertical-align: middle; width: auto; } .lg-outer, .lg-outer .lg, .lg-outer .lg-inner { width: 100%; height: 100%; } .lg-outer #lg-share { position: relative; } .lg-outer #lg-share:after { content: "\e80d"; } .lg-outer #lg-share-facebook .lg-icon { color: #3b5998; } .lg-outer #lg-share-facebook .lg-icon:after { content: "\e901"; } .lg-outer #lg-share-twitter .lg-icon { color: #00aced; } .lg-outer #lg-share-twitter .lg-icon:after { content: "\e904"; } .lg-outer #lg-share-googleplus .lg-icon { color: #dd4b39; } .lg-outer #lg-share-googleplus .lg-icon:after { content: "\e902"; } .lg-outer #lg-share-pinterest .lg-icon { color: #cb2027; } .lg-outer #lg-share-pinterest .lg-icon:after { content: "\e903"; } .lg-group:after { content: ""; display: table; clear: both; } .lg-outer { position: fixed; top: 0; left: 0; z-index: 1150; text-align: left; opacity: 0; transition: opacity .15s ease 0s; } .lg-outer * { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } .lg-outer.lg-visible { opacity: 1; } .lg-outer.lg-css3 .lg-item.lg-current, .lg-outer.lg-css3 .lg-item.lg-next-slide, .lg-outer.lg-css3 .lg-item.lg-prev-slide { transition-duration: inherit !important; transition-timing-function: inherit !important; } .lg-outer.lg-css3.lg-dragging .lg-item.lg-current, .lg-outer.lg-css3.lg-dragging .lg-item.lg-next-slide, .lg-outer.lg-css3.lg-dragging .lg-item.lg-prev-slide { transition-duration: 0s !important; opacity: 1; } .lg-outer.lg-grab img.lg-object { cursor: grab; } .lg-outer.lg-grabbing img.lg-object { cursor: grabbing; } .lg-outer .lg { position: relative; overflow: hidden; margin-left: auto; margin-right: auto; max-width: 100%; max-height: 100%; } .lg-outer .lg-inner { position: absolute; left: 0; top: 0; white-space: nowrap; } .lg-outer .lg-item { background: url(../images/loading.gif) center center no-repeat; display: none !important; } .lg-outer.lg-css .lg-current, .lg-outer.lg-css3 .lg-current, .lg-outer.lg-css3 .lg-next-slide, .lg-outer.lg-css3 .lg-prev-slide { display: inline-block !important; } .lg-outer .lg-img-wrap, .lg-outer .lg-item { display: inline-block; text-align: center; position: absolute; width: 100%; height: 100%; } .lg-outer .lg-img-wrap:before, .lg-outer .lg-item:before { content: ""; display: inline-block; height: 50%; width: 1px; margin-right: -1px; } .lg-outer .lg-img-wrap { position: absolute; padding: 0 5px; left: 0; right: 0; top: 0; bottom: 0; } .lg-outer .lg-item.lg-complete { background-image: none; } .lg-outer .lg-item.lg-current { z-index: 1160; } .lg-outer .lg-image { display: inline-block; vertical-align: middle; max-width: 100%; max-height: 100%; width: auto !important; height: auto !important; } .lg-outer.lg-show-after-load .lg-item .lg-object, .lg-outer.lg-show-after-load .lg-item .lg-video-play { opacity: 0; transition: opacity .15s ease 0s; } .lg-outer.lg-show-after-load .lg-item.lg-complete .lg-object, .lg-outer.lg-show-after-load .lg-item.lg-complete .lg-video-play { opacity: 1; } .lg-outer .lg-empty-html, .lg-outer.lg-hide-download #lg-download { display: none; } .lg-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1140; background-color: #000; opacity: 0; transition: opacity .15s ease 0s; } .lg-backdrop.in { opacity: .8; } .lg-css3.lg-no-trans .lg-current, .lg-css3.lg-no-trans .lg-next-slide, .lg-css3.lg-no-trans .lg-prev-slide { transition: none 0s ease 0s !important; } .lg-css3.lg-use-css3 .lg-item, .lg-css3.lg-use-left .lg-item { -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; backface-visibility: hidden; } .lg-css3.lg-fade .lg-item { opacity: 0; } .lg-css3.lg-fade .lg-item.lg-current { opacity: 1; } .lg-css3.lg-fade .lg-item.lg-current, .lg-css3.lg-fade .lg-item.lg-next-slide, .lg-css3.lg-fade .lg-item.lg-prev-slide { transition: opacity .1s ease 0s; } .lg-css3.lg-slide.lg-use-css3 .lg-item { opacity: 0; } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide { transform: translate3d(-100%, 0, 0); } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide { transform: translate3d(100%, 0, 0); } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current { transform: translate3d(0, 0, 0); opacity: 1; } .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-current, .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-next-slide, .lg-css3.lg-slide.lg-use-css3 .lg-item.lg-prev-slide { transition: transform 1s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.1s ease 0s; } .lg-css3.lg-slide.lg-use-left .lg-item { opacity: 0; position: absolute; left: 0; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-prev-slide { left: -100%; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-next-slide { left: 100%; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-current { left: 0; opacity: 1; } .lg-css3.lg-slide.lg-use-left .lg-item.lg-current, .lg-css3.lg-slide.lg-use-left .lg-item.lg-next-slide, .lg-css3.lg-slide.lg-use-left .lg-item.lg-prev-slide { transition: left 1s cubic-bezier(0, 0, 0.25, 1) 0s, opacity 0.1s ease 0s; } /* +* +* Material Parallax +*/ .parallax-content { position: relative; z-index: 1; } .parallax-container { position: relative; overflow: hidden; background-position: center center; background-size: cover; } .material-parallax { position: absolute; top: 0; left: -1px; right: -1px; bottom: 0; z-index: 0; background-position: center center; } .ipad .parallax-container, .iphone .parallax-container { background-attachment: scroll !important; } .material-parallax img { display: none; position: absolute; left: 50%; bottom: 0; min-width: 101%; min-height: 101%; max-width: none; transform: translate3d(-50%, 0, 0); } /* +* +* Slick Carousel +*/ /*rtl:begin:ignore*/ .slick-slider { position: relative; display: block; box-sizing: border-box; -webkit-touch-callout: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; -ms-touch-action: pan-y; touch-action: pan-y; -webkit-tap-highlight-color: transparent; } .slick-list { position: relative; overflow: hidden; display: block; margin: 0; padding: 0; } .slick-list:focus { outline: none; } .slick-list.dragging { cursor: pointer; cursor: hand; } .slick-slider .slick-track, .slick-slider .slick-list { transform: translate3d(0, 0, 0); } .slick-track { position: relative; left: 0; top: 0; display: block; } .slick-track::before, .slick-track::after { content: ""; display: table; } .slick-track::after { clear: both; } .slick-loading .slick-track { visibility: hidden; } .slick-slide { float: left; height: 100%; min-height: 1px; display: none; } [dir="rtl"] .slick-slide { float: right; } .slick-slide img { display: block; } .slick-slide.slick-loading img { display: none; } .slick-slide.dragging img { pointer-events: none; } .slick-initialized .slick-slide { display: block; } .slick-loading .slick-slide { visibility: hidden; } .slick-vertical .slick-slide { display: block; height: auto; border: 1px solid transparent; } .slick-arrow.slick-hidden { display: none; } .slick-loading .slick-list { background: #fff url("./../images/ajax-loader.gif") center center no-repeat; } /* Icons */ /* Arrows */ .slick-prev, .slick-next { position: absolute; display: block; height: 20px; width: 20px; line-height: 0; font-size: 0; cursor: pointer; color: transparent; background-color: transparent; top: 50%; transform: translate(0, -50%); padding: 0; border: none; outline: none; z-index: 9; } .slick-prev.slick-disabled:before, .slick-next.slick-disabled:before { opacity: 0.25; } .slick-prev::before, .slick-next::before { font-family: "fl-bigmug-line"; font-size: 24px; line-height: 1; color: #0045b6; opacity: 0.75; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; transition: .22s; } .slick-prev:hover::before, .slick-next:hover::before { color: #343db0; } .slick-prev { left: 0; } [dir="rtl"] .slick-prev { left: auto; right: 0; } .slick-prev:before { content: ""; } [dir="rtl"] .slick-prev:before { content: ""; } .slick-next { right: 0; } [dir="rtl"] .slick-next { left: 0; right: auto; } .slick-next:before { content: ""; } [dir="rtl"] .slick-next:before { content: ""; } /* Dots */ .slick-dots { position: absolute; bottom: -45px; list-style: none; display: block; text-align: center; padding: 0; width: 100%; } .slick-dots li { position: relative; display: inline-block; height: 20px; width: 20px; margin: 0 5px; padding: 0; cursor: pointer; } .slick-dots li button { border: 0; background: transparent; display: block; height: 20px; width: 20px; outline: none; line-height: 0; font-size: 0; color: transparent; padding: 5px; cursor: pointer; } .slick-dots li button:hover, .slick-dots li button:focus { outline: none; } .slick-dots li button:hover:before, .slick-dots li button:focus:before { opacity: 1; } .slick-dots li button:before { position: absolute; top: 0; left: 0; content: ""; font-family: "fl-bigmug-line"; font-size: 6px; line-height: 20px; text-align: center; color: #0045b6; opacity: 0.25; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .slick-dots li.slick-active button:before { color: #0045b6; opacity: 0.75; } /*rtl:end:ignore*/ .slick-widget-testimonials .carousel-child { max-width: 570px; padding: 0 50px; margin-left: auto; margin-right: auto; } .slick-widget-testimonials .carousel-child .item { border-radius: 50%; cursor: pointer; transform: scale3d(0.66, 0.66, 0.66); transition: .22s; text-align: center; } .slick-widget-testimonials .carousel-child .slick-center { transform: scale3d(1, 1, 1); } .slick-widget-testimonials .carousel-child img { border-radius: inherit; max-width: 100%; margin: 0 auto; } @media (max-width: 575.98px) { .slick-widget-testimonials .carousel-child .slick-prev { left: 30px; } .slick-widget-testimonials .carousel-child .slick-next { right: 30px; } } .slick-widget-testimonials .carousel-parent { max-width: 780px; margin-left: auto; margin-right: auto; } .slick-widget-testimonials .carousel-parent .item > * { transform-origin: 50% 0; opacity: 0; transition: .44s; } .slick-widget-testimonials .carousel-parent .slick-active > * { opacity: 1; } .slick-widget-testimonials > * + * { margin-top: 20px; } * + .slick-widget-testimonials { margin-top: 30px; } @media (min-width: 768px) { * + .slick-widget-testimonials { margin-top: 55px; } } @media (min-width: 1200px) { * + .slick-widget-testimonials { margin-top: 75px; } } /* +* +* Select 2 v4 +*/ .select2 { width: 100% !important; } .select2-container { box-sizing: border-box; display: inline-block; margin: 0; position: relative; vertical-align: middle; } .select2-container .select2-container--default .select2-selection--single { width: 100% !important; } .select2-container .select2-selection--single { display: block; cursor: pointer; user-select: none; } .select2-container .select2-selection--single .select2-selection__rendered { display: block; padding-left: 8px; padding-right: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .select2-container .select2-selection--single .select2-selection__clear { position: relative; } .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { padding-right: 8px; padding-left: 20px; } .select2-container .select2-selection--multiple { display: block; cursor: pointer; min-height: 32px; user-select: none; -webkit-user-select: none; } .select2-container .select2-selection--multiple .select2-selection__rendered { display: inline-block; overflow: hidden; padding-left: 8px; text-overflow: ellipsis; white-space: nowrap; } .select2-container .select2-search--inline { float: left; } .select2-container .select2-search--inline .select2-search__field { border: none; font-size: 100%; margin-top: 5px; padding: 0; } .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { -webkit-appearance: none; } .select2-dropdown { position: absolute; left: -100000px; z-index: 1051; display: block; overflow: hidden; width: 100%; border: 1px; border-radius: 6px; background-color: #fff; box-shadow: 0 0 5px 0 rgba(73, 80, 87, 0.2); } .select2-results { display: block; } .select2-results__options { list-style: none; margin: 0; padding: 0; } .select2-results__option { padding: 6px; user-select: none; -webkit-user-select: none; } .select2-results__option[aria-selected] { cursor: pointer; } .select2-container--open .select2-dropdown { left: 0; } .select2-container--open .select2-dropdown--above { border-bottom: none; border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .select2-container--open .select2-dropdown--below { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; } .select2-search--dropdown { display: block; padding: 4px; } .select2-search--dropdown .select2-search__field { padding: 4px; width: 100%; box-sizing: border-box; } .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { -webkit-appearance: none; } .select2-search--dropdown.select2-search--hide { display: none; } .select2-close-mask { border: 0; margin: 0; padding: 0; display: block; position: fixed; left: 0; top: 0; min-height: 100%; min-width: 100%; height: auto; width: auto; opacity: 0; z-index: 99; background-color: #fff; filter: alpha(opacity=0); } .select2-hidden-accessible { border: 0 !important; clip: rect(0 0 0 0) !important; height: 1px !important; margin: -1px !important; overflow: hidden !important; padding: 0 !important; position: absolute !important; width: 1px !important; } .select2-container--default .select2-selection--single { background-color: #fff; border: 1px; border-radius: 6px; border-style: solid; border-color: #aeb1be; } .select2-container--default .select2-selection--single .select2-selection__rendered { display: block; padding: 11px 29px 11px 19px; color: #495057; font-size: 16px; line-height: 1.5; font-weight: 400; min-height: 48px; } .select2-container--default .select2-selection--single .select2-selection__clear { cursor: pointer; float: right; font-weight: 700; } .select2-container--default .select2-selection--single .select2-selection__placeholder { color: #74757f; } .select2-container--default .select2-selection--single .select2-selection__arrow { position: absolute; top: 50%; transform: translate3d(0, -50%, 0); right: 5px; width: 20px; } .select2-container--default .select2-selection--single .select2-selection__arrow b { pointer-events: none; color: #495057; } .select2-container--default .select2-selection--single .select2-selection__arrow b::before { position: relative; display: inline-block; content: ""; font-family: "Material Design Icons"; font-size: 18px; transform: rotate(0deg); transition: .1s; } .select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { float: left; } .select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { left: 1px; right: auto; } .select2-container--default.select2-container--disabled .select2-selection--single { background-color: #f5f6fa; cursor: default; } .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { display: none; } .select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b::before { transform: rotate(180deg); } .select2-container--default .select2-selection--multiple { background-color: #fff; border: 1px solid #aaa; border-radius: 6px; cursor: text; } .select2-container--default .select2-selection--multiple .select2-selection__rendered { box-sizing: border-box; list-style: none; margin: 0; padding: 0 5px; width: 100%; } .select2-container--default .select2-selection--multiple .select2-selection__rendered li { list-style: none; } .select2-container--default .select2-selection--multiple .select2-selection__placeholder { color: #999; margin-top: 5px; float: left; } .select2-container--default .select2-selection--multiple .select2-selection__clear { cursor: pointer; float: right; font-weight: bold; margin-top: 5px; margin-right: 10px; } .select2-container--default .select2-selection--multiple .select2-selection__choice { background-color: #e4e4e4; border: 1px solid #aaa; border-radius: 4px; cursor: default; float: left; margin-right: 5px; margin-top: 5px; padding: 0 5px; } .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { color: #999; cursor: pointer; display: inline-block; font-weight: bold; margin-right: 2px; } .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { color: #333; } .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { float: right; } .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { margin-left: 5px; margin-right: auto; } .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { margin-left: 2px; margin-right: auto; } .select2-container--default.select2-container--focus .select2-selection--multiple { border: solid black 1px; outline: 0; } .select2-container--default.select2-container--disabled .select2-selection--multiple { background-color: #eee; cursor: default; } .select2-container--default.select2-container--disabled .select2-selection__choice__remove { display: none; } .select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { border-top-left-radius: 0; border-top-right-radius: 0; } .select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .select2-container--default .select2-search--dropdown { border-top: 0; } .select2-container--default .select2-search--dropdown .select2-search__field { border: 1px solid #c5c6cd; } .select2-container--default .select2-search--inline .select2-search__field { background: transparent; border: none; outline: 0; box-shadow: none; -webkit-appearance: textfield; } .select2-container--default .select2-results > .select2-results__options { max-height: 200px; overflow-y: auto; } .select2-container--default .select2-results__option[role=group] { padding: 0; } .select2-container--default .select2-results__option[aria-disabled=true] { color: #9b9b9b; } .select2-container--default .select2-results__option[aria-selected=true] { color: #fff; background-color: #0058e9; } .select2-container--default .select2-results__option .select2-results__option { padding-left: 1em; } .select2-container--default .select2-results__option .select2-results__option .select2-results__group { padding-left: 0; } .select2-container--default .select2-results__option .select2-results__option .select2-results__option { margin-left: -1em; padding-left: 2em; } .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { margin-left: -2em; padding-left: 3em; } .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { margin-left: -3em; padding-left: 4em; } .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { margin-left: -4em; padding-left: 5em; } .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { margin-left: -5em; padding-left: 6em; } .select2-container--default .select2-results__option--highlighted[aria-selected] { color: #fff; background-color: #0045b6; } .select2-container--default .select2-results__group { cursor: default; display: block; padding: 6px; } /* +* +* D3 Charts +*/ .c3 svg { padding-left: 4px; width: 100%; -webkit-tap-highlight-color: transparent; } .c3 path, .c3 line { fill: none; stroke: #9f9f9f; } .c3 text { font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 16px; fill: #9b9b9b; user-select: none; } .c3-legend-item-tile, .c3-xgrid-focus, .c3-ygrid, .c3-event-rect, .c3-bars path { shape-rendering: crispEdges; } .c3-chart-arc path { stroke: #fff; } .c3-chart-arc text { fill: #fff; font-size: 14px; } /*-- Axis --*/ /*-- Grid --*/ .c3-grid line { stroke: #eaebee; } .c3-grid text { fill: #aaa; } .c3-xgrid, .c3-ygrid { stroke-dasharray: 0 0; } /*-- Text on Chart --*/ .c3-text.c3-empty { fill: #808080; font-size: 2em; } /*-- Line --*/ .c3-line { stroke-width: 3px; } /*-- Point --*/ .c3-circle._expanded_ { stroke-width: 2px; stroke: white; } .c3-selected-circle { fill: white; stroke-width: 2px; } /*-- Bar --*/ .c3-bar { stroke-width: 0; } .c3-bar._expanded_ { fill-opacity: 0.75; } /*-- Focus --*/ .c3-target.c3-focused { opacity: 1; } .c3-target.c3-focused path.c3-line, .c3-target.c3-focused path.c3-step { stroke-width: 2px; } .c3-target.c3-defocused { opacity: 0.3 !important; } /*-- Region --*/ .c3-region { fill: steelblue; fill-opacity: .1; } /*-- Brush --*/ .c3-brush .extent { fill-opacity: .1; fill: #000; } /*-- Select - Drag --*/ /*-- Legend --*/ .c3-legend-item { font-size: 12px; } .c3-legend-item-hidden { opacity: 0.15; } .c3-legend-background { opacity: 0.75; fill: white; stroke: lightgray; stroke-width: 1; } /*-- Title --*/ .c3-title { font: 14px sans-serif; } /*-- Tooltip --*/ .c3-tooltip-container { z-index: 10; } .c3-tooltip { border-collapse: collapse; border-spacing: 0; background-color: #fff; empty-cells: show; opacity: 0.9; border: 1px solid #ccc; } .c3-tooltip tr { border: 1px solid #ccc; } .c3-tooltip th { background-color: #aaa; font-size: 14px; line-height: 1.2; padding: 2px 5px; text-align: left; color: #fff; } .c3-tooltip td { font-size: 13px; padding: 3px 6px; background-color: #fff; color: #000; border-left: 1px dotted #999; } .c3-tooltip td > span { display: inline-block; width: 10px; height: 10px; margin-right: 6px; } .c3-tooltip td.value { text-align: right; } /*-- Area --*/ .c3-area { stroke-width: 0; opacity: 0.2; } /*-- Arc --*/ .c3-chart-arcs-title { dominant-baseline: middle; font-size: 1.3em; } .c3-chart-arcs .c3-chart-arcs-background { fill: #e0e0e0; stroke: none; } .c3-chart-arcs .c3-chart-arcs-gauge-unit { fill: #000; font-size: 16px; } .c3-chart-arcs .c3-chart-arcs-gauge-max { fill: #777; } .c3-chart-arcs .c3-chart-arcs-gauge-min { fill: #777; } .c3-chart-arc .c3-gauge-value { fill: #000; } .c3 .domain { stroke: #c8c8c8; } .d3-chart .tick line { display: none; } .d3-chart .c3-tooltip { opacity: 1; color: #fff; background: #0045b6; border: 1px solid #aeb1be; } .d3-chart .c3-tooltip tr th { padding: 8px 10px; color: #fff; background: #495057; } .d3-chart .c3-tooltip tr td { padding: 5px 10px; color: #838386; background: #fff; } .d3-chart .c3-tooltip .value { border-left: 1px solid #f2f3f9; } #line-chart .c3-tooltip-container { border-radius: 6px; overflow: hidden; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.05); } #line-chart .c3-tooltip-container tr { border: 0; } #line-chart .c3-tooltip-container .name { border-left: 0; } #line-chart .c3-tooltip-container table { border: 0 solid transparent; border-radius: 6px; } #line-chart .c3-axis-y-label { transform: translateY(-10px); } #line-chart .c3-axis-y .tick text { transform: translateX(1px); } #line-chart .c3-axis-x { text-align: left; } #line-chart .c3-axis-x text { transform: translateX(-1px); text-anchor: start !important; } #line-chart .c3-legend-item { display: none !important; transform: translateY(15px); } #line-chart .c3-legend-item text { font-size: 16px; color: #29293a; } .d3-chart-wrap { width: 100%; max-width: 100%; overflow-y: hidden; overflow-x: auto; padding-bottom: 30px; } .d3-chart-wrap > .d3-chart { min-width: 520px; } .d3-chart-legend { transform: translateY(-5px); margin-bottom: -5px; margin-left: -30px; text-align: center; } .d3-chart-legend > * { display: inline-block; margin-top: 5px; margin-left: 30px; } .d3-chart-legend span { position: relative; display: inline-block; font-size: 16px; color: #74757f; cursor: pointer; } .d3-chart-legend span::before { content: ''; display: inline-block; height: 3px; width: 30px; margin-right: 12px; vertical-align: middle; } .d3-chart-legend span:nth-child(1)::before { background: #0045b6; } .d3-chart-legend span:nth-child(2)::before { background: #aeb1be; } * + .d3-chart-legend { margin-top: 20px; } @media (min-width: 1600px) { .d3-chart-legend span { font-size: 18px; } } .rd-range-input { border: none; background: none; width: auto; } .price-devider { padding: 0 5px 0 4px; display: flex; flex-direction: column; justify-content: flex-start; vertical-align: baseline; height: 100%; width: 20px; text-align: left; } .rd-range-form-wrap { display: flex; font-family: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; align-items: baseline; color: #151515; } .rd-range-form-wrap span { font-weight: 400; } .rd-range-input-value-1 { text-align: left; } .rd-range-input-value-2 { text-align: left; } .ios .rd-range-input { width: 100% !important; max-width: 45px; } .ios .price-devider { padding: 0 15px 0 0; width: auto; text-align: left; } .rd-range-wrap { width: 100%; display: flex; justify-content: center; align-items: center; vertical-align: middle; } .rd-range { height: 18px; display: -ms-flexbox; display: -webkit-flex; display: flex; -webkit-align-items: center; -ms-flex-align: center; align-items: center; } .rd-range__wrap { position: relative; width: 100%; background-color: #bdbdbd; transition: background 100ms cubic-bezier(0.23, 1, 0.32, 1); height: 5px; } .rd-range:hover .rd-range__wrap { background-color: #9e9e9e; } .rd-range:hover .rd-range__pointer:before { transform: scale(1); -webkit-animation-name: pulse; animation-name: pulse; } .rd-range__pointer { position: absolute; cursor: pointer; top: 1px; left: 6%; z-index: 1; width: 19px; height: 19px; border-radius: 50%; transform: translate(-50%, -50%); transform-origin: 0% 15%; transition: transform 0.2s cubic-bezier(0.35, 0, 0.25, 1); overflow: visible; background-color: #0045b6; background-clip: padding-box; } @media (min-width: 768px) { .rd-range__pointer { width: 12px; height: 12px; } .rd-range__pointer:before { content: ''; height: 26px; width: 26px; pointer-events: none; background: rgba(0, 69, 182, 0.16); position: absolute; top: -7px; left: -7px; border-radius: 50%; transform: scale(0); transition: transform 100ms cubic-bezier(0.445, 0.05, 0.55, 0.95) 0ms, opacity 60ms cubic-bezier(0.445, 0.05, 0.55, 0.95) 0ms; pointer-events: none; -webkit-animation-duration: 1s; animation-duration: 1s; -webkit-animation-fill-mode: both; animation-fill-mode: both; -webkit-animation-iteration-count: infinite; animation-iteration-count: infinite; } .rd-range__pointer.active:before { animation: none; transform: scale(0); opacity: 0; } } .rd-range__line { background-color: #0045b6; position: absolute; top: 0; left: 0; height: 5px; } .rd-range.hasTooltip .rd-range__pointer-tooltip { font-size: 12px; color: #ffffff; opacity: 0; transition: color 0.3s cubic-bezier(0.35, 0, 0.25, 1); display: none; } .rd-range.hasTooltip .rd-range__pointer { display: -webkit-flex; display: -ms-flexbox; display: flex; -webkit-align-items: center; -ms-flex-align: center; align-items: center; -webkit-justify-content: center; -ms-flex-pack: center; justify-content: center; position: absolute; width: 17px; height: 17px; border-radius: 28px; } .rd-range.hasTooltip .rd-range__pointer:before { content: none; } .rd-range.hasTooltip .rd-range__pointer:after { position: absolute; content: ''; left: 0; border-radius: 16px; top: 19px; border-left: 14px solid transparent; border-right: 14px solid transparent; border-top: 16px solid #0045b6; opacity: 0; -webkit-transform: translate3d(0, -8px, 0); transform: translate3d(0, -8px, 0); transition: all 0.2s cubic-bezier(0.35, 0, 0.25, 1); display: none; } .rd-range.hasTooltip .rd-range__pointer.active { opacity: 1; } .rd-range.hasTooltip .rd-range__pointer.active:after { opacity: 1; } .rd-range.hasTooltip .rd-range__pointer.active .rd-range__pointer-tooltip { opacity: 1; } \ No newline at end of file diff --git a/site/client_site/static/css/theme-overrides.css b/site/client_site/static/css/theme-overrides.css new file mode 100644 index 0000000..2792924 --- /dev/null +++ b/site/client_site/static/css/theme-overrides.css @@ -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; + } +} + diff --git a/site/client_site/static/fonts/FontAwesome.otf b/site/client_site/static/fonts/FontAwesome.otf new file mode 100755 index 0000000..401ec0f Binary files /dev/null and b/site/client_site/static/fonts/FontAwesome.otf differ diff --git a/site/client_site/static/fonts/Linearicons.eot b/site/client_site/static/fonts/Linearicons.eot new file mode 100755 index 0000000..513cef7 Binary files /dev/null and b/site/client_site/static/fonts/Linearicons.eot differ diff --git a/site/client_site/static/fonts/Linearicons.svg b/site/client_site/static/fonts/Linearicons.svg new file mode 100755 index 0000000..942c1d6 --- /dev/null +++ b/site/client_site/static/fonts/Linearicons.svg @@ -0,0 +1,1028 @@ + + + + + + +{ + "fontFamily": "Linearicons", + "majorVersion": 1, + "minorVersion": 0, + "version": "Version 1.0", + "fontId": "Linearicons", + "psName": "Linearicons", + "subFamily": "Regular", + "fullName": "Linearicons", + "description": "Font generated by IcoMoon." +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/site/client_site/static/fonts/Linearicons.ttf b/site/client_site/static/fonts/Linearicons.ttf new file mode 100755 index 0000000..73ec944 Binary files /dev/null and b/site/client_site/static/fonts/Linearicons.ttf differ diff --git a/site/client_site/static/fonts/Linearicons.woff b/site/client_site/static/fonts/Linearicons.woff new file mode 100755 index 0000000..2425dc0 Binary files /dev/null and b/site/client_site/static/fonts/Linearicons.woff differ diff --git a/site/client_site/static/fonts/fl-bigmug-line.eot b/site/client_site/static/fonts/fl-bigmug-line.eot new file mode 100755 index 0000000..7781397 Binary files /dev/null and b/site/client_site/static/fonts/fl-bigmug-line.eot differ diff --git a/site/client_site/static/fonts/fl-bigmug-line.svg b/site/client_site/static/fonts/fl-bigmug-line.svg new file mode 100755 index 0000000..9a9e941 --- /dev/null +++ b/site/client_site/static/fonts/fl-bigmug-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/site/client_site/static/fonts/fl-bigmug-line.ttf b/site/client_site/static/fonts/fl-bigmug-line.ttf new file mode 100755 index 0000000..26678a5 Binary files /dev/null and b/site/client_site/static/fonts/fl-bigmug-line.ttf differ diff --git a/site/client_site/static/fonts/fl-bigmug-line.woff b/site/client_site/static/fonts/fl-bigmug-line.woff new file mode 100755 index 0000000..3b5f0a4 Binary files /dev/null and b/site/client_site/static/fonts/fl-bigmug-line.woff differ diff --git a/site/client_site/static/fonts/fontawesome-webfont.eot b/site/client_site/static/fonts/fontawesome-webfont.eot new file mode 100755 index 0000000..e9f60ca Binary files /dev/null and b/site/client_site/static/fonts/fontawesome-webfont.eot differ diff --git a/site/client_site/static/fonts/fontawesome-webfont.svg b/site/client_site/static/fonts/fontawesome-webfont.svg new file mode 100755 index 0000000..855c845 --- /dev/null +++ b/site/client_site/static/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/client_site/static/fonts/fontawesome-webfont.ttf b/site/client_site/static/fonts/fontawesome-webfont.ttf new file mode 100755 index 0000000..35acda2 Binary files /dev/null and b/site/client_site/static/fonts/fontawesome-webfont.ttf differ diff --git a/site/client_site/static/fonts/fontawesome-webfont.woff b/site/client_site/static/fonts/fontawesome-webfont.woff new file mode 100755 index 0000000..400014a Binary files /dev/null and b/site/client_site/static/fonts/fontawesome-webfont.woff differ diff --git a/site/client_site/static/fonts/fontawesome-webfont.woff2 b/site/client_site/static/fonts/fontawesome-webfont.woff2 new file mode 100755 index 0000000..4d13fc6 Binary files /dev/null and b/site/client_site/static/fonts/fontawesome-webfont.woff2 differ diff --git a/site/client_site/static/fonts/lg.eot b/site/client_site/static/fonts/lg.eot new file mode 100755 index 0000000..51264c4 Binary files /dev/null and b/site/client_site/static/fonts/lg.eot differ diff --git a/site/client_site/static/fonts/lg.svg b/site/client_site/static/fonts/lg.svg new file mode 100755 index 0000000..22b1a1f --- /dev/null +++ b/site/client_site/static/fonts/lg.svg @@ -0,0 +1,47 @@ + + + + + + +{ + "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." +} + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/site/client_site/static/fonts/lg.ttf b/site/client_site/static/fonts/lg.ttf new file mode 100755 index 0000000..8ad8199 Binary files /dev/null and b/site/client_site/static/fonts/lg.ttf differ diff --git a/site/client_site/static/fonts/lg.woff b/site/client_site/static/fonts/lg.woff new file mode 100755 index 0000000..d98ff60 Binary files /dev/null and b/site/client_site/static/fonts/lg.woff differ diff --git a/site/client_site/static/fonts/materialdesignicons-webfont.eot b/site/client_site/static/fonts/materialdesignicons-webfont.eot new file mode 100755 index 0000000..15fa077 Binary files /dev/null and b/site/client_site/static/fonts/materialdesignicons-webfont.eot differ diff --git a/site/client_site/static/fonts/materialdesignicons-webfont.svg b/site/client_site/static/fonts/materialdesignicons-webfont.svg new file mode 100755 index 0000000..2d9f0ba --- /dev/null +++ b/site/client_site/static/fonts/materialdesignicons-webfont.svg @@ -0,0 +1,4383 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/client_site/static/fonts/materialdesignicons-webfont.ttf b/site/client_site/static/fonts/materialdesignicons-webfont.ttf new file mode 100755 index 0000000..1fc3931 Binary files /dev/null and b/site/client_site/static/fonts/materialdesignicons-webfont.ttf differ diff --git a/site/client_site/static/fonts/materialdesignicons-webfont.woff b/site/client_site/static/fonts/materialdesignicons-webfont.woff new file mode 100755 index 0000000..554be39 Binary files /dev/null and b/site/client_site/static/fonts/materialdesignicons-webfont.woff differ diff --git a/site/client_site/static/fonts/materialdesignicons-webfont.woff2 b/site/client_site/static/fonts/materialdesignicons-webfont.woff2 new file mode 100755 index 0000000..b2246e9 Binary files /dev/null and b/site/client_site/static/fonts/materialdesignicons-webfont.woff2 differ diff --git a/site/client_site/static/images/ajax-loader.gif b/site/client_site/static/images/ajax-loader.gif new file mode 100755 index 0000000..c122391 Binary files /dev/null and b/site/client_site/static/images/ajax-loader.gif differ diff --git a/site/client_site/static/images/bg-image-4.jpg b/site/client_site/static/images/bg-image-4.jpg new file mode 100755 index 0000000..e3701cd Binary files /dev/null and b/site/client_site/static/images/bg-image-4.jpg differ diff --git a/site/client_site/static/images/bg-image-7.jpg b/site/client_site/static/images/bg-image-7.jpg new file mode 100755 index 0000000..c140827 Binary files /dev/null and b/site/client_site/static/images/bg-image-7.jpg differ diff --git a/site/client_site/static/images/bg-pattern-boxed.png b/site/client_site/static/images/bg-pattern-boxed.png new file mode 100755 index 0000000..219dda0 Binary files /dev/null and b/site/client_site/static/images/bg-pattern-boxed.png differ diff --git a/site/client_site/static/images/breadcrumbs-image-1.jpg b/site/client_site/static/images/breadcrumbs-image-1.jpg new file mode 100755 index 0000000..afefa36 Binary files /dev/null and b/site/client_site/static/images/breadcrumbs-image-1.jpg differ diff --git a/site/client_site/static/images/careers-1-570x388.jpg b/site/client_site/static/images/careers-1-570x388.jpg new file mode 100755 index 0000000..dddc32d Binary files /dev/null and b/site/client_site/static/images/careers-1-570x388.jpg differ diff --git a/site/client_site/static/images/grid-layout-1-370x256.jpg b/site/client_site/static/images/grid-layout-1-370x256.jpg new file mode 100755 index 0000000..0631bf0 Binary files /dev/null and b/site/client_site/static/images/grid-layout-1-370x256.jpg differ diff --git a/site/client_site/static/images/grid-layout-2-370x256.jpg b/site/client_site/static/images/grid-layout-2-370x256.jpg new file mode 100755 index 0000000..cac24f5 Binary files /dev/null and b/site/client_site/static/images/grid-layout-2-370x256.jpg differ diff --git a/site/client_site/static/images/grid-layout-3-370x256.jpg b/site/client_site/static/images/grid-layout-3-370x256.jpg new file mode 100755 index 0000000..6277861 Binary files /dev/null and b/site/client_site/static/images/grid-layout-3-370x256.jpg differ diff --git a/site/client_site/static/images/isotope-loader.png b/site/client_site/static/images/isotope-loader.png new file mode 100755 index 0000000..ac94682 Binary files /dev/null and b/site/client_site/static/images/isotope-loader.png differ diff --git a/site/client_site/static/images/loading.gif b/site/client_site/static/images/loading.gif new file mode 100755 index 0000000..bbc3e71 Binary files /dev/null and b/site/client_site/static/images/loading.gif differ diff --git a/site/client_site/static/images/mCSB_buttons.png b/site/client_site/static/images/mCSB_buttons.png new file mode 100644 index 0000000..985a9b8 Binary files /dev/null and b/site/client_site/static/images/mCSB_buttons.png differ diff --git a/site/client_site/static/images/monica-dhillon-headshot.jpg b/site/client_site/static/images/monica-dhillon-headshot.jpg new file mode 100644 index 0000000..37f7bdf Binary files /dev/null and b/site/client_site/static/images/monica-dhillon-headshot.jpg differ diff --git a/site/client_site/static/images/slider-minimal-slide-1-1920x968.jpg b/site/client_site/static/images/slider-minimal-slide-1-1920x968.jpg new file mode 100755 index 0000000..ef47dbd Binary files /dev/null and b/site/client_site/static/images/slider-minimal-slide-1-1920x968.jpg differ diff --git a/site/client_site/static/images/slider-minimal-slide-2-1920x968.jpg b/site/client_site/static/images/slider-minimal-slide-2-1920x968.jpg new file mode 100755 index 0000000..29dde29 Binary files /dev/null and b/site/client_site/static/images/slider-minimal-slide-2-1920x968.jpg differ diff --git a/site/client_site/static/images/video-play.png b/site/client_site/static/images/video-play.png new file mode 100755 index 0000000..39f3190 Binary files /dev/null and b/site/client_site/static/images/video-play.png differ diff --git a/site/client_site/static/images/vimeo-play.png b/site/client_site/static/images/vimeo-play.png new file mode 100755 index 0000000..183e448 Binary files /dev/null and b/site/client_site/static/images/vimeo-play.png differ diff --git a/site/client_site/static/images/youtube-play.png b/site/client_site/static/images/youtube-play.png new file mode 100755 index 0000000..e69b965 Binary files /dev/null and b/site/client_site/static/images/youtube-play.png differ diff --git a/site/client_site/static/js/address-autocomplete.js b/site/client_site/static/js/address-autocomplete.js new file mode 100644 index 0000000..1e87362 --- /dev/null +++ b/site/client_site/static/js/address-autocomplete.js @@ -0,0 +1,149 @@ +/** + * Address autocomplete via Django /api/address-suggest/ (Nominatim proxy). + * Never calls Nominatim from the browser. + * + * Markup: wrap fields in [data-address-autocomplete][data-suggest-url="..."]. + * Mark inputs with data-ac="line1|line2|city|state|zip|country". + * The line1 input is the typeahead trigger. + */ +(function () { + "use strict"; + + var DEBOUNCE_MS = 280; + var MIN_CHARS = 3; + + function debounce(fn, wait) { + var t; + return function () { + var ctx = this; + var args = arguments; + clearTimeout(t); + t = setTimeout(function () { + fn.apply(ctx, args); + }, wait); + }; + } + + function field(root, key) { + return root.querySelector('[data-ac="' + key + '"]'); + } + + function ensureList(root) { + var list = root.querySelector(".address-ac-list"); + if (list) { + return list; + } + list = document.createElement("ul"); + list.className = "address-ac-list"; + list.hidden = true; + list.setAttribute("role", "listbox"); + var line1 = field(root, "line1"); + var wrap = line1 && line1.closest(".form-wrap, .field"); + (wrap || root).appendChild(list); + if (wrap) { + wrap.classList.add("address-ac-wrap"); + } else { + root.classList.add("address-ac-wrap"); + } + return list; + } + + function hide(list) { + list.hidden = true; + list.innerHTML = ""; + } + + function fill(root, item) { + var map = { + line1: item.line1 || "", + line2: item.line2 || "", + city: item.city || "", + state: item.state || "", + zip: item.zip || "", + country: item.country || "US", + }; + Object.keys(map).forEach(function (key) { + var el = field(root, key); + if (el) { + el.value = map[key]; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + } + + function render(root, list, results) { + list.innerHTML = ""; + if (!results.length) { + hide(list); + return; + } + results.forEach(function (item, index) { + var li = document.createElement("li"); + li.className = "address-ac-item"; + li.setAttribute("role", "option"); + li.setAttribute("tabindex", "-1"); + li.dataset.index = String(index); + li.textContent = item.label || item.line1 || "Address"; + li.addEventListener("mousedown", function (ev) { + ev.preventDefault(); + fill(root, item); + hide(list); + }); + list.appendChild(li); + }); + list.hidden = false; + } + + function bind(root) { + var url = root.getAttribute("data-suggest-url"); + var input = field(root, "line1"); + if (!url || !input) { + return; + } + var list = ensureList(root); + input.setAttribute("autocomplete", "off"); + input.setAttribute("aria-autocomplete", "list"); + + var fetchSuggest = debounce(function () { + var q = (input.value || "").trim(); + if (q.length < MIN_CHARS) { + hide(list); + return; + } + var req = url + (url.indexOf("?") >= 0 ? "&" : "?") + "q=" + encodeURIComponent(q) + "&limit=5"; + fetch(req, { headers: { Accept: "application/json" }, credentials: "same-origin" }) + .then(function (res) { + return res.json(); + }) + .then(function (data) { + render(root, list, (data && data.results) || []); + }) + .catch(function () { + hide(list); + }); + }, DEBOUNCE_MS); + + input.addEventListener("input", fetchSuggest); + input.addEventListener("keydown", function (ev) { + if (ev.key === "Escape") { + hide(list); + } + }); + document.addEventListener("click", function (ev) { + if (!root.contains(ev.target)) { + hide(list); + } + }); + } + + function init() { + document.querySelectorAll("[data-address-autocomplete]").forEach(bind); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/site/client_site/static/js/core.min.js b/site/client_site/static/js/core.min.js new file mode 100755 index 0000000..b160f8d --- /dev/null +++ b/site/client_site/static/js/core.min.js @@ -0,0 +1,299 @@ +/** + * @module jQuery + * @author jQuery Foundation and other contributors + * @see https://code.jquery.com/jquery/ + * @license MIT (jquery.org/license) + * @version 3.3.1 + */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(e,t){"use strict";var n=[],r=e.document,i=Object.getPrototypeOf,o=n.slice,a=n.concat,s=n.push,u=n.indexOf,l={},c=l.toString,f=l.hasOwnProperty,p=f.toString,d=p.call(Object),h={},g=function e(t){return"function"==typeof t&&"number"!=typeof t.nodeType},y=function e(t){return null!=t&&t===t.window},v={type:!0,src:!0,noModule:!0};function m(e,t,n){var i,o=(t=t||r).createElement("script");if(o.text=e,n)for(i in v)n[i]&&(o[i]=n[i]);t.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[c.call(e)]||"object":typeof e}var b="3.3.1",w=function(e,t){return new w.fn.init(e,t)},T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;w.fn=w.prototype={jquery:"3.3.1",constructor:w,length:0,toArray:function(){return o.call(this)},get:function(e){return null==e?o.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=w.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return w.each(this,e)},map:function(e){return this.pushStack(w.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(o.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n0&&t-1 in e)}var E=function(e){var t,n,r,i,o,a,s,u,l,c,f,p,d,h,g,y,v,m,x,b="sizzle"+1*new Date,w=e.document,T=0,C=0,E=ae(),k=ae(),S=ae(),D=function(e,t){return e===t&&(f=!0),0},N={}.hasOwnProperty,A=[],j=A.pop,q=A.push,L=A.push,H=A.slice,O=function(e,t){for(var n=0,r=e.length;n+~]|"+M+")"+M+"*"),z=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),X=new RegExp(W),U=new RegExp("^"+R+"$"),V={ID:new RegExp("^#("+R+")"),CLASS:new RegExp("^\\.("+R+")"),TAG:new RegExp("^("+R+"|[*])"),ATTR:new RegExp("^"+I),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+P+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},G=/^(?:input|select|textarea|button)$/i,Y=/^h\d$/i,Q=/^[^{]+\{\s*\[native \w/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,K=/[+~]/,Z=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ee=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},te=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ne=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},re=function(){p()},ie=me(function(e){return!0===e.disabled&&("form"in e||"label"in e)},{dir:"parentNode",next:"legend"});try{L.apply(A=H.call(w.childNodes),w.childNodes),A[w.childNodes.length].nodeType}catch(e){L={apply:A.length?function(e,t){q.apply(e,H.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function oe(e,t,r,i){var o,s,l,c,f,h,v,m=t&&t.ownerDocument,T=t?t.nodeType:9;if(r=r||[],"string"!=typeof e||!e||1!==T&&9!==T&&11!==T)return r;if(!i&&((t?t.ownerDocument||t:w)!==d&&p(t),t=t||d,g)){if(11!==T&&(f=J.exec(e)))if(o=f[1]){if(9===T){if(!(l=t.getElementById(o)))return r;if(l.id===o)return r.push(l),r}else if(m&&(l=m.getElementById(o))&&x(t,l)&&l.id===o)return r.push(l),r}else{if(f[2])return L.apply(r,t.getElementsByTagName(e)),r;if((o=f[3])&&n.getElementsByClassName&&t.getElementsByClassName)return L.apply(r,t.getElementsByClassName(o)),r}if(n.qsa&&!S[e+" "]&&(!y||!y.test(e))){if(1!==T)m=t,v=e;else if("object"!==t.nodeName.toLowerCase()){(c=t.getAttribute("id"))?c=c.replace(te,ne):t.setAttribute("id",c=b),s=(h=a(e)).length;while(s--)h[s]="#"+c+" "+ve(h[s]);v=h.join(","),m=K.test(e)&&ge(t.parentNode)||t}if(v)try{return L.apply(r,m.querySelectorAll(v)),r}catch(e){}finally{c===b&&t.removeAttribute("id")}}}return u(e.replace(B,"$1"),t,r,i)}function ae(){var e=[];function t(n,i){return e.push(n+" ")>r.cacheLength&&delete t[e.shift()],t[n+" "]=i}return t}function se(e){return e[b]=!0,e}function ue(e){var t=d.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function le(e,t){var n=e.split("|"),i=n.length;while(i--)r.attrHandle[n[i]]=t}function ce(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function fe(e){return function(t){return"input"===t.nodeName.toLowerCase()&&t.type===e}}function pe(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function de(e){return function(t){return"form"in t?t.parentNode&&!1===t.disabled?"label"in t?"label"in t.parentNode?t.parentNode.disabled===e:t.disabled===e:t.isDisabled===e||t.isDisabled!==!e&&ie(t)===e:t.disabled===e:"label"in t&&t.disabled===e}}function he(e){return se(function(t){return t=+t,se(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function ge(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}n=oe.support={},o=oe.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return!!t&&"HTML"!==t.nodeName},p=oe.setDocument=function(e){var t,i,a=e?e.ownerDocument||e:w;return a!==d&&9===a.nodeType&&a.documentElement?(d=a,h=d.documentElement,g=!o(d),w!==d&&(i=d.defaultView)&&i.top!==i&&(i.addEventListener?i.addEventListener("unload",re,!1):i.attachEvent&&i.attachEvent("onunload",re)),n.attributes=ue(function(e){return e.className="i",!e.getAttribute("className")}),n.getElementsByTagName=ue(function(e){return e.appendChild(d.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=Q.test(d.getElementsByClassName),n.getById=ue(function(e){return h.appendChild(e).id=b,!d.getElementsByName||!d.getElementsByName(b).length}),n.getById?(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){return e.getAttribute("id")===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n=t.getElementById(e);return n?[n]:[]}}):(r.filter.ID=function(e){var t=e.replace(Z,ee);return function(e){var n="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return n&&n.value===t}},r.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&g){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),r.find.TAG=n.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):n.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},r.find.CLASS=n.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&g)return t.getElementsByClassName(e)},v=[],y=[],(n.qsa=Q.test(d.querySelectorAll))&&(ue(function(e){h.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+P+")"),e.querySelectorAll("[id~="+b+"-]").length||y.push("~="),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+b+"+*").length||y.push(".#.+[+~]")}),ue(function(e){e.innerHTML="";var t=d.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),h.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(n.matchesSelector=Q.test(m=h.matches||h.webkitMatchesSelector||h.mozMatchesSelector||h.oMatchesSelector||h.msMatchesSelector))&&ue(function(e){n.disconnectedMatch=m.call(e,"*"),m.call(e,"[s!='']:x"),v.push("!=",W)}),y=y.length&&new RegExp(y.join("|")),v=v.length&&new RegExp(v.join("|")),t=Q.test(h.compareDocumentPosition),x=t||Q.test(h.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return f=!0,0;var r=!e.compareDocumentPosition-!t.compareDocumentPosition;return r||(1&(r=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!n.sortDetached&&t.compareDocumentPosition(e)===r?e===d||e.ownerDocument===w&&x(w,e)?-1:t===d||t.ownerDocument===w&&x(w,t)?1:c?O(c,e)-O(c,t):0:4&r?-1:1)}:function(e,t){if(e===t)return f=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===d?-1:t===d?1:i?-1:o?1:c?O(c,e)-O(c,t):0;if(i===o)return ce(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?ce(a[r],s[r]):a[r]===w?-1:s[r]===w?1:0},d):d},oe.matches=function(e,t){return oe(e,null,null,t)},oe.matchesSelector=function(e,t){if((e.ownerDocument||e)!==d&&p(e),t=t.replace(z,"='$1']"),n.matchesSelector&&g&&!S[t+" "]&&(!v||!v.test(t))&&(!y||!y.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(e){}return oe(t,d,null,[e]).length>0},oe.contains=function(e,t){return(e.ownerDocument||e)!==d&&p(e),x(e,t)},oe.attr=function(e,t){(e.ownerDocument||e)!==d&&p(e);var i=r.attrHandle[t.toLowerCase()],o=i&&N.call(r.attrHandle,t.toLowerCase())?i(e,t,!g):void 0;return void 0!==o?o:n.attributes||!g?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null},oe.escape=function(e){return(e+"").replace(te,ne)},oe.error=function(e){throw new Error("Syntax error, unrecognized expression: "+e)},oe.uniqueSort=function(e){var t,r=[],i=0,o=0;if(f=!n.detectDuplicates,c=!n.sortStable&&e.slice(0),e.sort(D),f){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return c=null,e},i=oe.getText=function(e){var t,n="",r=0,o=e.nodeType;if(o){if(1===o||9===o||11===o){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=i(e)}else if(3===o||4===o)return e.nodeValue}else while(t=e[r++])n+=i(t);return n},(r=oe.selectors={cacheLength:50,createPseudo:se,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(Z,ee),e[3]=(e[3]||e[4]||e[5]||"").replace(Z,ee),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||oe.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&oe.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return V.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=a(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(Z,ee).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=E[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&E(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=oe.attr(r,e);return null==i?"!="===t:!t||(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace($," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h,g=o!==a?"nextSibling":"previousSibling",y=t.parentNode,v=s&&t.nodeName.toLowerCase(),m=!u&&!s,x=!1;if(y){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===v:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?y.firstChild:y.lastChild],a&&m){x=(d=(l=(c=(f=(p=y)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1])&&l[2],p=d&&y.childNodes[d];while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if(1===p.nodeType&&++x&&p===t){c[e]=[T,d,x];break}}else if(m&&(x=d=(l=(c=(f=(p=t)[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]||[])[0]===T&&l[1]),!1===x)while(p=++d&&p&&p[g]||(x=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===v:1===p.nodeType)&&++x&&(m&&((c=(f=p[b]||(p[b]={}))[p.uniqueID]||(f[p.uniqueID]={}))[e]=[T,x]),p===t))break;return(x-=i)===r||x%r==0&&x/r>=0}}},PSEUDO:function(e,t){var n,i=r.pseudos[e]||r.setFilters[e.toLowerCase()]||oe.error("unsupported pseudo: "+e);return i[b]?i(t):i.length>1?(n=[e,e,"",t],r.setFilters.hasOwnProperty(e.toLowerCase())?se(function(e,n){var r,o=i(e,t),a=o.length;while(a--)e[r=O(e,o[a])]=!(n[r]=o[a])}):function(e){return i(e,0,n)}):i}},pseudos:{not:se(function(e){var t=[],n=[],r=s(e.replace(B,"$1"));return r[b]?se(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:se(function(e){return function(t){return oe(e,t).length>0}}),contains:se(function(e){return e=e.replace(Z,ee),function(t){return(t.textContent||t.innerText||i(t)).indexOf(e)>-1}}),lang:se(function(e){return U.test(e||"")||oe.error("unsupported lang: "+e),e=e.replace(Z,ee).toLowerCase(),function(t){var n;do{if(n=g?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-")}while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===h},focus:function(e){return e===d.activeElement&&(!d.hasFocus||d.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:de(!1),disabled:de(!0),checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!r.pseudos.empty(e)},header:function(e){return Y.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||"text"===t.toLowerCase())},first:he(function(){return[0]}),last:he(function(e,t){return[t-1]}),eq:he(function(e,t,n){return[n<0?n+t:n]}),even:he(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:he(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function be(e,t,n){for(var r=0,i=t.length;r-1&&(o[l]=!(a[l]=f))}}else v=we(v===a?v.splice(h,v.length):v),i?i(null,a,v,u):L.apply(a,v)})}function Ce(e){for(var t,n,i,o=e.length,a=r.relative[e[0].type],s=a||r.relative[" "],u=a?1:0,c=me(function(e){return e===t},s,!0),f=me(function(e){return O(t,e)>-1},s,!0),p=[function(e,n,r){var i=!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):f(e,n,r));return t=null,i}];u1&&xe(p),u>1&&ve(e.slice(0,u-1).concat({value:" "===e[u-2].type?"*":""})).replace(B,"$1"),n,u0,i=e.length>0,o=function(o,a,s,u,c){var f,h,y,v=0,m="0",x=o&&[],b=[],w=l,C=o||i&&r.find.TAG("*",c),E=T+=null==w?1:Math.random()||.1,k=C.length;for(c&&(l=a===d||a||c);m!==k&&null!=(f=C[m]);m++){if(i&&f){h=0,a||f.ownerDocument===d||(p(f),s=!g);while(y=e[h++])if(y(f,a||d,s)){u.push(f);break}c&&(T=E)}n&&((f=!y&&f)&&v--,o&&x.push(f))}if(v+=m,n&&m!==v){h=0;while(y=t[h++])y(x,b,a,s);if(o){if(v>0)while(m--)x[m]||b[m]||(b[m]=j.call(u));b=we(b)}L.apply(u,b),c&&!o&&b.length>0&&v+t.length>1&&oe.uniqueSort(u)}return c&&(T=E,l=w),x};return n?se(o):o}return s=oe.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=a(e)),n=t.length;while(n--)(o=Ce(t[n]))[b]?r.push(o):i.push(o);(o=S(e,Ee(i,r))).selector=e}return o},u=oe.select=function(e,t,n,i){var o,u,l,c,f,p="function"==typeof e&&e,d=!i&&a(e=p.selector||e);if(n=n||[],1===d.length){if((u=d[0]=d[0].slice(0)).length>2&&"ID"===(l=u[0]).type&&9===t.nodeType&&g&&r.relative[u[1].type]){if(!(t=(r.find.ID(l.matches[0].replace(Z,ee),t)||[])[0]))return n;p&&(t=t.parentNode),e=e.slice(u.shift().value.length)}o=V.needsContext.test(e)?0:u.length;while(o--){if(l=u[o],r.relative[c=l.type])break;if((f=r.find[c])&&(i=f(l.matches[0].replace(Z,ee),K.test(u[0].type)&&ge(t.parentNode)||t))){if(u.splice(o,1),!(e=i.length&&ve(u)))return L.apply(n,i),n;break}}}return(p||s(e,d))(i,t,!g,n,!t||K.test(e)&&ge(t.parentNode)||t),n},n.sortStable=b.split("").sort(D).join("")===b,n.detectDuplicates=!!f,p(),n.sortDetached=ue(function(e){return 1&e.compareDocumentPosition(d.createElement("fieldset"))}),ue(function(e){return e.innerHTML="","#"===e.firstChild.getAttribute("href")})||le("type|href|height|width",function(e,t,n){if(!n)return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}),n.attributes&&ue(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")})||le("value",function(e,t,n){if(!n&&"input"===e.nodeName.toLowerCase())return e.defaultValue}),ue(function(e){return null==e.getAttribute("disabled")})||le(P,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null}),oe}(e);w.find=E,w.expr=E.selectors,w.expr[":"]=w.expr.pseudos,w.uniqueSort=w.unique=E.uniqueSort,w.text=E.getText,w.isXMLDoc=E.isXML,w.contains=E.contains,w.escapeSelector=E.escape;var k=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&w(e).is(n))break;r.push(e)}return r},S=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n},D=w.expr.match.needsContext;function N(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var A=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,t,n){return g(t)?w.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?w.grep(e,function(e){return e===t!==n}):"string"!=typeof t?w.grep(e,function(e){return u.call(t,e)>-1!==n}):w.filter(t,e,n)}w.filter=function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?w.find.matchesSelector(r,e)?[r]:[]:w.find.matches(e,w.grep(t,function(e){return 1===e.nodeType}))},w.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(w(e).filter(function(){for(t=0;t1?w.uniqueSort(n):n},filter:function(e){return this.pushStack(j(this,e||[],!1))},not:function(e){return this.pushStack(j(this,e||[],!0))},is:function(e){return!!j(this,"string"==typeof e&&D.test(e)?w(e):e||[],!1).length}});var q,L=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(w.fn.init=function(e,t,n){var i,o;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(i="<"===e[0]&&">"===e[e.length-1]&&e.length>=3?[null,e,null]:L.exec(e))||!i[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(i[1]){if(t=t instanceof w?t[0]:t,w.merge(this,w.parseHTML(i[1],t&&t.nodeType?t.ownerDocument||t:r,!0)),A.test(i[1])&&w.isPlainObject(t))for(i in t)g(this[i])?this[i](t[i]):this.attr(i,t[i]);return this}return(o=r.getElementById(i[2]))&&(this[0]=o,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):g(e)?void 0!==n.ready?n.ready(e):e(w):w.makeArray(e,this)}).prototype=w.fn,q=w(r);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};w.fn.extend({has:function(e){var t=w(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&w.find.matchesSelector(n,e))){o.push(n);break}return this.pushStack(o.length>1?w.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?u.call(w(e),this[0]):u.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(w.uniqueSort(w.merge(this.get(),w(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}w.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return k(e,"parentNode")},parentsUntil:function(e,t,n){return k(e,"parentNode",n)},next:function(e){return P(e,"nextSibling")},prev:function(e){return P(e,"previousSibling")},nextAll:function(e){return k(e,"nextSibling")},prevAll:function(e){return k(e,"previousSibling")},nextUntil:function(e,t,n){return k(e,"nextSibling",n)},prevUntil:function(e,t,n){return k(e,"previousSibling",n)},siblings:function(e){return S((e.parentNode||{}).firstChild,e)},children:function(e){return S(e.firstChild)},contents:function(e){return N(e,"iframe")?e.contentDocument:(N(e,"template")&&(e=e.content||e),w.merge([],e.childNodes))}},function(e,t){w.fn[e]=function(n,r){var i=w.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=w.filter(r,i)),this.length>1&&(O[e]||w.uniqueSort(i),H.test(e)&&i.reverse()),this.pushStack(i)}});var M=/[^\x20\t\r\n\f]+/g;function R(e){var t={};return w.each(e.match(M)||[],function(e,n){t[n]=!0}),t}w.Callbacks=function(e){e="string"==typeof e?R(e):w.extend({},e);var t,n,r,i,o=[],a=[],s=-1,u=function(){for(i=i||e.once,r=t=!0;a.length;s=-1){n=a.shift();while(++s-1)o.splice(n,1),n<=s&&s--}),this},has:function(e){return e?w.inArray(e,o)>-1:o.length>0},empty:function(){return o&&(o=[]),this},disable:function(){return i=a=[],o=n="",this},disabled:function(){return!o},lock:function(){return i=a=[],n||t||(o=n=""),this},locked:function(){return!!i},fireWith:function(e,n){return i||(n=[e,(n=n||[]).slice?n.slice():n],a.push(n),t||u()),this},fire:function(){return l.fireWith(this,arguments),this},fired:function(){return!!r}};return l};function I(e){return e}function W(e){throw e}function $(e,t,n,r){var i;try{e&&g(i=e.promise)?i.call(e).done(t).fail(n):e&&g(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n.apply(void 0,[e])}}w.extend({Deferred:function(t){var n=[["notify","progress",w.Callbacks("memory"),w.Callbacks("memory"),2],["resolve","done",w.Callbacks("once memory"),w.Callbacks("once memory"),0,"resolved"],["reject","fail",w.Callbacks("once memory"),w.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},"catch":function(e){return i.then(null,e)},pipe:function(){var e=arguments;return w.Deferred(function(t){w.each(n,function(n,r){var i=g(e[r[4]])&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&g(e.promise)?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var s=this,u=arguments,l=function(){var e,l;if(!(t=o&&(r!==W&&(s=void 0,u=[e]),n.rejectWith(s,u))}};t?c():(w.Deferred.getStackHook&&(c.stackTrace=w.Deferred.getStackHook()),e.setTimeout(c))}}return w.Deferred(function(e){n[0][3].add(a(0,e,g(i)?i:I,e.notifyWith)),n[1][3].add(a(0,e,g(t)?t:I)),n[2][3].add(a(0,e,g(r)?r:W))}).promise()},promise:function(e){return null!=e?w.extend(e,i):i}},o={};return w.each(n,function(e,t){var a=t[2],s=t[5];i[t[1]]=a.add,s&&a.add(function(){r=s},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),i=o.call(arguments),a=w.Deferred(),s=function(e){return function(n){r[e]=this,i[e]=arguments.length>1?o.call(arguments):n,--t||a.resolveWith(r,i)}};if(t<=1&&($(e,a.done(s(n)).resolve,a.reject,!t),"pending"===a.state()||g(i[n]&&i[n].then)))return a.then();while(n--)$(i[n],s(n),a.reject);return a.promise()}});var B=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;w.Deferred.exceptionHook=function(t,n){e.console&&e.console.warn&&t&&B.test(t.name)&&e.console.warn("jQuery.Deferred exception: "+t.message,t.stack,n)},w.readyException=function(t){e.setTimeout(function(){throw t})};var F=w.Deferred();w.fn.ready=function(e){return F.then(e)["catch"](function(e){w.readyException(e)}),this},w.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--w.readyWait:w.isReady)||(w.isReady=!0,!0!==e&&--w.readyWait>0||F.resolveWith(r,[w]))}}),w.ready.then=F.then;function _(){r.removeEventListener("DOMContentLoaded",_),e.removeEventListener("load",_),w.ready()}"complete"===r.readyState||"loading"!==r.readyState&&!r.documentElement.doScroll?e.setTimeout(w.ready):(r.addEventListener("DOMContentLoaded",_),e.addEventListener("load",_));var z=function(e,t,n,r,i,o,a){var s=0,u=e.length,l=null==n;if("object"===x(n)){i=!0;for(s in n)z(e,t,s,n[s],!0,o,a)}else if(void 0!==r&&(i=!0,g(r)||(a=!0),l&&(a?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(w(e),n)})),t))for(;s1,null,!0)},removeData:function(e){return this.each(function(){K.remove(this,e)})}}),w.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=J.get(e,t),n&&(!r||Array.isArray(n)?r=J.access(e,t,w.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=w.queue(e,t),r=n.length,i=n.shift(),o=w._queueHooks(e,t),a=function(){w.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return J.get(e,n)||J.access(e,n,{empty:w.Callbacks("once memory").add(function(){J.remove(e,[t+"queue",n])})})}}),w.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),arguments.length\x20\t\r\n\f]+)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ge.optgroup=ge.option,ge.tbody=ge.tfoot=ge.colgroup=ge.caption=ge.thead,ge.th=ge.td;function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&N(e,t)?w.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n-1)i&&i.push(o);else if(l=w.contains(o.ownerDocument,o),a=ye(f.appendChild(o),"script"),l&&ve(a),n){c=0;while(o=a[c++])he.test(o.type||"")&&n.push(o)}return f}!function(){var e=r.createDocumentFragment().appendChild(r.createElement("div")),t=r.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),h.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",h.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var be=r.documentElement,we=/^key/,Te=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ce=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function ke(){return!1}function Se(){try{return r.activeElement}catch(e){}}function De(e,t,n,r,i,o){var a,s;if("object"==typeof t){"string"!=typeof n&&(r=r||n,n=void 0);for(s in t)De(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=ke;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return w().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=w.guid++)),e.each(function(){w.event.add(this,t,i,r,n)})}w.event={global:{},add:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.get(e);if(y){n.handler&&(n=(o=n).handler,i=o.selector),i&&w.find.matchesSelector(be,i),n.guid||(n.guid=w.guid++),(u=y.events)||(u=y.events={}),(a=y.handle)||(a=y.handle=function(t){return"undefined"!=typeof w&&w.event.triggered!==t.type?w.event.dispatch.apply(e,arguments):void 0}),l=(t=(t||"").match(M)||[""]).length;while(l--)d=g=(s=Ce.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=w.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=w.event.special[d]||{},c=w.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&w.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(e,r,h,a)||e.addEventListener&&e.addEventListener(d,a)),f.add&&(f.add.call(e,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),w.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,y=J.hasData(e)&&J.get(e);if(y&&(u=y.events)){l=(t=(t||"").match(M)||[""]).length;while(l--)if(s=Ce.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){f=w.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,y.handle)||w.removeEvent(e,d,y.handle),delete u[d])}else for(d in u)w.event.remove(e,d+t[l],n,r,!0);w.isEmptyObject(u)&&J.remove(e,"handle events")}},dispatch:function(e){var t=w.event.fix(e),n,r,i,o,a,s,u=new Array(arguments.length),l=(J.get(this,"events")||{})[t.type]||[],c=w.event.special[t.type]||{};for(u[0]=t,n=1;n=1))for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(o=[],a={},n=0;n-1:w.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}return l=this,u\x20\t\r\n\f]*)[^>]*)\/>/gi,Ae=/\s*$/g;function Le(e,t){return N(e,"table")&&N(11!==t.nodeType?t:t.firstChild,"tr")?w(e).children("tbody")[0]||e:e}function He(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Oe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Pe(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(J.hasData(e)&&(o=J.access(e),a=J.set(t,o),l=o.events)){delete a.handle,a.events={};for(i in l)for(n=0,r=l[i].length;n1&&"string"==typeof y&&!h.checkClone&&je.test(y))return e.each(function(i){var o=e.eq(i);v&&(t[0]=y.call(this,i,o.html())),Re(o,t,n,r)});if(p&&(i=xe(t,e[0].ownerDocument,!1,e,r),o=i.firstChild,1===i.childNodes.length&&(i=o),o||r)){for(u=(s=w.map(ye(i,"script"),He)).length;f")},clone:function(e,t,n){var r,i,o,a,s=e.cloneNode(!0),u=w.contains(e.ownerDocument,e);if(!(h.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||w.isXMLDoc(e)))for(a=ye(s),r=0,i=(o=ye(e)).length;r0&&ve(a,!u&&ye(e,"script")),s},cleanData:function(e){for(var t,n,r,i=w.event.special,o=0;void 0!==(n=e[o]);o++)if(Y(n)){if(t=n[J.expando]){if(t.events)for(r in t.events)i[r]?w.event.remove(n,r):w.removeEvent(n,r,t.handle);n[J.expando]=void 0}n[K.expando]&&(n[K.expando]=void 0)}}}),w.fn.extend({detach:function(e){return Ie(this,e,!0)},remove:function(e){return Ie(this,e)},text:function(e){return z(this,function(e){return void 0===e?w.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return Re(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||Le(this,e).appendChild(e)})},prepend:function(){return Re(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=Le(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return Re(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(w.cleanData(ye(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return w.clone(this,e,t)})},html:function(e){return z(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Ae.test(e)&&!ge[(de.exec(e)||["",""])[1].toLowerCase()]){e=w.htmlPrefilter(e);try{for(;n=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))),u}function et(e,t,n){var r=$e(e),i=Fe(e,t,r),o="border-box"===w.css(e,"boxSizing",!1,r),a=o;if(We.test(i)){if(!n)return i;i="auto"}return a=a&&(h.boxSizingReliable()||i===e.style[t]),("auto"===i||!parseFloat(i)&&"inline"===w.css(e,"display",!1,r))&&(i=e["offset"+t[0].toUpperCase()+t.slice(1)],a=!0),(i=parseFloat(i)||0)+Ze(e,t,n||(o?"border":"content"),a,r,i)+"px"}w.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Fe(e,"opacity");return""===n?"1":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=G(t),u=Xe.test(t),l=e.style;if(u||(t=Je(s)),a=w.cssHooks[t]||w.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];"string"==(o=typeof n)&&(i=ie.exec(n))&&i[1]&&(n=ue(e,t,i),o="number"),null!=n&&n===n&&("number"===o&&(n+=i&&i[3]||(w.cssNumber[s]?"":"px")),h.clearCloneStyle||""!==n||0!==t.indexOf("background")||(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=G(t);return Xe.test(t)||(t=Je(s)),(a=w.cssHooks[t]||w.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=Fe(e,t,r)),"normal"===i&&t in Ve&&(i=Ve[t]),""===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),w.each(["height","width"],function(e,t){w.cssHooks[t]={get:function(e,n,r){if(n)return!ze.test(w.css(e,"display"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):se(e,Ue,function(){return et(e,t,r)})},set:function(e,n,r){var i,o=$e(e),a="border-box"===w.css(e,"boxSizing",!1,o),s=r&&Ze(e,t,r,a,o);return a&&h.scrollboxSize()===o.position&&(s-=Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,"border",!1,o)-.5)),s&&(i=ie.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=w.css(e,t)),Ke(e,n,s)}}}),w.cssHooks.marginLeft=_e(h.reliableMarginLeft,function(e,t){if(t)return(parseFloat(Fe(e,"marginLeft"))||e.getBoundingClientRect().left-se(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),w.each({margin:"",padding:"",border:"Width"},function(e,t){w.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+oe[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(w.cssHooks[e+t].set=Ke)}),w.fn.extend({css:function(e,t){return z(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=$e(e),i=t.length;a1)}});function tt(e,t,n,r,i){return new tt.prototype.init(e,t,n,r,i)}w.Tween=tt,tt.prototype={constructor:tt,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||w.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(w.cssNumber[n]?"":"px")},cur:function(){var e=tt.propHooks[this.prop];return e&&e.get?e.get(this):tt.propHooks._default.get(this)},run:function(e){var t,n=tt.propHooks[this.prop];return this.options.duration?this.pos=t=w.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tt.propHooks._default.set(this),this}},tt.prototype.init.prototype=tt.prototype,tt.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=w.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){w.fx.step[e.prop]?w.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[w.cssProps[e.prop]]&&!w.cssHooks[e.prop]?e.elem[e.prop]=e.now:w.style(e.elem,e.prop,e.now+e.unit)}}},tt.propHooks.scrollTop=tt.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},w.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},w.fx=tt.prototype.init,w.fx.step={};var nt,rt,it=/^(?:toggle|show|hide)$/,ot=/queueHooks$/;function at(){rt&&(!1===r.hidden&&e.requestAnimationFrame?e.requestAnimationFrame(at):e.setTimeout(at,w.fx.interval),w.fx.tick())}function st(){return e.setTimeout(function(){nt=void 0}),nt=Date.now()}function ut(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i["margin"+(n=oe[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function lt(e,t,n){for(var r,i=(pt.tweeners[t]||[]).concat(pt.tweeners["*"]),o=0,a=i.length;o1)},removeAttr:function(e){return this.each(function(){w.removeAttr(this,e)})}}),w.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return"undefined"==typeof e.getAttribute?w.prop(e,t,n):(1===o&&w.isXMLDoc(e)||(i=w.attrHooks[t.toLowerCase()]||(w.expr.match.bool.test(t)?dt:void 0)),void 0!==n?null===n?void w.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+""),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=w.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!h.radioValue&&"radio"===t&&N(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(M);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),dt={set:function(e,t,n){return!1===t?w.removeAttr(e,n):e.setAttribute(n,n),n}},w.each(w.expr.match.bool.source.match(/\w+/g),function(e,t){var n=ht[t]||w.find.attr;ht[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=ht[a],ht[a]=i,i=null!=n(e,t,r)?a:null,ht[a]=o),i}});var gt=/^(?:input|select|textarea|button)$/i,yt=/^(?:a|area)$/i;w.fn.extend({prop:function(e,t){return z(this,w.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[w.propFix[e]||e]})}}),w.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&w.isXMLDoc(e)||(t=w.propFix[t]||t,i=w.propHooks[t]),void 0!==n?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=w.find.attr(e,"tabindex");return t?parseInt(t,10):gt.test(e.nodeName)||yt.test(e.nodeName)&&e.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),h.optSelected||(w.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),w.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){w.propFix[this.toLowerCase()]=this});function vt(e){return(e.match(M)||[]).join(" ")}function mt(e){return e.getAttribute&&e.getAttribute("class")||""}function xt(e){return Array.isArray(e)?e:"string"==typeof e?e.match(M)||[]:[]}w.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).addClass(e.call(this,t,mt(this)))});if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])r.indexOf(" "+o+" ")<0&&(r+=o+" ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(g(e))return this.each(function(t){w(this).removeClass(e.call(this,t,mt(this)))});if(!arguments.length)return this.attr("class","");if((t=xt(e)).length)while(n=this[u++])if(i=mt(n),r=1===n.nodeType&&" "+vt(i)+" "){a=0;while(o=t[a++])while(r.indexOf(" "+o+" ")>-1)r=r.replace(" "+o+" "," ");i!==(s=vt(r))&&n.setAttribute("class",s)}return this},toggleClass:function(e,t){var n=typeof e,r="string"===n||Array.isArray(e);return"boolean"==typeof t&&r?t?this.addClass(e):this.removeClass(e):g(e)?this.each(function(n){w(this).toggleClass(e.call(this,n,mt(this),t),t)}):this.each(function(){var t,i,o,a;if(r){i=0,o=w(this),a=xt(e);while(t=a[i++])o.hasClass(t)?o.removeClass(t):o.addClass(t)}else void 0!==e&&"boolean"!==n||((t=mt(this))&&J.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":J.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;t=" "+e+" ";while(n=this[r++])if(1===n.nodeType&&(" "+vt(mt(n))+" ").indexOf(t)>-1)return!0;return!1}});var bt=/\r/g;w.fn.extend({val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=g(e),this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,w(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=w.map(i,function(e){return null==e?"":e+""})),(t=w.valHooks[this.type]||w.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))});if(i)return(t=w.valHooks[i.type]||w.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:"string"==typeof(n=i.value)?n.replace(bt,""):null==n?"":n}}}),w.extend({valHooks:{option:{get:function(e){var t=w.find.attr(e,"value");return null!=t?t:vt(w.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),w.each(["radio","checkbox"],function(){w.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=w.inArray(w(e).val(),t)>-1}},h.checkOn||(w.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})}),h.focusin="onfocusin"in e;var wt=/^(?:focusinfocus|focusoutblur)$/,Tt=function(e){e.stopPropagation()};w.extend(w.event,{trigger:function(t,n,i,o){var a,s,u,l,c,p,d,h,v=[i||r],m=f.call(t,"type")?t.type:t,x=f.call(t,"namespace")?t.namespace.split("."):[];if(s=h=u=i=i||r,3!==i.nodeType&&8!==i.nodeType&&!wt.test(m+w.event.triggered)&&(m.indexOf(".")>-1&&(m=(x=m.split(".")).shift(),x.sort()),c=m.indexOf(":")<0&&"on"+m,t=t[w.expando]?t:new w.Event(m,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=x.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+x.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=i),n=null==n?[t]:w.makeArray(n,[t]),d=w.event.special[m]||{},o||!d.trigger||!1!==d.trigger.apply(i,n))){if(!o&&!d.noBubble&&!y(i)){for(l=d.delegateType||m,wt.test(l+m)||(s=s.parentNode);s;s=s.parentNode)v.push(s),u=s;u===(i.ownerDocument||r)&&v.push(u.defaultView||u.parentWindow||e)}a=0;while((s=v[a++])&&!t.isPropagationStopped())h=s,t.type=a>1?l:d.bindType||m,(p=(J.get(s,"events")||{})[t.type]&&J.get(s,"handle"))&&p.apply(s,n),(p=c&&s[c])&&p.apply&&Y(s)&&(t.result=p.apply(s,n),!1===t.result&&t.preventDefault());return t.type=m,o||t.isDefaultPrevented()||d._default&&!1!==d._default.apply(v.pop(),n)||!Y(i)||c&&g(i[m])&&!y(i)&&((u=i[c])&&(i[c]=null),w.event.triggered=m,t.isPropagationStopped()&&h.addEventListener(m,Tt),i[m](),t.isPropagationStopped()&&h.removeEventListener(m,Tt),w.event.triggered=void 0,u&&(i[c]=u)),t.result}},simulate:function(e,t,n){var r=w.extend(new w.Event,n,{type:e,isSimulated:!0});w.event.trigger(r,null,t)}}),w.fn.extend({trigger:function(e,t){return this.each(function(){w.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return w.event.trigger(e,t,n,!0)}}),h.focusin||w.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){w.event.simulate(t,e.target,w.event.fix(e))};w.event.special[t]={setup:function(){var r=this.ownerDocument||this,i=J.access(r,t);i||r.addEventListener(e,n,!0),J.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this,i=J.access(r,t)-1;i?J.access(r,t,i):(r.removeEventListener(e,n,!0),J.remove(r,t))}}});var Ct=e.location,Et=Date.now(),kt=/\?/;w.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,"text/xml")}catch(e){n=void 0}return n&&!n.getElementsByTagName("parsererror").length||w.error("Invalid XML: "+t),n};var St=/\[\]$/,Dt=/\r?\n/g,Nt=/^(?:submit|button|image|reset|file)$/i,At=/^(?:input|select|textarea|keygen)/i;function jt(e,t,n,r){var i;if(Array.isArray(t))w.each(t,function(t,i){n||St.test(e)?r(e,i):jt(e+"["+("object"==typeof i&&null!=i?t:"")+"]",i,n,r)});else if(n||"object"!==x(t))r(e,t);else for(i in t)jt(e+"["+i+"]",t[i],n,r)}w.param=function(e,t){var n,r=[],i=function(e,t){var n=g(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(Array.isArray(e)||e.jquery&&!w.isPlainObject(e))w.each(e,function(){i(this.name,this.value)});else for(n in e)jt(n,e[n],t,i);return r.join("&")},w.fn.extend({serialize:function(){return w.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=w.prop(this,"elements");return e?w.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!w(this).is(":disabled")&&At.test(this.nodeName)&&!Nt.test(e)&&(this.checked||!pe.test(e))}).map(function(e,t){var n=w(this).val();return null==n?null:Array.isArray(n)?w.map(n,function(e){return{name:t.name,value:e.replace(Dt,"\r\n")}}):{name:t.name,value:n.replace(Dt,"\r\n")}}).get()}});var qt=/%20/g,Lt=/#.*$/,Ht=/([?&])_=[^&]*/,Ot=/^(.*?):[ \t]*([^\r\n]*)$/gm,Pt=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Mt=/^(?:GET|HEAD)$/,Rt=/^\/\//,It={},Wt={},$t="*/".concat("*"),Bt=r.createElement("a");Bt.href=Ct.href;function Ft(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(M)||[];if(g(n))while(r=o[i++])"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function _t(e,t,n,r){var i={},o=e===Wt;function a(s){var u;return i[s]=!0,w.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function zt(e,t){var n,r,i=w.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&w.extend(!0,e,r),e}function Xt(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}function Ut(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o]))for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}return{state:"success",data:t}}w.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Ct.href,type:"GET",isLocal:Pt.test(Ct.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":$t,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":w.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?zt(zt(e,w.ajaxSettings),t):zt(w.ajaxSettings,e)},ajaxPrefilter:Ft(It),ajaxTransport:Ft(Wt),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var i,o,a,s,u,l,c,f,p,d,h=w.ajaxSetup({},n),g=h.context||h,y=h.context&&(g.nodeType||g.jquery)?w(g):w.event,v=w.Deferred(),m=w.Callbacks("once memory"),x=h.statusCode||{},b={},T={},C="canceled",E={readyState:0,getResponseHeader:function(e){var t;if(c){if(!s){s={};while(t=Ot.exec(a))s[t[1].toLowerCase()]=t[2]}t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return c?a:null},setRequestHeader:function(e,t){return null==c&&(e=T[e.toLowerCase()]=T[e.toLowerCase()]||e,b[e]=t),this},overrideMimeType:function(e){return null==c&&(h.mimeType=e),this},statusCode:function(e){var t;if(e)if(c)E.always(e[E.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return i&&i.abort(t),k(0,t),this}};if(v.promise(E),h.url=((t||h.url||Ct.href)+"").replace(Rt,Ct.protocol+"//"),h.type=n.method||n.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(M)||[""],null==h.crossDomain){l=r.createElement("a");try{l.href=h.url,l.href=l.href,h.crossDomain=Bt.protocol+"//"+Bt.host!=l.protocol+"//"+l.host}catch(e){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=w.param(h.data,h.traditional)),_t(It,h,n,E),c)return E;(f=w.event&&h.global)&&0==w.active++&&w.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!Mt.test(h.type),o=h.url.replace(Lt,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(qt,"+")):(d=h.url.slice(o.length),h.data&&(h.processData||"string"==typeof h.data)&&(o+=(kt.test(o)?"&":"?")+h.data,delete h.data),!1===h.cache&&(o=o.replace(Ht,"$1"),d=(kt.test(o)?"&":"?")+"_="+Et+++d),h.url=o+d),h.ifModified&&(w.lastModified[o]&&E.setRequestHeader("If-Modified-Since",w.lastModified[o]),w.etag[o]&&E.setRequestHeader("If-None-Match",w.etag[o])),(h.data&&h.hasContent&&!1!==h.contentType||n.contentType)&&E.setRequestHeader("Content-Type",h.contentType),E.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+$t+"; q=0.01":""):h.accepts["*"]);for(p in h.headers)E.setRequestHeader(p,h.headers[p]);if(h.beforeSend&&(!1===h.beforeSend.call(g,E,h)||c))return E.abort();if(C="abort",m.add(h.complete),E.done(h.success),E.fail(h.error),i=_t(Wt,h,n,E)){if(E.readyState=1,f&&y.trigger("ajaxSend",[E,h]),c)return E;h.async&&h.timeout>0&&(u=e.setTimeout(function(){E.abort("timeout")},h.timeout));try{c=!1,i.send(b,k)}catch(e){if(c)throw e;k(-1,e)}}else k(-1,"No Transport");function k(t,n,r,s){var l,p,d,b,T,C=n;c||(c=!0,u&&e.clearTimeout(u),i=void 0,a=s||"",E.readyState=t>0?4:0,l=t>=200&&t<300||304===t,r&&(b=Xt(h,E,r)),b=Ut(h,b,E,l),l?(h.ifModified&&((T=E.getResponseHeader("Last-Modified"))&&(w.lastModified[o]=T),(T=E.getResponseHeader("etag"))&&(w.etag[o]=T)),204===t||"HEAD"===h.type?C="nocontent":304===t?C="notmodified":(C=b.state,p=b.data,l=!(d=b.error))):(d=C,!t&&C||(C="error",t<0&&(t=0))),E.status=t,E.statusText=(n||C)+"",l?v.resolveWith(g,[p,C,E]):v.rejectWith(g,[E,C,d]),E.statusCode(x),x=void 0,f&&y.trigger(l?"ajaxSuccess":"ajaxError",[E,h,l?p:d]),m.fireWith(g,[E,C]),f&&(y.trigger("ajaxComplete",[E,h]),--w.active||w.event.trigger("ajaxStop")))}return E},getJSON:function(e,t,n){return w.get(e,t,n,"json")},getScript:function(e,t){return w.get(e,void 0,t,"script")}}),w.each(["get","post"],function(e,t){w[t]=function(e,n,r,i){return g(n)&&(i=i||r,r=n,n=void 0),w.ajax(w.extend({url:e,type:t,dataType:i,data:n,success:r},w.isPlainObject(e)&&e))}}),w._evalUrl=function(e){return w.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},w.fn.extend({wrapAll:function(e){var t;return this[0]&&(g(e)&&(e=e.call(this[0])),t=w(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return g(e)?this.each(function(t){w(this).wrapInner(e.call(this,t))}):this.each(function(){var t=w(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=g(e);return this.each(function(n){w(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){w(this).replaceWith(this.childNodes)}),this}}),w.expr.pseudos.hidden=function(e){return!w.expr.pseudos.visible(e)},w.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},w.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var Vt={0:200,1223:204},Gt=w.ajaxSettings.xhr();h.cors=!!Gt&&"withCredentials"in Gt,h.ajax=Gt=!!Gt,w.ajaxTransport(function(t){var n,r;if(h.cors||Gt&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");for(a in i)s.setRequestHeader(a,i[a]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,"abort"===e?s.abort():"error"===e?"number"!=typeof s.status?o(0,"error"):o(s.status,s.statusText):o(Vt[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=s.ontimeout=n("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout(function(){n&&r()})},n=n("abort");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}}),w.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),w.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return w.globalEval(e),e}}}),w.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),w.ajaxTransport("script",function(e){if(e.crossDomain){var t,n;return{send:function(i,o){t=w(" +{% endif %} +{% block tracking_events %}{% endblock %} + + +{% block extra_js %}{% endblock %} + + diff --git a/site/client_site/templates/portal_base.html b/site/client_site/templates/portal_base.html new file mode 100644 index 0000000..f432ab7 --- /dev/null +++ b/site/client_site/templates/portal_base.html @@ -0,0 +1,71 @@ +{% load static %} + + + + + + {% block title %}Portal · {{ SITE_NAME }}{% endblock %} + + + + + {% block extra_head %}{% endblock %} + + +{% if tianji_enabled %} + +{% endif %} +{% block body %} +
+ +
+
+

{% block topbar_title %}Portal{% endblock %}

+
+ Signed in as {{ user.get_username }} + · +
+ {% csrf_token %} + +
+
+
+
+ {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% block portal_content %}{% endblock %} +
+
+
+{% endblock %} +{% if tianji_enabled %} + +{% endif %} +{% block tracking_events %}{% endblock %} +{% block extra_js %}{% endblock %} + + diff --git a/site/client_site/urls.py b/site/client_site/urls.py new file mode 100644 index 0000000..5f4de61 --- /dev/null +++ b/site/client_site/urls.py @@ -0,0 +1,44 @@ +"""URL configuration. Optional apps are included only when installed.""" + +from django.apps import apps +from django.contrib import admin +from django.urls import include, path + +from contacts.views import address_suggest +from core.views import healthz + +urlpatterns = [ + path("healthz/", healthz, name="healthz"), + path("api/address-suggest/", address_suggest, name="address_suggest"), + path("files/", include("core.urls")), + path("admin/", admin.site.urls), + path("accounts/", include("accounts.urls")), + path("portal/", include("dashboard.urls")), + path("portal/leads/", include("leads.urls")), + path("portal/contacts/", include("contacts.urls")), + path("portal/analytics/", include("analytics.urls")), + path("", include("public.urls")), +] + +if apps.is_installed("email_sms"): + urlpatterns += [path("portal/messaging/", include("email_sms.urls"))] +if apps.is_installed("directmail"): + urlpatterns += [path("portal/direct-mail/", include("directmail.urls"))] +if apps.is_installed("blog"): + urlpatterns += [ + path("blog/", include("blog.public_urls")), + path("portal/blog/", include("blog.portal_urls")), + ] +if apps.is_installed("payments"): + urlpatterns += [ + path("portal/payments/", include("payments.urls")), + path("pay/", include("payments.public_urls")), + ] +if apps.is_installed("social"): + urlpatterns += [path("portal/social/", include("social.urls"))] +if apps.is_installed("social_ai"): + urlpatterns += [ + path("portal/social/api/generate/", include("social_ai.urls")), + ] + +handler404 = "public.views.page_not_found" diff --git a/site/client_site/wsgi.py b/site/client_site/wsgi.py new file mode 100644 index 0000000..2d84398 --- /dev/null +++ b/site/client_site/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI config for client_site.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings") + +application = get_wsgi_application() diff --git a/site/contacts/__init__.py b/site/contacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/contacts/admin.py b/site/contacts/admin.py new file mode 100644 index 0000000..5511f7a --- /dev/null +++ b/site/contacts/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from contacts.models import ConsentRecord, Contact, Suppression + + +class ConsentInline(admin.TabularInline): + model = ConsentRecord + extra = 0 + + +class SuppressionInline(admin.TabularInline): + model = Suppression + extra = 0 + + +@admin.register(Contact) +class ContactAdmin(admin.ModelAdmin): + list_display = ("email", "first_name", "last_name", "phone", "source", "created_at") + search_fields = ("email", "first_name", "last_name", "phone") + list_filter = ("source",) + fields = ( + "email", + "first_name", + "last_name", + "phone", + "postal_address", + "source", + "notes", + "created_at", + "updated_at", + ) + readonly_fields = ("created_at", "updated_at") + inlines = [ConsentInline, SuppressionInline] diff --git a/site/contacts/apps.py b/site/contacts/apps.py new file mode 100644 index 0000000..73115ae --- /dev/null +++ b/site/contacts/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig + + +class ContactsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "contacts" + + def ready(self): + from core.registry import register_portal_nav + + register_portal_nav( + section="contacts", + label="Contacts", + url_name="contacts:list", + group="Outreach", + order=10, + ) diff --git a/site/contacts/consent.py b/site/contacts/consent.py new file mode 100644 index 0000000..5626bfd --- /dev/null +++ b/site/contacts/consent.py @@ -0,0 +1,179 @@ +"""Consent, suppression, and unsubscribe helpers (always-on).""" + +from __future__ import annotations + +from django.core import signing +from django.db.models import QuerySet +from django.urls import reverse + +from contacts.models import Channel, ConsentRecord, Contact, Suppression + +UNSUB_SALT = "client-site-unsubscribe" +UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year + + +def contact_may_receive(contact: Contact, channel: str) -> bool: + if Suppression.objects.filter( + contact=contact, channel=channel, active=True + ).exists(): + return False + consent = ConsentRecord.objects.filter(contact=contact, channel=channel).first() + if channel == Channel.POSTCARD: + if consent is None: + return Contact.postal_address_has_content(contact.postal_address) + return bool(consent.opted_in) + return bool(consent and consent.opted_in) + + +def channel_preferences(contact: Contact) -> dict[str, bool]: + """Current opt-in flags for every channel (missing record = False). + + Postcard: missing consent + postal address → shown as opted in (default). + """ + flags = {c.value: False for c in Channel} + seen: set[str] = set() + for record in contact.consents.all(): + flags[record.channel] = record.opted_in + seen.add(record.channel) + if ( + Channel.POSTCARD not in seen + and Contact.postal_address_has_content(contact.postal_address) + ): + flags[Channel.POSTCARD] = True + return flags + + +def set_channel_consent( + contact: Contact, + channel: str, + *, + opted_in: bool, + reason: str = "", +) -> None: + if channel not in Channel.values: + raise ValueError(f"Unknown channel: {channel}") + ConsentRecord.objects.update_or_create( + contact=contact, + channel=channel, + defaults={"opted_in": opted_in, "reason": reason}, + ) + Suppression.objects.update_or_create( + contact=contact, + channel=channel, + defaults={ + "active": not opted_in, + "reason": reason if not opted_in else "", + }, + ) + + +def set_channel_preferences( + contact: Contact, + preferences: dict[str, bool], + *, + reason: str = "", +) -> None: + for channel, opted_in in preferences.items(): + if channel not in Channel.values: + continue + set_channel_consent( + contact, channel, opted_in=bool(opted_in), reason=reason + ) + + +def unsubscribe_all(contact: Contact, *, reason: str = "unsubscribe_all") -> None: + for channel in Channel: + set_channel_consent( + contact, channel.value, opted_in=False, reason=reason + ) + + +def make_unsubscribe_token(contact_id: str, channel: str = Channel.EMAIL) -> str: + return signing.dumps({"c": str(contact_id), "ch": channel}, salt=UNSUB_SALT) + + +def parse_unsubscribe_token(token: str) -> tuple[Contact | None, str]: + try: + data = signing.loads(token, salt=UNSUB_SALT, max_age=UNSUB_MAX_AGE) + except signing.BadSignature: + return None, "" + contact = ( + Contact.objects.filter(pk=data.get("c")) + .prefetch_related("consents") + .first() + ) + if not contact: + return None, "" + channel = data.get("ch") or Channel.EMAIL + if channel not in Channel.values: + channel = Channel.EMAIL + return contact, channel + + +def process_unsubscribe_token(token: str) -> bool: + contact, channel = parse_unsubscribe_token(token) + if not contact: + return False + set_channel_consent( + contact, channel, opted_in=False, reason="unsubscribe_link" + ) + return True + + +def preferences_url(contact_id: str, channel: str = Channel.EMAIL) -> str: + token = make_unsubscribe_token(contact_id, channel) + return reverse("public:unsubscribe", kwargs={"token": token}) + + +def one_click_unsubscribe_url(contact_id: str, channel: str = Channel.EMAIL) -> str: + token = make_unsubscribe_token(contact_id, channel) + return reverse("public:unsubscribe_one_click", kwargs={"token": token}) + + +def record_sms_stop(phone: str) -> bool: + digits = "".join(ch for ch in (phone or "") if ch.isdigit()) + if len(digits) < 7: + return False + tail = digits[-10:] + contact = None + for row in Contact.objects.exclude(phone="").iterator(): + stored = "".join(ch for ch in row.phone if ch.isdigit()) + if stored.endswith(tail) or tail.endswith(stored[-10:]): + contact = row + break + if not contact: + return False + set_channel_consent( + contact, Channel.SMS, opted_in=False, reason="sms_stop" + ) + return True + + +def opted_in_contacts(channel: str) -> QuerySet[Contact]: + """Contacts opted in for channel and not actively suppressed.""" + suppressed = Suppression.objects.filter( + channel=channel, active=True + ).values_list("contact_id", flat=True) + + if channel == Channel.POSTCARD: + with_address = Contact.objects.filter( + postal_address__has_key="line1", + ).exclude(postal_address__line1="") + explicit = with_address.filter( + consents__channel=Channel.POSTCARD, + consents__opted_in=True, + ) + implicit = with_address.exclude(consents__channel=Channel.POSTCARD) + qs = (explicit | implicit).exclude(pk__in=suppressed).distinct() + return qs.order_by("first_name", "last_name", "email") + + qs = ( + Contact.objects.filter( + consents__channel=channel, + consents__opted_in=True, + ) + .exclude(pk__in=suppressed) + .distinct() + .order_by("first_name", "last_name", "email") + ) + return qs diff --git a/site/contacts/migrations/0001_initial.py b/site/contacts/migrations/0001_initial.py new file mode 100644 index 0000000..2e6893e --- /dev/null +++ b/site/contacts/migrations/0001_initial.py @@ -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 = [ + ] + + operations = [ + migrations.CreateModel( + name='Contact', + 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)), + ('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)), + ('phone', models.CharField(blank=True, max_length=32)), + ('first_name', models.CharField(blank=True, max_length=100)), + ('last_name', models.CharField(blank=True, max_length=100)), + ('postal_address', models.JSONField(blank=True, default=dict)), + ('source', models.CharField(choices=[('contact_form', 'Contact form'), ('import', 'Import'), ('manual', 'Manual'), ('notify_me', 'Notify me'), ('other', 'Other')], default='other', max_length=32)), + ('notes', models.TextField(blank=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ConsentRecord', + 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)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('opted_in', models.BooleanField(default=False)), + ('changed_at', models.DateTimeField(auto_now=True)), + ('reason', models.CharField(blank=True, max_length=255)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='contacts.contact')), + ], + options={ + 'ordering': ['-changed_at'], + 'unique_together': {('contact', 'channel')}, + }, + ), + migrations.CreateModel( + name='Suppression', + 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)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('reason', models.CharField(blank=True, max_length=255)), + ('active', models.BooleanField(default=True)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suppressions', to='contacts.contact')), + ], + options={ + 'unique_together': {('contact', 'channel')}, + }, + ), + ] diff --git a/site/contacts/migrations/__init__.py b/site/contacts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/contacts/models.py b/site/contacts/models.py new file mode 100644 index 0000000..fb949d5 --- /dev/null +++ b/site/contacts/models.py @@ -0,0 +1,101 @@ +from django.db import models + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class Channel(models.TextChoices): + EMAIL = "email", "Email" + SMS = "sms", "SMS" + POSTCARD = "postcard", "Postcard" + + +class Contact(UUIDPrimaryKeyModel, TimeStampedModel): + class Source(models.TextChoices): + CONTACT_FORM = "contact_form", "Contact form" + IMPORT = "import", "Import" + MANUAL = "manual", "Manual" + NOTIFY_ME = "notify_me", "Notify me" + OTHER = "other", "Other" + + email = models.EmailField(unique=True, blank=True, null=True) + phone = models.CharField(max_length=32, blank=True) + first_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) + postal_address = models.JSONField(default=dict, blank=True) + source = models.CharField( + max_length=32, choices=Source.choices, default=Source.OTHER + ) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + name = f"{self.first_name} {self.last_name}".strip() + return name or self.email or self.phone or str(self.pk) + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}".strip() + + @staticmethod + def make_postal_address( + *, + line1: str = "", + line2: str = "", + city: str = "", + state: str = "", + zip_code: str = "", + country: str = "US", + ) -> dict: + """Normalize Lob-shaped postal address dict.""" + return { + "line1": (line1 or "").strip(), + "line2": (line2 or "").strip(), + "city": (city or "").strip(), + "state": (state or "").strip(), + "zip": (zip_code or "").strip(), + "country": ((country or "").strip() or "US"), + } + + @staticmethod + def postal_address_has_content(addr: dict | None) -> bool: + if not addr: + return False + return any( + (addr.get(key) or "").strip() + for key in ("line1", "line2", "city", "state", "zip") + ) + + +class ConsentRecord(TimeStampedModel): + contact = models.ForeignKey( + Contact, on_delete=models.CASCADE, related_name="consents" + ) + channel = models.CharField(max_length=16, choices=Channel.choices) + opted_in = models.BooleanField(default=False) + changed_at = models.DateTimeField(auto_now=True) + reason = models.CharField(max_length=255, blank=True) + + class Meta: + unique_together = ("contact", "channel") + ordering = ["-changed_at"] + + def __str__(self) -> str: + state = "in" if self.opted_in else "out" + return f"{self.contact} {self.channel} opt-{state}" + + +class Suppression(TimeStampedModel): + contact = models.ForeignKey( + Contact, on_delete=models.CASCADE, related_name="suppressions" + ) + channel = models.CharField(max_length=16, choices=Channel.choices) + reason = models.CharField(max_length=255, blank=True) + active = models.BooleanField(default=True) + + class Meta: + unique_together = ("contact", "channel") + + def __str__(self) -> str: + return f"suppress {self.contact} {self.channel}" diff --git a/site/contacts/nominatim.py b/site/contacts/nominatim.py new file mode 100644 index 0000000..b614493 --- /dev/null +++ b/site/contacts/nominatim.py @@ -0,0 +1,214 @@ +"""Nominatim client — server-side only; browsers never call Nominatim directly.""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +# ISO3166-2-lvl4 "US-OH" → "OH"; fall back to common full-name map. +_US_STATE_ABBREV = { + "alabama": "AL", + "alaska": "AK", + "arizona": "AZ", + "arkansas": "AR", + "california": "CA", + "colorado": "CO", + "connecticut": "CT", + "delaware": "DE", + "district of columbia": "DC", + "florida": "FL", + "georgia": "GA", + "hawaii": "HI", + "idaho": "ID", + "illinois": "IL", + "indiana": "IN", + "iowa": "IA", + "kansas": "KS", + "kentucky": "KY", + "louisiana": "LA", + "maine": "ME", + "maryland": "MD", + "massachusetts": "MA", + "michigan": "MI", + "minnesota": "MN", + "mississippi": "MS", + "missouri": "MO", + "montana": "MT", + "nebraska": "NE", + "nevada": "NV", + "new hampshire": "NH", + "new jersey": "NJ", + "new mexico": "NM", + "new york": "NY", + "north carolina": "NC", + "north dakota": "ND", + "ohio": "OH", + "oklahoma": "OK", + "oregon": "OR", + "pennsylvania": "PA", + "rhode island": "RI", + "south carolina": "SC", + "south dakota": "SD", + "tennessee": "TN", + "texas": "TX", + "utah": "UT", + "vermont": "VT", + "virginia": "VA", + "washington": "WA", + "west virginia": "WV", + "wisconsin": "WI", + "wyoming": "WY", +} + + +class NominatimError(RuntimeError): + pass + + +# Leading house / unit number from user query (e.g. "1968", "12A", "100-102"). +_HOUSE_FROM_QUERY = re.compile(r"^(\d+[A-Za-z]?(?:-\d+[A-Za-z]?)?)\b") + + +def _house_from_query(query: str) -> str: + match = _HOUSE_FROM_QUERY.match((query or "").strip()) + return match.group(1) if match else "" + + +def _state_code(addr: dict[str, Any]) -> str: + iso = (addr.get("ISO3166-2-lvl4") or "").strip() + if iso.startswith("US-") and len(iso) == 5: + return iso[3:] + raw = (addr.get("state") or "").strip() + if len(raw) == 2: + return raw.upper() + return _US_STATE_ABBREV.get(raw.lower(), raw) + + +def _city(addr: dict[str, Any]) -> str: + for key in ("city", "town", "village", "hamlet", "municipality", "suburb"): + val = (addr.get(key) or "").strip() + if val: + return val + return "" + + +def _line1(addr: dict[str, Any], display_name: str, *, query: str = "") -> str: + house = (addr.get("house_number") or "").strip() + road = (addr.get("road") or addr.get("pedestrian") or "").strip() + # Nominatim often returns road-level hits with no house_number even when the + # user typed one — keep that number so mailing street isn't incomplete. + if not house: + house = _house_from_query(query) + if house and road: + return f"{house} {road}" + if road: + return road + # Place-level hits (city only) — leave street empty for the user to fill. + if house or road: + return " ".join(p for p in (house, road) if p) + first = (display_name or "").split(",")[0].strip() + # Avoid stuffing "Akron" into street when it's a city result. + if first and first.lower() != _city(addr).lower(): + return first + return "" + + +def normalize_hit(raw: dict[str, Any], *, query: str = "") -> dict[str, str]: + addr = raw.get("address") or {} + if not isinstance(addr, dict): + addr = {} + country_code = (addr.get("country_code") or "us").upper() + if country_code == "US": + country = "US" + else: + country = country_code[:2] or "US" + display = (raw.get("display_name") or "").strip() + line1 = _line1(addr, display, query=query) + label = display + # Surface recovered house number in the dropdown when OSM omitted it. + house = (addr.get("house_number") or "").strip() or _house_from_query(query) + if house and label and not re.match(rf"^{re.escape(house)}\b", label, re.I): + label = f"{house} {label}" + return { + "label": label, + "line1": line1, + "line2": "", + "city": _city(addr), + "state": _state_code(addr), + "zip": (addr.get("postcode") or "").strip().split(";")[0].strip(), + "country": country, + } + + +def suggest_addresses(query: str, *, limit: int = 5) -> list[dict[str, str]]: + """ + Proxy Nominatim /search. Returns normalized address dicts for the UI. + + Nominatim itself has no API-key auth — LAN firewall + this Django proxy + gate access. Optional NOMINATIM_API_KEY is sent as X-API-Key if you put + a gateway in front of Nominatim later. + """ + base = (settings.NOMINATIM_BASE_URL or "").rstrip("/") + if not base: + raise NominatimError("NOMINATIM_BASE_URL is not configured") + + q = (query or "").strip() + if len(q) < 3: + return [] + + limit = max(1, min(int(limit or 5), 8)) + params: dict[str, str | int] = { + "q": q, + "format": "json", + "addressdetails": 1, + "limit": limit, + } + countrycodes = (settings.NOMINATIM_COUNTRY_CODES or "").strip() + if countrycodes: + params["countrycodes"] = countrycodes + + headers = { + "User-Agent": settings.NOMINATIM_USER_AGENT, + "Accept": "application/json", + } + api_key = (settings.NOMINATIM_API_KEY or "").strip() + if api_key: + headers["X-API-Key"] = api_key + + url = f"{base}/search" + try: + response = requests.get( + url, + params=params, + headers=headers, + timeout=settings.NOMINATIM_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + except requests.RequestException as exc: + logger.exception("Nominatim request failed") + raise NominatimError(f"Nominatim unreachable at {url}: {exc}") from exc + except ValueError as exc: + raise NominatimError("Nominatim returned invalid JSON") from exc + + if not isinstance(payload, list): + return [] + + results: list[dict[str, str]] = [] + seen: set[str] = set() + for item in payload: + if not isinstance(item, dict): + continue + normalized = normalize_hit(item, query=q) + key = re.sub(r"\s+", " ", normalized["label"].lower()) + if not key or key in seen: + continue + seen.add(key) + results.append(normalized) + return results diff --git a/site/contacts/services.py b/site/contacts/services.py new file mode 100644 index 0000000..8d290db --- /dev/null +++ b/site/contacts/services.py @@ -0,0 +1,239 @@ +"""Contact identity matching and merge helpers.""" + +from __future__ import annotations + +import re + +from contacts.models import Contact + +_PHONE_MIN_DIGITS = 10 + + +def normalize_phone_digits(value: str) -> str: + return "".join(ch for ch in (value or "") if ch.isdigit()) + + +def phones_match(a: str, b: str) -> bool: + """True when both phones have ≥10 digits and last-10 match.""" + da = normalize_phone_digits(a) + db = normalize_phone_digits(b) + if len(da) < _PHONE_MIN_DIGITS or len(db) < _PHONE_MIN_DIGITS: + return False + return da[-10:] == db[-10:] + + +def _norm_addr_part(value: str) -> str: + text = (value or "").strip().lower() + text = re.sub(r"[.,#]", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def addresses_match(a: dict | None, b: dict | None) -> bool: + """ + Strong postal match: same street line1 + ZIP, or line1 + city + state. + + Empty / partial addresses never match. + """ + if not a or not b: + return False + line1_a = _norm_addr_part(a.get("line1") or "") + line1_b = _norm_addr_part(b.get("line1") or "") + if not line1_a or not line1_b or line1_a != line1_b: + return False + + zip_a = _norm_addr_part(a.get("zip") or "") + zip_b = _norm_addr_part(b.get("zip") or "") + if zip_a and zip_b: + return zip_a[:5] == zip_b[:5] + + city_a = _norm_addr_part(a.get("city") or "") + city_b = _norm_addr_part(b.get("city") or "") + state_a = _norm_addr_part(a.get("state") or "") + state_b = _norm_addr_part(b.get("state") or "") + if city_a and city_b and state_a and state_b: + return city_a == city_b and state_a == state_b + return False + + +def find_matching_contact( + *, + email: str = "", + phone: str = "", + postal_address: dict | None = None, + match_email: bool = True, + match_phone: bool = True, + match_address: bool = True, +) -> tuple[Contact | None, str]: + """ + Resolve an existing contact. + + Priority: email → phone (last 10) → mailing address. + Returns (contact, reason) where reason is email|phone|address|"". + """ + email_norm = (email or "").strip().lower() + if match_email and email_norm: + hit = Contact.objects.filter(email__iexact=email_norm).first() + if hit: + return hit, "email" + + if match_phone: + phone_digits = normalize_phone_digits(phone) + if len(phone_digits) >= _PHONE_MIN_DIGITS: + tail = phone_digits[-10:] + for row in Contact.objects.exclude(phone="").only("id", "phone").iterator(): + other = normalize_phone_digits(row.phone) + if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail: + return Contact.objects.get(pk=row.pk), "phone" + + if match_address and Contact.postal_address_has_content(postal_address): + line1 = (postal_address.get("line1") or "").strip() + zip_code = (postal_address.get("zip") or "").strip() + candidates = Contact.objects.exclude(postal_address={}) + if zip_code: + candidates = candidates.filter( + postal_address__zip__istartswith=zip_code[:5] + ) + elif line1: + candidates = candidates.filter(postal_address__line1__iexact=line1) + for row in candidates.iterator(): + if addresses_match(postal_address, row.postal_address): + return row, "address" + + return None, "" + + +def _merge_notes(existing: str, addition: str) -> str: + existing = (existing or "").strip() + addition = (addition or "").strip() + if not addition: + return existing + if not existing: + return addition + if addition in existing: + return existing + return f"{existing}\n{addition}".strip() + + +def _apply_contact_fields( + existing: Contact, + *, + email_norm: str, + first_name: str, + last_name: str, + phone: str, + postal: dict, + has_postal: bool, + source: str, + notes_append: str, +) -> None: + changed: list[str] = [] + if first_name and first_name.strip(): + existing.first_name = first_name.strip() + changed.append("first_name") + if last_name is not None and str(last_name).strip() != "": + existing.last_name = last_name.strip() + changed.append("last_name") + if phone: + existing.phone = phone + changed.append("phone") + if has_postal: + existing.postal_address = postal + changed.append("postal_address") + + existing_email = (existing.email or "").strip().lower() + if email_norm and email_norm != existing_email: + if not existing_email: + taken = ( + Contact.objects.filter(email__iexact=email_norm) + .exclude(pk=existing.pk) + .exists() + ) + if not taken: + existing.email = email_norm + changed.append("email") + else: + existing.notes = _merge_notes( + existing.notes, + f"Alternate email from form (owned elsewhere): {email_norm}", + ) + changed.append("notes") + else: + existing.notes = _merge_notes( + existing.notes, + f"Alternate email from form: {email_norm}", + ) + changed.append("notes") + + if notes_append: + existing.notes = _merge_notes(existing.notes, notes_append) + changed.append("notes") + + if source and existing.source == Contact.Source.OTHER: + existing.source = source + changed.append("source") + + if changed: + fields = sorted(set(changed) | {"updated_at"}) + existing.save(update_fields=fields) + + +def upsert_contact( + *, + email: str, + first_name: str = "", + last_name: str = "", + phone: str = "", + postal_address: dict | None = None, + source: str = Contact.Source.CONTACT_FORM, + notes_append: str = "", + merge_phone_address: bool = True, + merge_into: Contact | None = None, +) -> tuple[Contact, bool, str]: + """ + Find or create a contact, merging on email / phone / address. + + Returns (contact, created, match_reason). + + ``merge_phone_address=False`` only merges on exact email. + ``merge_into`` forces merge into that contact row. + """ + email_norm = (email or "").strip().lower() + phone = (phone or "").strip() + postal = postal_address if isinstance(postal_address, dict) else {} + has_postal = Contact.postal_address_has_content(postal) + + if merge_into is not None: + existing, reason = merge_into, "manual" + else: + existing, reason = find_matching_contact( + email=email_norm, + phone=phone, + postal_address=postal if has_postal else None, + match_phone=merge_phone_address, + match_address=merge_phone_address, + ) + + if existing is None: + contact = Contact.objects.create( + email=email_norm or None, + first_name=(first_name or "").strip(), + last_name=(last_name or "").strip(), + phone=phone, + postal_address=postal if has_postal else {}, + source=source, + notes=(notes_append or "").strip(), + ) + return contact, True, "" + + _apply_contact_fields( + existing, + email_norm=email_norm, + first_name=first_name, + last_name=last_name, + phone=phone, + postal=postal, + has_postal=has_postal, + source=source, + notes_append=notes_append, + ) + return existing, False, reason diff --git a/site/contacts/templates/contacts/create.html b/site/contacts/templates/contacts/create.html new file mode 100644 index 0000000..d95f161 --- /dev/null +++ b/site/contacts/templates/contacts/create.html @@ -0,0 +1,175 @@ +{% extends "portal_base.html" %} +{% load static %} +{% block title %}New contact · Portal{% endblock %} +{% block topbar_title %}New contact{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} + + +
+
+

Profile

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ + Cancel +
+
+
+
+

Consent

+
+
+ +
+
+ +
+
+ +
+

+ Same email always updates that contact. Same phone or address asks whether to update or create new. +

+
+
+
+
+ +{% if match_prompt %} + +{% endif %} +{% endblock %} +{% block extra_js %} + +{% if match_prompt %} + +{% endif %} +{% endblock %} diff --git a/site/contacts/templates/contacts/detail.html b/site/contacts/templates/contacts/detail.html new file mode 100644 index 0000000..7039f20 --- /dev/null +++ b/site/contacts/templates/contacts/detail.html @@ -0,0 +1,97 @@ +{% extends "portal_base.html" %} +{% load static %} +{% block title %}{{ contact }} · Contact{% endblock %} +{% block topbar_title %}Contact · {{ contact }}{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} +
+
+

Profile

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+ + ← Mailing list +
+
+
+
+

Consent

+
+
+ +
+
+ +
+
+ +
+

Postcard mailings default on when a street address is on file. Uncheck to opt out. Opt-outs also write a suppression so campaigns skip this contact.

+
+
+
+
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/contacts/templates/contacts/import.html b/site/contacts/templates/contacts/import.html new file mode 100644 index 0000000..15f9d08 --- /dev/null +++ b/site/contacts/templates/contacts/import.html @@ -0,0 +1,64 @@ +{% extends "portal_base.html" %} +{% block title %}Import contacts · Portal{% endblock %} +{% block topbar_title %}Import contacts{% endblock %} +{% block portal_content %} +
+
1 Upload
+
2 Map columns
+
3 Consent
+
4 Import
+
+ +
+
+
+

Upload

+
+
+

Drop CSV or Excel here

+

Import processing wires up next. Accepted: .csv, .xlsx

+

+
+
+
+
+

Column mapping

+
+ + + + + + + + + + + + +
Your columnMaps to
Emailemail
Firstfirst_name
Lastlast_name
Phonephone
Streetpostal_address.line1
Citypostal_address.city
Statepostal_address.state
ZIPpostal_address.zip
+
+
+
+
+
+

Consent defaults

+
+ + + +
+
+
+
+
+

Preview

+
+

Sample rows appear after upload.

+ +

← Back to mailing list

+
+
+
+
+{% endblock %} diff --git a/site/contacts/templates/contacts/list.html b/site/contacts/templates/contacts/list.html new file mode 100644 index 0000000..a40eb65 --- /dev/null +++ b/site/contacts/templates/contacts/list.html @@ -0,0 +1,68 @@ +{% extends "portal_base.html" %} +{% block title %}Mailing list · Portal{% endblock %} +{% block topbar_title %}Mailing list{% endblock %} +{% block portal_content %} +
+
+ + +
+ +
+ +
+
+ + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + {% empty %} + + {% endfor %} + +
ContactAddressConsentSource
+ {{ contact }}
+ + {% if contact.email %}{{ contact.email }}{% endif %} + {% if contact.email and contact.phone %} · {% endif %} + {% if contact.phone %}{{ contact.phone }}{% endif %} + +
+ {% if contact.postal_address.line1 %} + {{ contact.postal_address.line1 }}{% if contact.postal_address.city %}, {{ contact.postal_address.city }}{% endif %} + {% else %} + — + {% endif %} + + {% with c=contact.consent_flags %} + E + S + P + {% endwith %} + {{ contact.get_source_display }}
No contacts yet.
+
+
+

+ E = email · S = SMS · P = postcard. + Import contacts for bulk CSV/Excel. +

+{% endblock %} diff --git a/site/contacts/tests_merge.py b/site/contacts/tests_merge.py new file mode 100644 index 0000000..86b35a7 --- /dev/null +++ b/site/contacts/tests_merge.py @@ -0,0 +1,221 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from contacts.models import Contact +from contacts.services import ( + addresses_match, + find_matching_contact, + phones_match, + upsert_contact, +) +from leads.models import Lead + + +class ContactMatchHelpersTests(TestCase): + def test_phones_match_ignores_formatting(self): + self.assertTrue(phones_match("(630) 452-4443", "+1-630-452-4443")) + self.assertFalse(phones_match("6304524443", "6304524444")) + self.assertFalse(phones_match("4524443", "6304524443")) # too short + + def test_addresses_match_by_line1_and_zip(self): + a = Contact.make_postal_address( + line1="123 Main St.", + city="Naperville", + state="IL", + zip_code="60540-1234", + ) + b = Contact.make_postal_address( + line1="123 Main St", + city="Elsewhere", + state="IL", + zip_code="60540", + ) + self.assertTrue(addresses_match(a, b)) + + def test_find_by_phone_then_address(self): + existing = Contact.objects.create( + email="one@example.com", + phone="6305551212", + first_name="Pat", + postal_address=Contact.make_postal_address( + line1="9 Oak Ave", + city="Wheaton", + state="IL", + zip_code="60187", + ), + ) + by_phone, reason = find_matching_contact( + email="other@example.com", + phone="(630) 555-1212", + ) + self.assertEqual(by_phone, existing) + self.assertEqual(reason, "phone") + + by_addr, reason = find_matching_contact( + email="third@example.com", + phone="9995550000", + postal_address=Contact.make_postal_address( + line1="9 Oak Ave", + city="Wheaton", + state="IL", + zip_code="60187", + ), + ) + self.assertEqual(by_addr, existing) + self.assertEqual(reason, "address") + + +class UpsertContactMergeTests(TestCase): + def test_merge_by_phone_keeps_one_contact(self): + original = Contact.objects.create( + email="ryan@example.com", + phone="6305559999", + first_name="Ryan", + ) + contact, created, reason = upsert_contact( + email="ryan.alt@example.com", + first_name="Ryan", + last_name="Westfall", + phone="630-555-9999", + ) + self.assertFalse(created) + self.assertEqual(reason, "phone") + self.assertEqual(contact.pk, original.pk) + self.assertEqual(Contact.objects.count(), 1) + contact.refresh_from_db() + self.assertEqual(contact.email, "ryan@example.com") + self.assertIn("ryan.alt@example.com", contact.notes) + self.assertEqual(contact.last_name, "Westfall") + + def test_merge_by_address(self): + original = Contact.objects.create( + email="home@example.com", + first_name="Sam", + postal_address=Contact.make_postal_address( + line1="100 Lake St", + city="Naperville", + state="IL", + zip_code="60540", + ), + ) + contact, created, reason = upsert_contact( + email="new@example.com", + first_name="Sam", + postal_address=Contact.make_postal_address( + line1="100 Lake St", + city="Naperville", + state="IL", + zip_code="60540", + ), + ) + self.assertFalse(created) + self.assertEqual(reason, "address") + self.assertEqual(contact.pk, original.pk) + self.assertEqual(Contact.objects.count(), 1) + + +class ContactFormMergeTests(TestCase): + def setUp(self): + self.client = Client() + self.existing = Contact.objects.create( + email="primary@example.com", + phone="6301112222", + first_name="Alex", + postal_address=Contact.make_postal_address( + line1="55 River Rd", + city="Aurora", + state="IL", + zip_code="60505", + ), + ) + + def test_contact_form_merges_on_phone(self): + url = reverse("public:contact") + response = self.client.post( + url, + { + "first_name": "Alex", + "last_name": "Lee", + "email": "alt@example.com", + "phone": "(630) 111-2222", + "address_line1": "55 River Rd", + "address_city": "Aurora", + "address_state": "IL", + "address_zip": "60505", + "interest": "general", + "message": "Looking to buy", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Contact.objects.count(), 1) + lead = Lead.objects.get() + self.assertEqual(lead.contact_id, self.existing.pk) + self.assertIn("alt@example.com", lead.message) + self.assertIn("merged by phone", lead.message) + + +class PortalCreateMatchPromptTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="adder", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="adder", password="test-pass-123") + self.existing = Contact.objects.create( + email="primary@example.com", + phone="6301112222", + first_name="Alex", + ) + + def test_phone_match_shows_prompt(self): + url = reverse("contacts:create") + response = self.client.post( + url, + { + "first_name": "Alex", + "email": "alt@example.com", + "phone": "(630) 111-2222", + "consent_email": "1", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Possible duplicate") + self.assertContains(response, "Update existing") + self.assertEqual(Contact.objects.count(), 1) + + def test_choose_create_new_keeps_both(self): + url = reverse("contacts:create") + response = self.client.post( + url, + { + "first_name": "Alex", + "email": "alt@example.com", + "phone": "(630) 111-2222", + "consent_email": "1", + "resolve_match": "create", + "match_id": str(self.existing.pk), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Contact.objects.count(), 2) + + def test_choose_update_merges(self): + url = reverse("contacts:create") + response = self.client.post( + url, + { + "first_name": "Alexander", + "email": "alt@example.com", + "phone": "(630) 111-2222", + "consent_email": "1", + "resolve_match": "update", + "match_id": str(self.existing.pk), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(Contact.objects.count(), 1) + self.existing.refresh_from_db() + self.assertEqual(self.existing.first_name, "Alexander") + self.assertIn("alt@example.com", self.existing.notes) diff --git a/site/contacts/tests_nominatim.py b/site/contacts/tests_nominatim.py new file mode 100644 index 0000000..baee710 --- /dev/null +++ b/site/contacts/tests_nominatim.py @@ -0,0 +1,42 @@ +from contacts.nominatim import normalize_hit + + +def test_line1_keeps_house_number_from_query_when_nominatim_omits_it(): + raw = { + "display_name": ( + "Greensboro Drive, Wheaton, DuPage County, Illinois, 60189, United States" + ), + "address": { + "road": "Greensboro Drive", + "town": "Wheaton", + "county": "DuPage County", + "state": "Illinois", + "postcode": "60189", + "country_code": "us", + "ISO3166-2-lvl4": "US-IL", + }, + } + hit = normalize_hit(raw, query="1968 Greensboro Drive, Wheaton") + assert hit["line1"] == "1968 Greensboro Drive" + assert hit["label"].startswith("1968 Greensboro Drive") + assert hit["city"] == "Wheaton" + assert hit["state"] == "IL" + assert hit["zip"] == "60189" + + +def test_line1_prefers_nominatim_house_number(): + raw = { + "display_name": "1968 Greensboro Drive, Wheaton, Illinois, 60189, United States", + "address": { + "house_number": "1968", + "road": "Greensboro Drive", + "town": "Wheaton", + "state": "Illinois", + "postcode": "60189", + "country_code": "us", + "ISO3166-2-lvl4": "US-IL", + }, + } + hit = normalize_hit(raw, query="1968 Greensboro Drive") + assert hit["line1"] == "1968 Greensboro Drive" + assert hit["label"] == raw["display_name"] diff --git a/site/contacts/urls.py b/site/contacts/urls.py new file mode 100644 index 0000000..e48b34c --- /dev/null +++ b/site/contacts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from contacts import views + +app_name = "contacts" + +urlpatterns = [ + path("", views.contact_list, name="list"), + path("new/", views.contact_create, name="create"), + path("import/", views.contact_import, name="import"), + path("/", views.contact_detail, name="detail"), +] diff --git a/site/contacts/views.py b/site/contacts/views.py new file mode 100644 index 0000000..922a288 --- /dev/null +++ b/site/contacts/views.py @@ -0,0 +1,274 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db.models import Prefetch, Q +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_GET, require_http_methods + +from contacts.models import Channel, ConsentRecord, Contact +from contacts.nominatim import NominatimError, suggest_addresses +from contacts.services import find_matching_contact, upsert_contact +from contacts.consent import channel_preferences, set_channel_preferences + + +def _consent_flags(contact: Contact) -> dict[str, bool]: + return channel_preferences(contact) + + +def _postal_from_post(post) -> dict: + return Contact.make_postal_address( + line1=post.get("address_line1", ""), + line2=post.get("address_line2", ""), + city=post.get("address_city", ""), + state=post.get("address_state", ""), + zip_code=post.get("address_zip", ""), + country=post.get("address_country", "US"), + ) + + +@login_required +def contact_list(request): + contacts = Contact.objects.prefetch_related( + Prefetch("consents", queryset=ConsentRecord.objects.all()) + ).all() + q = (request.GET.get("q") or "").strip() + if q: + contacts = contacts.filter( + Q(first_name__icontains=q) + | Q(last_name__icontains=q) + | Q(email__icontains=q) + | Q(phone__icontains=q) + ) + rows = list(contacts[:200]) + for contact in rows: + contact.consent_flags = _consent_flags(contact) + return render( + request, + "contacts/list.html", + {"contacts": rows, "q": q}, + ) + + +@login_required +@require_http_methods(["GET", "POST"]) +def contact_create(request): + form = { + "first_name": "", + "last_name": "", + "email": "", + "phone": "", + "address_line1": "", + "address_line2": "", + "address_city": "", + "address_state": "", + "address_zip": "", + "address_country": "US", + "notes": "", + "consent_email": True, + "consent_sms": False, + "consent_postcard": True, + } + match_prompt = None + if request.method == "POST": + for key in list(form.keys()): + if key.startswith("consent_"): + form[key] = key in request.POST + else: + form[key] = (request.POST.get(key) or "").strip() + email = form["email"].lower() + resolve = (request.POST.get("resolve_match") or "").strip() + match_id = (request.POST.get("match_id") or "").strip() + errors: list[str] = [] + if not form["first_name"]: + errors.append("First name is required.") + if not email: + errors.append("Email is required.") + else: + try: + validate_email(email) + except ValidationError: + errors.append("Enter a valid email address.") + postal = _postal_from_post(request.POST) + has_postal = Contact.postal_address_has_content(postal) + if form["consent_sms"] and not form["phone"]: + errors.append("Phone is required for SMS consent.") + if form["consent_postcard"] and not has_postal: + form["consent_postcard"] = False + + if not errors: + existing, reason = find_matching_contact( + email=email, + phone=form["phone"], + postal_address=postal if has_postal else None, + ) + # Phone/address collision (different email): ask user unless they chose. + if ( + existing + and reason in {"phone", "address"} + and resolve not in {"update", "create"} + ): + match_prompt = { + "contact": existing, + "reason": reason, + "reason_label": "phone number" + if reason == "phone" + else "mailing address", + } + else: + merge_into = None + merge_phone_address = True + if resolve == "update" and match_id: + merge_into = Contact.objects.filter(pk=match_id).first() + if merge_into is None: + errors.append("Matched contact no longer exists.") + elif resolve == "create": + merge_phone_address = False + elif reason == "email" and existing: + merge_into = existing + + if not errors: + contact, created, used_reason = upsert_contact( + email=email, + first_name=form["first_name"], + last_name=form["last_name"], + phone=form["phone"], + postal_address=postal if has_postal else None, + source=Contact.Source.MANUAL, + notes_append=form["notes"], + merge_phone_address=merge_phone_address, + merge_into=merge_into, + ) + set_channel_preferences( + contact, + { + Channel.EMAIL: form["consent_email"], + Channel.SMS: form["consent_sms"], + Channel.POSTCARD: form["consent_postcard"], + }, + reason="portal_manual", + ) + if created: + verb = "Added" + elif used_reason == "email": + verb = "Updated (same email)" + elif resolve == "update": + verb = f"Updated (matched by {reason or used_reason})" + else: + verb = f"Updated (matched by {used_reason or 'email'})" + messages.success(request, f"{verb} {contact}.") + return redirect("contacts:detail", pk=contact.pk) + for err in errors: + messages.error(request, err) + return render( + request, + "contacts/create.html", + {"form": form, "match_prompt": match_prompt}, + ) + +@login_required +@require_http_methods(["GET", "POST"]) +def contact_detail(request, pk): + contact = get_object_or_404( + Contact.objects.prefetch_related("consents"), pk=pk + ) + if request.method == "POST": + first_name = (request.POST.get("first_name") or "").strip() + last_name = (request.POST.get("last_name") or "").strip() + email = (request.POST.get("email") or "").strip().lower() + phone = (request.POST.get("phone") or "").strip() + errors: list[str] = [] + if not first_name: + errors.append("First name is required.") + if not email: + errors.append("Email is required.") + else: + try: + validate_email(email) + except ValidationError: + errors.append("Enter a valid email address.") + else: + taken = ( + Contact.objects.filter(email__iexact=email) + .exclude(pk=contact.pk) + .exists() + ) + if taken: + errors.append("Another contact already uses that email.") + if errors: + for err in errors: + messages.error(request, err) + prefs = _consent_flags(contact) + # Reflect submitted values so the user can fix them. + contact.first_name = first_name + contact.last_name = last_name + contact.email = email + contact.phone = phone + contact.postal_address = _postal_from_post(request.POST) + contact.notes = (request.POST.get("notes") or "").strip() + return render( + request, + "contacts/detail.html", + {"contact": contact, "prefs": prefs}, + ) + + contact.first_name = first_name + contact.last_name = last_name + contact.email = email + contact.phone = phone + contact.postal_address = _postal_from_post(request.POST) + contact.notes = (request.POST.get("notes") or "").strip() + contact.save( + update_fields=[ + "first_name", + "last_name", + "email", + "phone", + "postal_address", + "notes", + "updated_at", + ] + ) + set_channel_preferences( + contact, + { + Channel.EMAIL: "consent_email" in request.POST, + Channel.SMS: "consent_sms" in request.POST, + Channel.POSTCARD: "consent_postcard" in request.POST, + }, + reason="portal_manual", + ) + messages.success(request, "Contact updated.") + return redirect("contacts:detail", pk=contact.pk) + prefs = _consent_flags(contact) + return render( + request, + "contacts/detail.html", + {"contact": contact, "prefs": prefs}, + ) + + +@login_required +def contact_import(request): + return render(request, "contacts/import.html") + + +@require_GET +def address_suggest(request): + """ + Backend proxy for Nominatim search. Browser JS must call this URL only — + never Nominatim directly. + """ + q = (request.GET.get("q") or "").strip() + if len(q) < 3: + return JsonResponse({"results": []}) + try: + limit = int(request.GET.get("limit") or 5) + except (TypeError, ValueError): + limit = 5 + try: + results = suggest_addresses(q, limit=limit) + except NominatimError as exc: + return JsonResponse({"error": str(exc), "results": []}, status=502) + return JsonResponse({"results": results}) diff --git a/site/core/__init__.py b/site/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/core/admin.py b/site/core/admin.py new file mode 100644 index 0000000..71c5c0a --- /dev/null +++ b/site/core/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from core.models import StoredFile + + +@admin.register(StoredFile) +class StoredFileAdmin(admin.ModelAdmin): + list_display = ("filename", "kind", "content_type", "size", "created_at") + list_filter = ("kind", "content_type") + readonly_fields = ("size", "content_type", "created_at", "updated_at") diff --git a/site/core/apps.py b/site/core/apps.py new file mode 100644 index 0000000..4b62d1d --- /dev/null +++ b/site/core/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" + + def ready(self): + from core.registry import register_feature + + register_feature("core") diff --git a/site/core/context_processors.py b/site/core/context_processors.py new file mode 100644 index 0000000..ecc84a8 --- /dev/null +++ b/site/core/context_processors.py @@ -0,0 +1,9 @@ +from core.registry import enabled_features, portal_nav, public_nav + + +def feature_registry(request): + return { + "portal_nav": portal_nav(), + "public_nav_extra": public_nav(), + "enabled_features": enabled_features(), + } diff --git a/site/core/management/__init__.py b/site/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/core/management/commands/__init__.py b/site/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/core/management/commands/dispatch_due.py b/site/core/management/commands/dispatch_due.py new file mode 100644 index 0000000..764dc19 --- /dev/null +++ b/site/core/management/commands/dispatch_due.py @@ -0,0 +1,46 @@ +import time + +from django.core.management.base import BaseCommand + +from core.registry import run_dispatchers + + +class Command(BaseCommand): + help = ( + "Enqueue due scheduled work registered by installed apps. " + "Pass --loop to poll until stopped (worker entrypoint)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--loop", + action="store_true", + help="Poll forever until SIGINT/SIGTERM.", + ) + parser.add_argument( + "--interval", + type=int, + default=15, + help="Seconds between polls when --loop is set (default 15).", + ) + + def handle(self, *args, **options): + self.verbosity = int(options.get("verbosity", 1)) + if options["loop"]: + interval = max(1, int(options["interval"] or 15)) + self.stdout.write(f"Polling due scheduled work every {interval}s.") + try: + while True: + self._dispatch_once() + time.sleep(interval) + except KeyboardInterrupt: + self.stdout.write("dispatch_due loop stopped.") + return + self._dispatch_once() + + def _dispatch_once(self): + enqueued = run_dispatchers() + if enqueued: + self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s).")) + elif self.verbosity >= 2: + self.stdout.write("No due items.") diff --git a/site/core/migrations/0001_initial.py b/site/core/migrations/0001_initial.py new file mode 100644 index 0000000..39bcdc8 --- /dev/null +++ b/site/core/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# 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='StoredFile', + 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)), + ('kind', models.CharField(choices=[('campaign_image', 'Campaign image'), ('social_image', 'Social image'), ('social_video', 'Social video'), ('invoice_pdf', 'Invoice PDF')], default='campaign_image', max_length=32)), + ('filename', models.CharField(blank=True, max_length=255)), + ('content_type', models.CharField(max_length=128)), + ('size', models.PositiveIntegerField(default=0)), + ('data', models.BinaryField()), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/site/core/migrations/__init__.py b/site/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/core/models.py b/site/core/models.py new file mode 100644 index 0000000..5c80b3e --- /dev/null +++ b/site/core/models.py @@ -0,0 +1,52 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class TimeStampedModel(models.Model): + """Abstract base with created/updated timestamps.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class UUIDPrimaryKeyModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + +class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel): + """Binary file blob in the database (no filesystem media storage).""" + + class Kind(models.TextChoices): + CAMPAIGN_IMAGE = "campaign_image", "Campaign image" + SOCIAL_IMAGE = "social_image", "Social image" + SOCIAL_VIDEO = "social_video", "Social video" + INVOICE_PDF = "invoice_pdf", "Invoice PDF" + + kind = models.CharField( + max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE + ) + filename = models.CharField(max_length=255, blank=True) + content_type = models.CharField(max_length=128) + size = models.PositiveIntegerField(default=0) + data = models.BinaryField() + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="uploaded_files", + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.filename or str(self.pk) diff --git a/site/core/registry.py b/site/core/registry.py new file mode 100644 index 0000000..fc57481 --- /dev/null +++ b/site/core/registry.py @@ -0,0 +1,105 @@ +"""Optional-app registry. Apps register from AppConfig.ready() — never from always-on imports.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from django.urls import reverse + + +@dataclass(frozen=True) +class NavItem: + section: str + label: str + url_name: str + group: str = "Overview" + order: int = 100 + + +GROUP_ORDER = { + "Overview": 10, + "Outreach": 20, + "Content": 30, + "Social": 40, + "Billing": 50, +} + +_portal_nav: list[NavItem] = [] +_public_nav: list[NavItem] = [] +_dashboard_collectors: list[Callable] = [] +_dispatchers: list[Callable] = [] +_features: set[str] = set() + + +def register_feature(name: str) -> None: + _features.add(name) + + +def enabled_features() -> frozenset[str]: + return frozenset(_features) + + +def register_portal_nav( + *, + section: str, + label: str, + url_name: str, + group: str = "Overview", + order: int = 100, +) -> None: + item = NavItem( + section=section, label=label, url_name=url_name, group=group, order=order + ) + _portal_nav.append(item) + + +def register_public_nav( + *, + section: str, + label: str, + url_name: str, + order: int = 100, +) -> None: + _public_nav.append( + NavItem(section=section, label=label, url_name=url_name, order=order) + ) + + +def register_dashboard_collector(fn: Callable) -> None: + """fn(request) -> dict merged into dashboard home context.""" + _dashboard_collectors.append(fn) + + +def register_dispatcher(fn: Callable) -> None: + """fn() -> int count of items enqueued. Called by dispatch_due.""" + _dispatchers.append(fn) + + +def portal_nav() -> list[NavItem]: + return sorted( + _portal_nav, + key=lambda i: (GROUP_ORDER.get(i.group, 99), i.order, i.label), + ) + + +def public_nav() -> list[NavItem]: + return sorted(_public_nav, key=lambda i: (i.order, i.label)) + + +def collect_dashboard_context(request) -> dict: + extra: dict = {} + for fn in _dashboard_collectors: + extra.update(fn(request) or {}) + return extra + + +def run_dispatchers() -> int: + total = 0 + for fn in _dispatchers: + total += int(fn() or 0) + return total + + +def reverse_nav(item: NavItem) -> str: + return reverse(item.url_name) diff --git a/site/core/scheduling.py b/site/core/scheduling.py new file mode 100644 index 0000000..7cda963 --- /dev/null +++ b/site/core/scheduling.py @@ -0,0 +1,19 @@ +"""Shared schedule parsing for campaigns and social posts.""" + +from datetime import datetime + +from django.utils import timezone + + +def parse_scheduled_for(raw: str | None): + """Parse optional ``datetime-local`` value into an aware datetime.""" + value = (raw or "").strip() + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise ValueError("Invalid schedule datetime.") from exc + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + return parsed diff --git a/site/core/tests.py b/site/core/tests.py new file mode 100644 index 0000000..bd5c606 --- /dev/null +++ b/site/core/tests.py @@ -0,0 +1,39 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import Client, TestCase, override_settings +from django.urls import reverse + + +class HealthzTests(TestCase): + def test_healthz_ok(self): + response = Client().get("/healthz/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + + +class UnderConstructionTests(TestCase): + @override_settings(SITE_UNDER_CONSTRUCTION=True) + def test_home_redirects_when_gated(self): + response = Client().get("/") + self.assertEqual(response.status_code, 302) + self.assertIn("/under-construction", response["Location"]) + + @override_settings(SITE_UNDER_CONSTRUCTION=False) + def test_home_ok_when_open(self): + response = Client().get("/") + self.assertEqual(response.status_code, 200) + + +class PublicSmokeTests(TestCase): + def test_about_and_contact_get(self): + client = Client() + self.assertEqual(client.get(reverse("public:about")).status_code, 200) + self.assertEqual(client.get(reverse("public:contact")).status_code, 200) + + +class DispatchDueTests(TestCase): + def test_quiet_when_nothing_due(self): + out = StringIO() + call_command("dispatch_due", stdout=out) + self.assertEqual(out.getvalue(), "") diff --git a/site/core/tests_features.py b/site/core/tests_features.py new file mode 100644 index 0000000..8997fdf --- /dev/null +++ b/site/core/tests_features.py @@ -0,0 +1,92 @@ +"""Feature-flag isolation: flags control INSTALLED_APPS membership and deps.""" + +import ast +from pathlib import Path + +from django.apps import apps +from django.core.exceptions import ImproperlyConfigured +from django.test import SimpleTestCase, TestCase +from django.urls import reverse + + +def _read_settings_source() -> str: + return (Path(__file__).resolve().parent.parent / "client_site/settings/base.py").read_text() + + +class FeatureFlagSettingsTests(SimpleTestCase): + def test_optional_apps_are_gated_in_settings(self): + src = _read_settings_source() + self.assertIn("FEATURE_EMAIL_SMS", src) + self.assertIn("FEATURE_DIRECT_MAIL", src) + self.assertIn("FEATURE_BLOG", src) + self.assertIn("FEATURE_PAYMENTS", src) + self.assertIn("FEATURE_SOCIAL", src) + self.assertIn("FEATURE_SOCIAL_AI", src) + self.assertIn("email_sms.apps.EmailSmsConfig", src) + self.assertIn("directmail.apps.DirectmailConfig", src) + self.assertIn("blog.apps.BlogConfig", src) + self.assertIn("payments.apps.PaymentsConfig", src) + self.assertIn("social_ai.apps.SocialAiConfig", src) + + def test_payments_requires_email_sms_in_source(self): + src = _read_settings_source() + self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src) + self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src) + + +class AlwaysOnImportIsolationTests(SimpleTestCase): + """Always-on modules must not import optional apps at module level.""" + + OPTIONAL = { + "email_sms", + "directmail", + "blog", + "payments", + "social", + "social_ai", + } + + ALWAYS_ON = [ + "dashboard/views.py", + "public/views.py", + "core/management/commands/dispatch_due.py", + "client_site/urls.py", + ] + + def test_no_module_level_optional_imports(self): + root = Path(__file__).resolve().parent.parent + for rel in self.ALWAYS_ON: + path = root / rel + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + top = node.module.split(".")[0] + # Allow inside functions — ast.ImportFrom at module body only + if isinstance(node, (ast.Import, ast.ImportFrom)): + pass + # Check only top-level + for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + self.assertNotIn(top, self.OPTIONAL, f"{rel} imports {alias.name}") + if isinstance(node, ast.ImportFrom) and node.module: + top = node.module.split(".")[0] + self.assertNotIn(top, self.OPTIONAL, f"{rel} imports {node.module}") + + +class InstalledOptionalAppsTests(TestCase): + """Dev defaults install optional apps so the template is a working seed.""" + + def test_dev_installs_catalog_apps(self): + labels = {c.label for c in apps.get_app_configs()} + for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"): + self.assertIn(label, labels) + + def test_optional_urls_resolve_when_installed(self): + self.assertTrue(reverse("email_sms:campaign_list").startswith("/portal/messaging/")) + self.assertTrue(reverse("directmail:postcard_designer").startswith("/portal/direct-mail/")) + self.assertTrue(reverse("blog:list").startswith("/blog/")) + self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/")) + self.assertTrue(reverse("social:composer").startswith("/portal/social/")) + self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) diff --git a/site/core/urls.py b/site/core/urls.py new file mode 100644 index 0000000..c0b9d04 --- /dev/null +++ b/site/core/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from core import views + +app_name = "core" + +urlpatterns = [ + path("files//", views.stored_file, name="stored_file"), +] diff --git a/site/core/views.py b/site/core/views.py new file mode 100644 index 0000000..266726c --- /dev/null +++ b/site/core/views.py @@ -0,0 +1,26 @@ +import io + +from django.http import FileResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_GET + +from core.models import StoredFile + + +def healthz(_request): + """Liveness probe for deploy / NPM health checks.""" + return JsonResponse({"status": "ok"}) + + +@require_GET +def stored_file(request, pk): + """Public fetch (UUID acts as capability token).""" + stored = get_object_or_404(StoredFile, pk=pk) + response = FileResponse( + io.BytesIO(bytes(stored.data)), + content_type=stored.content_type or "application/octet-stream", + ) + if stored.filename: + response["Content-Disposition"] = f'inline; filename="{stored.filename}"' + response["Cache-Control"] = "public, max-age=86400" + return response diff --git a/site/dashboard/__init__.py b/site/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/dashboard/apps.py b/site/dashboard/apps.py new file mode 100644 index 0000000..31f679f --- /dev/null +++ b/site/dashboard/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "dashboard" + + def ready(self): + from core.registry import register_portal_nav + + register_portal_nav( + section="dashboard", + label="Dashboard", + url_name="dashboard:home", + group="Overview", + order=10, + ) diff --git a/site/dashboard/migrations/__init__.py b/site/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/dashboard/models.py b/site/dashboard/models.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/dashboard/models.py @@ -0,0 +1 @@ + diff --git a/site/dashboard/templates/dashboard/home.html b/site/dashboard/templates/dashboard/home.html new file mode 100644 index 0000000..1fd1366 --- /dev/null +++ b/site/dashboard/templates/dashboard/home.html @@ -0,0 +1,77 @@ +{% extends "portal_base.html" %} +{% block title %}Dashboard · Portal{% endblock %} +{% block topbar_title %}Dashboard{% endblock %} +{% block portal_content %} +
+
+
New leads
+
{{ lead_count }}
+
+
+
Open pipeline
+
{{ open_pipeline }}
+
+
+
Mailing list
+
{{ contact_count }}
+
+ {% if 'social' in enabled_features %} +
+
Scheduled posts
+
{{ scheduled_posts|default:0 }}
+
+ {% endif %} + {% if 'payments' in enabled_features %} +
+
Open invoices
+
{{ open_invoices|default:0 }}
+
+ {% endif %} +
+ +
+
+
+

Recent leads

+ View all +
+
+ + + + + + {% for lead in recent_leads %} + + + + + + {% empty %} + + {% endfor %} + +
NameSourceStatus
{{ lead.contact }}{% if lead.attribution %}{{ lead.attribution.utm_source|default:"direct" }}{% if lead.attribution.utm_campaign %} / {{ lead.attribution.utm_campaign }}{% endif %}{% else %}—{% endif %}{{ lead.get_status_display }}
No leads yet.
+
+
+ {% if 'email_sms' in enabled_features %} +
+
+

Upcoming outreach

+ Compose +
+
+ {% for campaign in upcoming_campaigns %} +

+ {{ campaign.name }} + — {{ campaign.get_channel_display }} · {{ campaign.get_status_display }} + {% if campaign.scheduled_for %} · {{ campaign.scheduled_for }}{% endif %} +

+ {% empty %} +

No campaigns yet.

+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/site/dashboard/urls.py b/site/dashboard/urls.py new file mode 100644 index 0000000..4f13690 --- /dev/null +++ b/site/dashboard/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from dashboard import views + +app_name = "dashboard" + +urlpatterns = [ + path("", views.home, name="home"), +] diff --git a/site/dashboard/views.py b/site/dashboard/views.py new file mode 100644 index 0000000..972295a --- /dev/null +++ b/site/dashboard/views.py @@ -0,0 +1,20 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render + +from contacts.models import Contact +from core.registry import collect_dashboard_context +from leads.models import Lead + + +@login_required +def home(request): + context = { + "lead_count": Lead.objects.filter(status=Lead.Status.NEW).count(), + "open_pipeline": Lead.objects.filter( + status__in=[Lead.Status.NEW, Lead.Status.CONTACTED] + ).count(), + "contact_count": Contact.objects.count(), + "recent_leads": Lead.objects.select_related("contact", "attribution").all()[:8], + } + context.update(collect_dashboard_context(request)) + return render(request, "dashboard/home.html", context) diff --git a/site/directmail/README.md b/site/directmail/README.md new file mode 100644 index 0000000..bd34eac --- /dev/null +++ b/site/directmail/README.md @@ -0,0 +1,133 @@ +# Messaging + +Campaign compose/send, SMTP2GO email + SMS, PCM Integrations postcards, and delivery webhooks. + +## SMTP2GO webhook setup + +Campaign report page polls provider events every 10s. Create **one** SMTP2GO +webhook (email + SMS share a URL — paid plans cap at 10 webhooks). + +### Auth (`SMTP2GO_WEBHOOK_SECRET`) + +1. Set `SMTP2GO_WEBHOOK_SECRET` in `.env` / prod env (long random string). +2. In SMTP2GO, set **Authorization header** to **Bearer** and paste that same secret + (do not leave it as “None”). +3. Fallback: `?token=` on the webhook URL also works. + +### Unified email + SMS webhook + +| Field | Value | +|-------|--------| +| URL | `https://mkdrealtor.com/portal/messaging/webhooks/smtp2go/` | +| Authorization header | **Bearer** + `SMTP2GO_WEBHOOK_SECRET` | +| Output type | JSON | +| Users | email SMTP user(s) **and** the SMS API key (`SMTP2GO_SMS_API_KEY`) | +| Email events | processed, bounced, rejected, spam, delivered, unsub/resub, opened, clicked | +| Email headers | `X-Monica-Message-Id` | +| SMS events | Submitted, Sending, Delivered, Failed, Rejected (and Opt-out if shown) | + +The handler classifies each POST from the payload (`sms_*` / `destination_number` +→ SMS; `rcpt` / `email_id` / `X-Monica-Message-Id` → email; inbound `text=STOP` +without `event` → SMS opt-out). + +`X-Monica-Message-Id` is set on every campaign email send and is required so email +webhook events match the correct recipient row. Invalid / missing header values +no longer 500 the endpoint (SMTP2GO “Test this webhook” often sends a sample +non-UUID). + +SMS correlation uses `message_id` (SMS id), then `destination_number` phone +fallback. Do **not** treat webhook `id` as the SMS id. + +**Opt-out:** SMTP2GO auto-handles replies `STOP` / `UNSUB` / `UNSUBSCRIBE`. +This endpoint also accepts inbound POSTs without an `event` field +(`text=STOP`, `from=…`) and opts the contact out of SMS. Later sends to +opted-out numbers are typically `sms_rejected`. + +Beta / other hosts: swap the hostname, keep the path. + +Legacy aliases (same handler): `/portal/messaging/webhooks/email/` and +`/portal/messaging/webhooks/sms/` — prefer `/smtp2go/` for new configs. + +## PCM Integrations (postcards) + +Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail API v3. + +### Env + +| Var | Purpose | +|-----|---------| +| `PCM_API_KEY` | API key from PCM portal (My Account → API Keys) | +| `PCM_API_SECRET` | Matching API secret; used with key on `POST /auth/login` | +| `PCM_CHILD_REF_NBR` | Optional child-app ref for multi-account | +| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) | +| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) | +| `PCM_RETURN_ADDRESS` | JSON return address on orders | +| `POSTCARD_PROVIDER` | `pcm` (default) | + +Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived +`token` used as `Authorization: Bearer …` on design/order calls +([PCM Logging In](https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in)). + +### Designer + +Portal → **Postcard design**: create/list designs via API, edit in iframe +(`POST /design/custom`, `GET /design/{id}/edit?mode=embed`). Save as a +`MessageTemplate` (stores `design_id`) then pick it when composing a postcard campaign. + +### Postcard webhook + +PCM allows **one event per subscription**, and each subscription gets its own +**signature secret** (copy-only in the UI). Create one subscription per status +you care about; point them all at the same URL and paste every secret into env. + +| Field | Value | +|-------|--------| +| URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` | +| Events | One subscription each: Pending, Processing, Processed, Delivered, Undeliverable, Canceled (skip QrCodeScan unless needed) | +| Environments | Sandbox and/or Production as needed | +| Secrets | Copy each subscription signature → `PCM_WEBHOOK_SECRETS=sec1,sec2,…` | + +We accept Bearer, `?token=`, or common signature headers (raw secret or +HMAC-SHA256 of body) matching **any** listed secret. + +Correlation: we send `extRefNbr=` on each recipient; webhooks should +echo that (or `orderID`, matched to `Message.provider_message_id`). + +### Campaign completion email + +When a campaign reaches **completed** (email, SMS, or postcard), one summary email +goes to `campaign.created_by.email`, else `CONTACT_EMAIL`. Guarded by +`Campaign.notify_sent_at` so it only sends once. + +### Local development + +SMTP2GO / PCM cannot reach `localhost`. Use a tunnel (Cloudflare Tunnel / ngrok) to `:8000`, +or test webhooks against beta/prod. + +For real SMTP delivery locally (not console logs): + +```bash +# in .env +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST_USER=… +EMAIL_HOST_PASSWORD=… +SMTP2GO_WEBHOOK_SECRET=… +SMTP2GO_SMS_API_KEY=… # SMS sends only +PCM_API_KEY=… +PCM_API_SECRET=… +PCM_WEBHOOK_SECRETS=sec1,sec2,… +PCM_RETURN_ADDRESS={…} +``` + +### Endpoints (app) + +| Path | Purpose | +|------|---------| +| `POST /portal/messaging/webhooks/smtp2go/` | Unified SMTP2GO email + SMS (+ inbound STOP) | +| `POST /portal/messaging/webhooks/email/` | Legacy alias → same as `/smtp2go/` | +| `POST /portal/messaging/webhooks/sms/` | Legacy alias → same as `/smtp2go/` | +| `POST /portal/messaging/webhooks/postcard/` | PCM order / mail tracking events | +| `GET /portal/messaging/campaigns//status.json` | Live stats for the campaign report UI | +| `GET /portal/messaging/postcard/` | PCM designer iframe | + +Code: `webhooks.py`, `providers/postcard/pcm.py`, `views.py`. diff --git a/site/directmail/__init__.py b/site/directmail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/directmail/admin.py b/site/directmail/admin.py new file mode 100644 index 0000000..16bc9d3 --- /dev/null +++ b/site/directmail/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin + +from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent + + +@admin.register(MessageTemplate) +class MessageTemplateAdmin(admin.ModelAdmin): + list_display = ("name", "channel", "created_at") + list_filter = ("channel",) + + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ("status", "provider", "provider_message_id", "sent_at") + + +@admin.register(Campaign) +class CampaignAdmin(admin.ModelAdmin): + list_display = ( + "name", + "channel", + "audience", + "status", + "scheduled_for", + "created_at", + ) + list_filter = ("channel", "audience", "status") + inlines = [MessageInline] + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("campaign", "contact", "channel", "status", "scheduled_for") + list_filter = ("channel", "status") + + +@admin.register(ProviderEvent) +class ProviderEventAdmin(admin.ModelAdmin): + list_display = ("provider", "event_type", "created_at") diff --git a/site/directmail/apps.py b/site/directmail/apps.py new file mode 100644 index 0000000..14d17be --- /dev/null +++ b/site/directmail/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class DirectmailConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "directmail" + verbose_name = "Direct mail" + + def ready(self): + from directmail import hooks + + hooks.register() diff --git a/site/directmail/channels/__init__.py b/site/directmail/channels/__init__.py new file mode 100644 index 0000000..968ecea --- /dev/null +++ b/site/directmail/channels/__init__.py @@ -0,0 +1,21 @@ +"""Channel dispatch — postcard / direct mail.""" + +from dataclasses import dataclass + +from contacts.models import Channel +from directmail.models import Message +from directmail.providers.postcard import get_postcard_provider + + +@dataclass +class ProviderResult: + provider: str + provider_id: str + + +def dispatch_message(message: Message) -> ProviderResult: + if message.channel == Channel.POSTCARD: + provider = get_postcard_provider() + result = provider.send_postcard(message) + return ProviderResult(provider=provider.name, provider_id=result.provider_id) + raise ValueError(f"Unsupported channel: {message.channel}") diff --git a/site/directmail/hooks.py b/site/directmail/hooks.py new file mode 100644 index 0000000..5590b70 --- /dev/null +++ b/site/directmail/hooks.py @@ -0,0 +1,58 @@ +"""Register portal nav, dashboard widgets, and due-work dispatch.""" + +from django.utils import timezone + +from core.registry import ( + register_dashboard_collector, + register_dispatcher, + register_feature, + register_portal_nav, +) + + +def register() -> None: + register_feature("directmail") + register_portal_nav( + section="directmail", + label="Direct mail", + url_name="directmail:campaign_list", + group="Outreach", + order=30, + ) + register_portal_nav( + section="postcard", + label="Postcard design", + url_name="directmail:postcard_designer", + group="Outreach", + order=31, + ) + register_dashboard_collector(_dashboard) + register_dispatcher(_dispatch_due) + + +def _dashboard(request) -> dict: + from directmail.models import Campaign + + upcoming = list( + Campaign.objects.exclude( + status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED] + ).order_by("scheduled_for", "-created_at")[:5] + ) + return {"upcoming_directmail": upcoming} + + +def _dispatch_due() -> int: + from directmail.models import Message + from directmail.tasks import send_campaign_message + + now = timezone.now() + enqueued = 0 + for message in Message.objects.filter( + status=Message.Status.SCHEDULED, + scheduled_for__lte=now, + ).iterator(): + message.status = Message.Status.QUEUED + message.save(update_fields=["status", "updated_at"]) + send_campaign_message.enqueue(message_id=str(message.pk)) + enqueued += 1 + return enqueued diff --git a/site/directmail/management/__init__.py b/site/directmail/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/directmail/management/commands/__init__.py b/site/directmail/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/directmail/migrations/0001_initial.py b/site/directmail/migrations/0001_initial.py new file mode 100644 index 0000000..ee88471 --- /dev/null +++ b/site/directmail/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# 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 = [ + ('contacts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MessageTemplate', + 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)), + ('name', models.CharField(max_length=120)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('subject', models.CharField(blank=True, max_length=255)), + ('body', models.TextField()), + ('postcard_front', models.JSONField(blank=True, default=dict)), + ('postcard_back', models.JSONField(blank=True, default=dict)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Campaign', + 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)), + ('name', models.CharField(max_length=120)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('audience', models.CharField(blank=True, choices=[('postcard_opt_in', 'Mailing list · postcard opt-in')], default='', max_length=32)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)), + ('scheduled_for', models.DateTimeField(blank=True, null=True)), + ('subject_override', models.CharField(blank=True, max_length=255)), + ('body_override', models.TextField(blank=True)), + ('notify_sent_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directmail_campaigns_created', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directmail_campaigns', to='directmail.messagetemplate')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Message', + 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)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('opened', 'Opened'), ('clicked', 'Clicked'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16)), + ('provider', models.CharField(blank=True, max_length=64)), + ('provider_message_id', models.CharField(blank=True, max_length=255)), + ('scheduled_for', models.DateTimeField(blank=True, null=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('error', models.TextField(blank=True)), + ('body_snapshot', models.TextField(blank=True)), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='directmail.campaign')), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directmail_messages', to='contacts.contact')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ProviderEvent', + 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)), + ('provider', models.CharField(max_length=64)), + ('event_type', models.CharField(max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='directmail.message')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/site/directmail/migrations/__init__.py b/site/directmail/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/directmail/models.py b/site/directmail/models.py new file mode 100644 index 0000000..524f181 --- /dev/null +++ b/site/directmail/models.py @@ -0,0 +1,118 @@ +from django.conf import settings +from django.db import models + +from contacts.models import Channel, Contact +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class MessageTemplate(UUIDPrimaryKeyModel, TimeStampedModel): + name = models.CharField(max_length=120) + channel = models.CharField(max_length=16, choices=Channel.choices) + subject = models.CharField(max_length=255, blank=True) + body = models.TextField() + postcard_front = models.JSONField(default=dict, blank=True) + postcard_back = models.JSONField(default=dict, blank=True) + + def __str__(self) -> str: + return f"{self.name} ({self.channel})" + + +class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCHEDULED = "scheduled", "Scheduled" + SENDING = "sending", "Sending" + COMPLETED = "completed", "Completed" + CANCELLED = "cancelled", "Cancelled" + + class Audience(models.TextChoices): + POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in" + + name = models.CharField(max_length=120) + channel = models.CharField(max_length=16, choices=Channel.choices) + audience = models.CharField( + max_length=32, + choices=Audience.choices, + blank=True, + default="", + ) + template = models.ForeignKey( + MessageTemplate, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="directmail_campaigns", + ) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + scheduled_for = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="directmail_campaigns_created", + ) + subject_override = models.CharField(max_length=255, blank=True) + body_override = models.TextField(blank=True) + # Set when completion summary email is sent (campaign COMPLETED). + notify_sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class Message(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCHEDULED = "scheduled", "Scheduled" + QUEUED = "queued", "Queued" + SENT = "sent", "Sent" + DELIVERED = "delivered", "Delivered" + OPENED = "opened", "Opened" + CLICKED = "clicked", "Clicked" + FAILED = "failed", "Failed" + BOUNCED = "bounced", "Bounced" + SUPPRESSED = "suppressed", "Suppressed" + + campaign = models.ForeignKey( + Campaign, on_delete=models.CASCADE, related_name="messages" + ) + contact = models.ForeignKey( + Contact, on_delete=models.CASCADE, related_name="directmail_messages" + ) + channel = models.CharField(max_length=16, choices=Channel.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + provider = models.CharField(max_length=64, blank=True) + provider_message_id = models.CharField(max_length=255, blank=True) + scheduled_for = models.DateTimeField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + error = models.TextField(blank=True) + body_snapshot = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.channel} → {self.contact} ({self.status})" + + +class ProviderEvent(TimeStampedModel): + message = models.ForeignKey( + Message, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="events", + ) + provider = models.CharField(max_length=64) + event_type = models.CharField(max_length=64) + payload = models.JSONField(default=dict, blank=True) + + diff --git a/site/directmail/providers/__init__.py b/site/directmail/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/directmail/providers/postcard/__init__.py b/site/directmail/providers/postcard/__init__.py new file mode 100644 index 0000000..dbc8fd0 --- /dev/null +++ b/site/directmail/providers/postcard/__init__.py @@ -0,0 +1,42 @@ +"""Pluggable postcard providers.""" + +from dataclasses import dataclass +from typing import Protocol + +from django.conf import settings + + +@dataclass +class PostcardResult: + provider_id: str + + +class PostcardProvider(Protocol): + name: str + + def send_postcard(self, message) -> PostcardResult: ... + + def get_status(self, provider_id: str) -> str: ... + + +def get_postcard_provider() -> PostcardProvider: + name = (settings.POSTCARD_PROVIDER or "pcm").lower() + if name == "pcm": + from directmail.providers.postcard.pcm import PcmProvider + + return PcmProvider() + if name == "click2mail": + from directmail.providers.postcard.click2mail import Click2MailProvider + + return Click2MailProvider() + if name == "postgrid": + from directmail.providers.postcard.postgrid import PostGridProvider + + return PostGridProvider() + if name == "lob": + from directmail.providers.postcard.lob import LobProvider + + return LobProvider() + from directmail.providers.postcard.pcm import PcmProvider + + return PcmProvider() diff --git a/site/directmail/providers/postcard/click2mail.py b/site/directmail/providers/postcard/click2mail.py new file mode 100644 index 0000000..8ce77d5 --- /dev/null +++ b/site/directmail/providers/postcard/click2mail.py @@ -0,0 +1,23 @@ +"""Click2Mail postcard adapter (low-volume pay-per-piece option).""" + +from dataclasses import dataclass + +from django.conf import settings + +from directmail.providers.postcard import PostcardResult + + +@dataclass +class Click2MailProvider: + name: str = "click2mail" + + def send_postcard(self, message) -> PostcardResult: + if not settings.CLICK2MAIL_API_KEY: + raise RuntimeError("CLICK2MAIL_API_KEY is not configured") + # Placeholder: wire full Click2Mail job API when account credentials are ready. + raise NotImplementedError( + "Click2Mail adapter stub — configure account then implement job submit" + ) + + def get_status(self, provider_id: str) -> str: + return "unknown" diff --git a/site/directmail/providers/postcard/lob.py b/site/directmail/providers/postcard/lob.py new file mode 100644 index 0000000..a6d8a51 --- /dev/null +++ b/site/directmail/providers/postcard/lob.py @@ -0,0 +1,66 @@ +"""Lob postcard adapter (default).""" + +from dataclasses import dataclass + +import requests +from django.conf import settings + +from directmail.providers.postcard import PostcardResult + + +@dataclass +class LobProvider: + name: str = "lob" + + def send_postcard(self, message) -> PostcardResult: + api_key = settings.LOB_API_KEY + if not api_key: + raise RuntimeError("LOB_API_KEY is not configured") + + contact = message.contact + address = contact.postal_address or {} + if not address.get("line1"): + raise ValueError("Contact postal_address.line1 required for postcard") + + # Minimal Lob create-postcard payload; artwork URLs come from template JSON. + template = message.campaign.template + front = (template.postcard_front if template else {}) or {} + back = (template.postcard_back if template else {}) or {} + + payload = { + "description": f"campaign-{message.campaign_id}", + "to": { + "name": contact.full_name or contact.email or "Resident", + "address_line1": address.get("line1", ""), + "address_line2": address.get("line2", ""), + "address_city": address.get("city", ""), + "address_state": address.get("state", ""), + "address_zip": address.get("zip", ""), + "address_country": address.get("country", "US"), + }, + "front": front.get("html") or front.get("url") or "", + "back": back.get("html") or back.get("url") or "", + } + response = requests.post( + "https://api.lob.com/v1/postcards", + json=payload, + auth=(api_key, ""), + timeout=60, + headers={"Idempotency-Key": str(message.pk)}, + ) + response.raise_for_status() + data = response.json() + return PostcardResult(provider_id=str(data.get("id") or message.pk)) + + def get_status(self, provider_id: str) -> str: + api_key = settings.LOB_API_KEY + if not api_key: + return "unknown" + response = requests.get( + f"https://api.lob.com/v1/postcards/{provider_id}", + auth=(api_key, ""), + timeout=30, + ) + if not response.ok: + return "unknown" + return str(response.json().get("status") or "unknown") diff --git a/site/directmail/providers/postcard/pcm.py b/site/directmail/providers/postcard/pcm.py new file mode 100644 index 0000000..f27fe2a --- /dev/null +++ b/site/directmail/providers/postcard/pcm.py @@ -0,0 +1,422 @@ +"""PCM Integrations (DirectMail API v3) postcard adapter.""" + +from __future__ import annotations + +import json +import logging +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import requests +from django.conf import settings + +from directmail.providers.postcard import PostcardResult + +logger = logging.getLogger(__name__) + +PCM_API_BASE = "https://v3.pcmintegrations.com" + +# Refresh a bit before PCM's expires timestamp. +_TOKEN_SKEW = timedelta(seconds=60) + +# PCM size codes for custom designer designs. +PCM_SIZE_CHOICES = ( + ("46", "4.25 × 6"), + ("68", "6 × 8.5"), + ("69", "6 × 9"), + ("611", "6 × 11"), + ("811", "8.5 × 11"), +) + + +class PcmApiError(RuntimeError): + """Raised when a PCM API call fails.""" + + +_token_lock = threading.Lock() +_cached_token: str | None = None +_cached_expires: datetime | None = None + + +def _api_key() -> str: + return (settings.PCM_API_KEY or "").strip() + + +def _api_secret() -> str: + return (settings.PCM_API_SECRET or "").strip() + + +def _parse_expires(raw: Any) -> datetime: + """Parse PCM expires timestamp; default to 55 minutes from now.""" + if isinstance(raw, (int, float)): + # Unix seconds vs ms + ts = float(raw) + if ts > 1e12: + ts /= 1000.0 + return datetime.fromtimestamp(ts, tz=timezone.utc) + if isinstance(raw, str) and raw.strip(): + text = raw.strip().replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + dt = None + if dt is not None: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + return datetime.now(timezone.utc) + timedelta(minutes=55) + + +def clear_token_cache() -> None: + """Drop cached bearer token (tests / forced re-login).""" + global _cached_token, _cached_expires + with _token_lock: + _cached_token = None + _cached_expires = None + + +def login(*, force: bool = False) -> str: + """POST /auth/login with apiKey + apiSecret → bearer token. + + See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in + """ + global _cached_token, _cached_expires + + with _token_lock: + now = datetime.now(timezone.utc) + if ( + not force + and _cached_token + and _cached_expires + and now < (_cached_expires - _TOKEN_SKEW) + ): + return _cached_token + + key = _api_key() + secret = _api_secret() + if not key or not secret: + raise PcmApiError( + "PCM_API_KEY and PCM_API_SECRET are required " + "(POST /auth/login, then use the returned token)" + ) + + body: dict[str, str] = {"apiKey": key, "apiSecret": secret} + child = (getattr(settings, "PCM_CHILD_REF_NBR", None) or "").strip() + if child: + body["childRefNbr"] = child + + response = requests.post( + f"{PCM_API_BASE}/auth/login", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + json=body, + timeout=30, + ) + if response.status_code >= 400: + detail = (response.text or "")[:500] + raise PcmApiError( + f"PCM POST /auth/login → {response.status_code}: {detail}" + ) + try: + data = response.json() + except ValueError as exc: + raise PcmApiError("PCM login returned non-JSON") from exc + if not isinstance(data, dict): + raise PcmApiError("PCM login returned unexpected payload") + + token = str(data.get("token") or "").strip() + if not token: + raise PcmApiError("PCM login response missing token") + + _cached_token = token + _cached_expires = _parse_expires(data.get("expires")) + logger.info( + "PCM login ok; token expires %s", + _cached_expires.isoformat(), + ) + return token + + +def _headers(*, force_login: bool = False) -> dict[str, str]: + token = login(force=force_login) + return { + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + +def pcm_request( + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + timeout: int = 60, +) -> Any: + """Call PCM v3 API. ``path`` is absolute under the API host (e.g. ``/design``).""" + url = f"{PCM_API_BASE}{path}" + response = requests.request( + method, + url, + headers=_headers(), + params=params, + json=json_body, + timeout=timeout, + ) + # Expired/revoked session → login once and retry. + if response.status_code == 401: + clear_token_cache() + response = requests.request( + method, + url, + headers=_headers(force_login=True), + params=params, + json=json_body, + timeout=timeout, + ) + if response.status_code >= 400: + detail = (response.text or "")[:500] + raise PcmApiError( + f"PCM {method} {path} → {response.status_code}: {detail}" + ) + if not response.content: + return {} + try: + return response.json() + except ValueError: + return {"raw": response.text} + + +def return_address_from_settings() -> dict[str, str]: + """Build PCM returnAddress from PCM_RETURN_ADDRESS JSON and/or PCM_RETURN_* vars. + + PCM requires address/city/state (and zip) whenever firstName is set — an empty + street with SITE_NAME-derived firstName causes a 400 from POST /order/postcard. + """ + data: dict[str, Any] = {} + raw = (settings.PCM_RETURN_ADDRESS or "").strip() + if raw: + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise PcmApiError( + "PCM_RETURN_ADDRESS must be valid JSON " + '(e.g. {"firstName":"…","lastName":"…","address":"…",' + '"city":"…","state":"IL","zipCode":"…"})' + ) from exc + if not isinstance(parsed, dict): + raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object") + data = parsed + + name = (settings.SITE_NAME or "").strip() + parts = name.split(None, 1) + site_first = parts[0] if parts else "" + site_last = parts[1] if len(parts) > 1 else "" + + address = { + "company": str(data.get("company") or "").strip(), + "firstName": str( + data.get("firstName") or data.get("first_name") or site_first or "" + ).strip(), + "lastName": str( + data.get("lastName") or data.get("last_name") or site_last or "" + ).strip(), + "address": str( + data.get("address") + or data.get("line1") + or getattr(settings, "PCM_RETURN_LINE1", "") + or "" + ).strip(), + # PCM treats blank address2 oddly; space matches recipient payload. + "address2": str( + data.get("address2") + or data.get("line2") + or getattr(settings, "PCM_RETURN_LINE2", "") + or "" + ).strip() + or " ", + "city": str( + data.get("city") or getattr(settings, "PCM_RETURN_CITY", "") or "" + ).strip(), + "state": str( + data.get("state") or getattr(settings, "PCM_RETURN_STATE", "") or "" + ).strip(), + "zipCode": str( + data.get("zipCode") + or data.get("zip") + or getattr(settings, "PCM_RETURN_ZIP", "") + or "" + ).strip(), + } + + missing = [ + field + for field in ("firstName", "address", "city", "state", "zipCode") + if not address.get(field, "").strip() + ] + if missing: + raise PcmApiError( + "PCM return address incomplete (missing " + + ", ".join(missing) + + "). Set PCM_RETURN_ADDRESS JSON with firstName, lastName, " + "address, city, state, zipCode " + "(or PCM_RETURN_LINE1 / CITY / STATE / ZIP)." + ) + return address + + +def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]: + address = contact.postal_address or {} + line1 = (address.get("line1") or "").strip() + if not line1: + raise ValueError("Contact postal_address.line1 required for postcard") + + first = (contact.first_name or "").strip() + last = (contact.last_name or "").strip() + if not first and not last: + # PCM requires name or company. + first = (contact.full_name or contact.email or "Resident").strip() + + return { + "firstName": first, + "lastName": last, + "address": line1, + "address2": (address.get("line2") or "").strip() or " ", + "city": (address.get("city") or "").strip(), + "state": (address.get("state") or "").strip(), + "zipCode": (address.get("zip") or "").strip(), + "extRefNbr": ext_ref, + } + + +def list_designs(*, product_type: str = "postcard", page: int = 1, per_page: int = 50) -> list[dict]: + data = pcm_request( + "GET", + "/design", + params={ + "productType": product_type, + "page": page, + "perPage": per_page, + }, + ) + if isinstance(data, dict): + results = data.get("results") or data.get("designs") or [] + return results if isinstance(results, list) else [] + return [] + + +def create_custom_design(*, name: str, size: str) -> dict[str, Any]: + """POST /design/custom → designID + embed url.""" + return pcm_request( + "POST", + "/design/custom", + json_body={"name": name, "size": size}, + ) + + +def get_design_embed_url(design_id: int | str, *, duplicate: bool = False) -> str: + """GET /design/{id}/edit?mode=embed → iframe URL.""" + params: dict[str, Any] = {"mode": "embed"} + if duplicate: + params["duplicate"] = "true" + data = pcm_request("GET", f"/design/{design_id}/edit", params=params) + if not isinstance(data, dict): + raise PcmApiError("Unexpected embed response from PCM") + url = data.get("embed_url") or data.get("url") or "" + if not url: + raise PcmApiError("PCM did not return an embed URL") + return str(url) + + +def get_order(order_id: int | str) -> dict[str, Any]: + data = pcm_request("GET", f"/order/{order_id}") + return data if isinstance(data, dict) else {} + + +def place_postcard_order( + *, + design_id: int, + recipient: dict[str, str], + ext_ref: str, + mail_class: str = "FirstClass", +) -> str: + """Place a one-recipient postcard order; return PCM orderID as string.""" + payload = { + "designID": design_id, + "mailClass": mail_class, + "extRefNbr": ext_ref, + "returnAddress": return_address_from_settings(), + "recipients": [recipient], + } + # Product-specific path — plain POST /order is 404 ("Cannot POST /order"). + # Docs: Place Postcard Order → POST /order/postcard + data = pcm_request("POST", "/order/postcard", json_body=payload) + if not isinstance(data, dict): + raise PcmApiError("Unexpected order response from PCM") + + order_id = data.get("orderID") or data.get("orderId") or data.get("id") + if order_id is None and isinstance(data.get("results"), list) and data["results"]: + order_id = data["results"][0].get("orderID") + if order_id is None: + raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400]) + return str(order_id) + + +def design_id_from_template(template) -> int | None: + """Read design_id from MessageTemplate.postcard_front JSON.""" + if not template: + return None + front = template.postcard_front or {} + if not isinstance(front, dict): + return None + raw = front.get("design_id") or front.get("designID") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +@dataclass +class PcmProvider: + name: str = "pcm" + + def send_postcard(self, message) -> PostcardResult: + template = message.campaign.template if message.campaign_id else None + design_id = design_id_from_template(template) + if not design_id: + raise ValueError( + "Postcard campaign template missing PCM design_id " + "(save a design from the postcard designer first)" + ) + + recipient = contact_to_pcm_recipient( + message.contact, ext_ref=str(message.pk) + ) + mail_class = "FirstClass" + if template and isinstance(template.postcard_front, dict): + mail_class = ( + template.postcard_front.get("mail_class") or mail_class + ) + + order_id = place_postcard_order( + design_id=design_id, + recipient=recipient, + ext_ref=str(message.pk), + mail_class=str(mail_class), + ) + return PostcardResult(provider_id=order_id) + + def get_status(self, provider_id: str) -> str: + try: + data = get_order(provider_id) + except PcmApiError: + logger.exception("PCM get_status failed for %s", provider_id) + return "unknown" + return str(data.get("status") or "unknown") diff --git a/site/directmail/providers/postcard/postgrid.py b/site/directmail/providers/postcard/postgrid.py new file mode 100644 index 0000000..a69650d --- /dev/null +++ b/site/directmail/providers/postcard/postgrid.py @@ -0,0 +1,22 @@ +"""PostGrid postcard adapter.""" + +from dataclasses import dataclass + +from django.conf import settings + +from directmail.providers.postcard import PostcardResult + + +@dataclass +class PostGridProvider: + name: str = "postgrid" + + def send_postcard(self, message) -> PostcardResult: + if not settings.POSTGRID_API_KEY: + raise RuntimeError("POSTGRID_API_KEY is not configured") + raise NotImplementedError( + "PostGrid adapter stub — configure account then implement send" + ) + + def get_status(self, provider_id: str) -> str: + return "unknown" diff --git a/site/directmail/services.py b/site/directmail/services.py new file mode 100644 index 0000000..17b9831 --- /dev/null +++ b/site/directmail/services.py @@ -0,0 +1,360 @@ +"""Campaign draft/send helpers for direct mail.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from django.urls import reverse +from django.utils import timezone + +from contacts.consent import ( # noqa: F401 — re-export for tests + providers + channel_preferences, + contact_may_receive, + make_unsubscribe_token, + one_click_unsubscribe_url, + opted_in_contacts, + parse_unsubscribe_token, + preferences_url, + process_unsubscribe_token, + record_sms_stop, + set_channel_consent, + set_channel_preferences, + unsubscribe_all, +) +from contacts.models import Channel, Contact +from core.scheduling import parse_scheduled_for +from directmail.models import Campaign, Message, MessageTemplate + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractBaseUser + +AUDIENCE_CHANNEL = { + Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, +} + +# {{first_name}} preferred; {first_name} also accepted (composer hint legacy). +_MERGE_TAG_RE = re.compile( + r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}" + r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}", + re.IGNORECASE, +) + +REMOVABLE_MESSAGE_STATUSES = frozenset( + { + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.FAILED, + } +) + + +def render_merge_tags(text: str, contact: Contact | None) -> str: + """Replace personalization tags with contact field values.""" + if not text: + return text or "" + first = (getattr(contact, "first_name", None) or "").strip() if contact else "" + last = (getattr(contact, "last_name", None) or "").strip() if contact else "" + email = (getattr(contact, "email", None) or "").strip() if contact else "" + phone = (getattr(contact, "phone", None) or "").strip() if contact else "" + full = f"{first} {last}".strip() + values = { + "first_name": first, + "last_name": last, + "email": email, + "phone": phone, + "full_name": full, + } + + def _replace(match: re.Match[str]) -> str: + key = (match.group(1) or match.group(2) or "").lower() + return values.get(key, "") + + return _MERGE_TAG_RE.sub(_replace, text) + + +def message_is_removable(message: Message) -> bool: + return message.status in REMOVABLE_MESSAGE_STATUSES + + +def channel_for_audience(audience: str) -> str: + try: + return AUDIENCE_CHANNEL[audience] + except KeyError as exc: + raise ValueError(f"Unknown audience: {audience}") from exc + + +def create_campaign_draft( + *, + name: str, + audience: str, + subject: str = "", + body: str = "", + scheduled_for=None, + created_by: AbstractBaseUser | None = None, + template: MessageTemplate | None = None, +) -> Campaign: + """Persist a draft campaign and per-recipient Message stubs.""" + channel = channel_for_audience(audience) + campaign = Campaign.objects.create( + name=name, + channel=channel, + audience=audience, + status=Campaign.Status.DRAFT, + scheduled_for=scheduled_for, + subject_override=subject, + body_override=body, + created_by=created_by, + template=template, + ) + contacts = list(opted_in_contacts(channel)) + Message.objects.bulk_create( + [ + Message( + campaign=campaign, + contact=contact, + channel=channel, + status=Message.Status.DRAFT, + scheduled_for=scheduled_for, + body_snapshot=body, + ) + for contact in contacts + ] + ) + return campaign + + +def campaign_notify_recipient(campaign: Campaign) -> str: + """Email address for the realtor summary (created_by, else CONTACT_EMAIL).""" + from django.conf import settings + + user = campaign.created_by + if user is not None: + email = (getattr(user, "email", None) or "").strip() + if email: + return email + return (settings.CONTACT_EMAIL or "").strip() + + +def send_campaign_completion_notify(campaign: Campaign) -> bool: + """ + One-shot summary email when a campaign finishes sending. + + Returns True if mail was sent (or already sent earlier). + """ + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.db.models import Count, Q + from django.template.loader import get_template + from django.urls import reverse + + from public.email_branding import email_brand_context + + campaign.refresh_from_db() + if campaign.notify_sent_at: + return True + if campaign.status != Campaign.Status.COMPLETED: + return False + + to_email = campaign_notify_recipient(campaign) + if not to_email: + return False + + # Claim the notify slot atomically so concurrent refresh calls only send once. + now = timezone.now() + claimed = Campaign.objects.filter( + pk=campaign.pk, + status=Campaign.Status.COMPLETED, + notify_sent_at__isnull=True, + ).update(notify_sent_at=now) + if not claimed: + return True + campaign.notify_sent_at = now + + counts = campaign.messages.aggregate( + sent=Count( + "id", + filter=Q( + status__in=[ + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + ] + ), + ), + delivered=Count( + "id", + filter=Q( + status__in=[ + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + ] + ), + ), + failed=Count( + "id", + filter=Q( + status__in=[ + Message.Status.FAILED, + Message.Status.BOUNCED, + ] + ), + ), + suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)), + total=Count("id"), + ) + report_path = reverse("directmail:campaign_detail", kwargs={"pk": campaign.pk}) + public = (settings.PUBLIC_SITE_URL or "").rstrip("/") + report_url = f"{public}{report_path}" if public else report_path + + subject = f"Campaign sent: {campaign.name}" + ctx = email_brand_context( + subject=subject, + campaign_name=campaign.name, + channel_display=campaign.get_channel_display(), + total=counts["total"], + sent=counts["sent"], + delivered=counts["delivered"], + failed=counts["failed"], + suppressed=counts["suppressed"], + report_url=report_url, + ) + text_content = get_template("emails/campaign_complete.txt").render(ctx) + html_content = get_template("emails/campaign_complete.html").render(ctx) + email = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + email.attach_alternative(html_content, "text/html") + try: + email.send(fail_silently=False) + except Exception: # noqa: BLE001 — don't block completion on mail errors + import logging + + logging.getLogger(__name__).exception( + "Campaign completion notify failed for %s", campaign.pk + ) + Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None) + campaign.notify_sent_at = None + return False + + return True + + +def refresh_campaign_status(campaign: Campaign) -> Campaign: + """Set campaign to completed when no messages remain pending.""" + campaign.refresh_from_db() + pending = campaign.messages.filter( + status__in=[ + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.QUEUED, + ] + ).exists() + if pending: + return campaign + if campaign.status == Campaign.Status.SENDING: + campaign.status = Campaign.Status.COMPLETED + campaign.save(update_fields=["status", "updated_at"]) + send_campaign_completion_notify(campaign) + return campaign + + +def enqueue_campaign_send(campaign: Campaign) -> int: + """ + Queue draft/scheduled/failed messages for send. + + Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console. + """ + from directmail.tasks import send_campaign_message + + sendable = list( + campaign.messages.filter( + status__in=[ + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.FAILED, + ] + ) + ) + if not sendable: + return 0 + + campaign.status = Campaign.Status.SENDING + campaign.save(update_fields=["status", "updated_at"]) + + enqueued = 0 + for message in sendable: + message.status = Message.Status.QUEUED + message.save(update_fields=["status", "updated_at"]) + try: + send_campaign_message.enqueue(message_id=str(message.pk)) + except Exception: # noqa: BLE001 — task already persisted FAILED + pass + enqueued += 1 + + refresh_campaign_status(campaign) + return enqueued + + +def send_campaign_test_email(campaign: Campaign, to_email: str) -> None: + """Send one preview copy to ``to_email`` without touching recipient rows.""" + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import get_template + + from public.email_branding import ( + campaign_body_to_email_html, + campaign_body_to_plain_text, + email_brand_context, + ) + + if campaign.channel != Channel.EMAIL: + raise ValueError("Test send is only available for email campaigns.") + subject = campaign.subject_override or ( + campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}" + ) + body = campaign.body_override or ( + campaign.template.body if campaign.template_id else "" + ) + if not subject.strip(): + raise ValueError("Campaign has no subject.") + if not body.strip(): + raise ValueError("Campaign has no body.") + + # Preview merge tags using first recipient when available. + sample = ( + campaign.messages.select_related("contact") + .order_by("created_at") + .first() + ) + sample_contact = sample.contact if sample else None + subject = render_merge_tags(subject, sample_contact) + body = render_merge_tags(body, sample_contact) + + notice = ( + "This is a test send from the portal. " + "Recipient list was not notified." + ) + ctx = email_brand_context( + title=f"[TEST] {subject}", + content=f"{campaign_body_to_plain_text(body)}\n\n{notice}", + content_html=( + f"{campaign_body_to_email_html(body)}" + f'

{notice}

' + ), + ) + text_content = get_template("emails/marketing_email.txt").render(ctx) + html_content = get_template("emails/marketing_email.html").render(ctx) + email = EmailMultiAlternatives( + subject=f"[TEST] {subject}", + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + email.attach_alternative(html_content, "text/html") + email.send(fail_silently=False) \ No newline at end of file diff --git a/site/directmail/tasks.py b/site/directmail/tasks.py new file mode 100644 index 0000000..82c29ff --- /dev/null +++ b/site/directmail/tasks.py @@ -0,0 +1,52 @@ +from django.tasks import task +from django.utils import timezone + +from directmail.channels import dispatch_message +from directmail.models import Message +from directmail.services import contact_may_receive, refresh_campaign_status + + +@task +def send_campaign_message(message_id: str) -> None: + campaign = None + try: + message = Message.objects.select_related("contact", "campaign").get( + pk=message_id + ) + except Message.DoesNotExist: + return + + campaign = message.campaign + + if not contact_may_receive(message.contact, message.channel): + message.status = Message.Status.SUPPRESSED + message.error = "Contact opted out or suppressed" + message.save(update_fields=["status", "error", "updated_at"]) + refresh_campaign_status(campaign) + return + + try: + result = dispatch_message(message) + message.status = Message.Status.SENT + message.provider = result.provider + message.provider_message_id = result.provider_id + message.sent_at = timezone.now() + message.error = "" + message.save( + update_fields=[ + "status", + "provider", + "provider_message_id", + "sent_at", + "error", + "updated_at", + ] + ) + except Exception as exc: # noqa: BLE001 — persist provider failures + message.status = Message.Status.FAILED + message.error = str(exc)[:2000] + message.save(update_fields=["status", "error", "updated_at"]) + refresh_campaign_status(campaign) + raise + + refresh_campaign_status(campaign) diff --git a/site/directmail/templates/directmail/campaign_detail.html b/site/directmail/templates/directmail/campaign_detail.html new file mode 100644 index 0000000..445d697 --- /dev/null +++ b/site/directmail/templates/directmail/campaign_detail.html @@ -0,0 +1,299 @@ +{% extends "portal_base.html" %} +{% block title %}{{ campaign.name }} · Campaign{% endblock %} +{% block topbar_title %}{{ campaign.name }}{% endblock %} +{% block portal_content %} +
+
+ {{ campaign.get_status_display }} + {{ campaign.get_channel_display }} + {% if campaign.scheduled_for %} + Scheduled {{ campaign.scheduled_for|date:"M j, g:i A" }} + {% endif %} + Live · updates every 10s +
+ ← Campaigns +
+ +{% if campaign.channel == "email" or can_send %} +
+

Send

+
+ {% if campaign.channel == "email" %} +
+ {% csrf_token %} +
+ + +
Sends one [TEST] copy. Does not notify the recipient list.
+
+
+ +
+
+ {% endif %} + {% if can_send %} +
+ {% csrf_token %} + +

+ {% if campaign.channel == "postcard" %} + Enqueues draft / scheduled / failed messages via PCM Integrations. + {% else %} + Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline). + {% endif %} +

+
+ {% endif %} +
+
+{% endif %} + +
+
+
Messages
+
{{ stats.total }}
+
+
+
Sent
+
{{ stats.sent }}
+
+
+
Delivered
+
{{ stats.delivered }}
+
+ {% if campaign.channel == "email" %} +
+
Opens
+
{{ stats.opens }}
+
+
+
Clicks
+
{{ stats.clicks }}
+
+ {% endif %} +
+
Bounced / failed
+
{{ stats.failed }}
+
+
+
Suppressed
+
{{ stats.suppressed }}
+
+
+ +
+
+

Engagement

+
+ {% if campaign.channel == "email" %} +

+ Unique recipients: {{ stats.opens }} opened · + {{ stats.clicks }} clicked + ({{ stats.open_events }} open events / + {{ stats.click_events }} click events from SMTP2GO). +

+ {% elif campaign.channel == "sms" %} +

+ Delivery status updates from SMTP2GO SMS webhooks. +

+ {% else %} +

+ Postcard status updates from PCM Integrations webhooks. +

+ {% endif %} + +
+
+
+

{{ events_title }}

+
+ + + + {% for event in recent_events %} + + + + + + {% empty %} + + {% endfor %} + +
WhenEventContact
{{ event.created_at|date:"M j, g:i A" }}{{ event.event_type }}{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}
{{ events_empty|safe }}
+
+
+
+ +
+
+

Recipients

+ + {{ page_obj.paginator.count }} total + {% if page_obj.paginator.num_pages > 1 %} + · page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + {% endif %} + +
+
+ + + + + + {% for message in recipient_messages %} + + + + + + + + {% empty %} + + {% endfor %} + +
ContactStatusProvider idError
+
{{ message.contact }}
+ {% if message.destination %} +
{{ message.destination }}
+ {% endif %} +
{{ message.get_status_display }}{{ message.provider_message_id|default:"—" }}{{ message.error|truncatechars:60|default:"—" }} + {% if message.can_remove %} +
+ {% csrf_token %} + + +
+ {% else %} + + {% endif %} +
No messages on this campaign.
+
+ {% if page_obj.paginator.num_pages > 1 %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} +
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/directmail/templates/directmail/campaign_list.html b/site/directmail/templates/directmail/campaign_list.html new file mode 100644 index 0000000..4f4b9cc --- /dev/null +++ b/site/directmail/templates/directmail/campaign_list.html @@ -0,0 +1,376 @@ +{% extends "portal_base.html" %} +{% block title %}Campaigns · Portal{% endblock %} +{% block topbar_title %}Campaign composer{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block portal_content %} +
+ Email + SMS + Postcard +
+ +
+
+

Compose

+
+ {% if form_errors %} +
    + {% for err in form_errors %} +
  • {{ err }}
  • + {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} +
+ + +
+
+ + +
Email only — ignored for SMS / postcard
+
+
+ +
+ +
Bold, fonts, sizes, links, images · merge tags: {% templatetag openvariable %}first_name{% templatetag closevariable %}, {% templatetag openvariable %}last_name{% templatetag closevariable %}
+
+ + +
+ + + +
+
+
+ + +
+
+ + +
Date & time · leave blank to keep as unscheduled draft
+
+
+

Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.

+ +
+
+
+
+

Preview

+
+
+
Preview updates as you type.
+ +
+

After send → open the campaign report for delivery & engagement.

+
+
+
+ +
+

Recent campaigns

+
+ + + + + + + + + + + + {% for campaign in campaigns %} + + + + + + + + {% empty %} + + {% endfor %} + +
NameChannelStatusScheduled
{{ campaign.name }}{{ campaign.get_channel_display }}{{ campaign.get_status_display }}{% if campaign.scheduled_for %}{{ campaign.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}Report
No campaigns yet.
+
+
+{% endblock %} +{% block extra_js %} + + +{% endblock %} diff --git a/site/directmail/templates/directmail/postcard_designer.html b/site/directmail/templates/directmail/postcard_designer.html new file mode 100644 index 0000000..38996ac --- /dev/null +++ b/site/directmail/templates/directmail/postcard_designer.html @@ -0,0 +1,135 @@ +{% extends "portal_base.html" %} +{% block title %}Postcard designer · Portal{% endblock %} +{% block topbar_title %}Postcard designer{% endblock %} +{% block portal_content %} +{% if api_error %} +
    +
  • {{ api_error }}
  • +
+{% endif %} + +
+
+
+

New design

+
+
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+

+ Opens PCM Integrations editor in the frame. Artwork stays on their side; + we store the design id for campaigns. +

+
+
+ +
+

Your designs

+
+ + + + + + + + + + {% for d in designs %} + + + + + + {% empty %} + + {% endfor %} + +
NameID
{{ d.name }}{{ d.design_id }} + Edit +
No designs yet — create one above.
+
+
+ + {% if active_design_id %} +
+

Save for campaigns

+
+
+ {% csrf_token %} + + +
+ + +
+ +
+

+ Designs listed above are selectable in Campaigns when Recipients is postcard. + Saving as a template keeps a named local copy. +

+
+
+ {% endif %} + + {% if saved_templates %} +
+

Saved templates

+
+ + + + + + {% for t in saved_templates %} + + + + + + {% endfor %} + +
NameDesign
{{ t.name }}{{ t.postcard_front.design_id }} + Open +
+
+
+ {% endif %} +
+ +
+
+

{% if active_design_id %}PCM editor · design {{ active_design_id }}{% else %}Editor{% endif %}

+
+
+ {% if embed_url %} + + {% else %} +
+ Create a design or pick one from the list to open the PCM editor here. +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/site/directmail/tests.py b/site/directmail/tests.py new file mode 100644 index 0000000..321c81f --- /dev/null +++ b/site/directmail/tests.py @@ -0,0 +1,281 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase, override_settings +from django.urls import reverse +from unittest.mock import patch + +from contacts.models import Channel, ConsentRecord, Contact, Suppression +from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent +from contacts.consent import channel_preferences +from directmail.services import ( + contact_may_receive, + create_campaign_draft, + opted_in_contacts, + set_channel_consent, +) + + +class PcmPostcardWebhookTests(TestCase): + def setUp(self): + self.client = Client() + self.contact = Contact.objects.create( + email="mail@example.com", + first_name="Pat", + last_name="Lee", + postal_address=Contact.make_postal_address( + line1="123 Main St", + city="Naperville", + state="IL", + zip_code="60540", + ), + ) + set_channel_consent( + self.contact, Channel.POSTCARD, opted_in=True, reason="test" + ) + template = MessageTemplate.objects.create( + name="PCM test design", + channel=Channel.POSTCARD, + body="Postcard", + postcard_front={"design_id": 99, "size": "46", "provider": "pcm"}, + ) + self.campaign = create_campaign_draft( + name="March mailer", + audience=Campaign.Audience.POSTCARD_OPT_IN, + body="Postcard mailing", + template=template, + ) + self.message = self.campaign.messages.get() + self.message.status = Message.Status.SENT + self.message.provider = "pcm" + self.message.provider_message_id = "order-555" + self.message.save() + + def test_delivered_by_ext_ref(self): + import json + + url = reverse("directmail:postcard_webhook") + response = self.client.post( + url, + data=json.dumps( + { + "status": "Delivered", + "extRefNbr": str(self.message.pk), + "orderID": 555, + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.DELIVERED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, provider="pcm", event_type="Delivered" + ).exists() + ) + + def test_requires_bearer_when_secret_set(self): + import json + + url = reverse("directmail:postcard_webhook") + with self.settings(PCM_WEBHOOK_SECRET="pcm-secret", PCM_WEBHOOK_SECRETS=""): + denied = self.client.post( + url, + data=json.dumps({"status": "Delivered", "orderID": 555}), + content_type="application/json", + ) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + url, + data=json.dumps( + { + "status": "Delivered", + "orderID": "order-555", + } + ), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer pcm-secret", + ) + self.assertEqual(ok.status_code, 200) + + def test_accepts_any_secret_from_list(self): + import json + + url = reverse("directmail:postcard_webhook") + with self.settings( + PCM_WEBHOOK_SECRET="", + PCM_WEBHOOK_SECRETS="sec-a,sec-b", + ): + denied = self.client.post( + url, + data=json.dumps({"status": "Delivered", "orderID": "order-555"}), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer wrong", + ) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + url, + data=json.dumps({"status": "Delivered", "orderID": "order-555"}), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer sec-b", + ) + self.assertEqual(ok.status_code, 200) + + +class PcmAuthTests(TestCase): + def setUp(self): + from directmail.providers.postcard import pcm as pcm_mod + + pcm_mod.clear_token_cache() + + def tearDown(self): + from directmail.providers.postcard import pcm as pcm_mod + + pcm_mod.clear_token_cache() + + def test_login_required_before_design_list(self): + from unittest.mock import MagicMock, patch + + from directmail.providers.postcard import pcm as pcm_mod + + login_resp = MagicMock() + login_resp.status_code = 200 + login_resp.content = b'{"token":"session-tok","expires":"2099-01-01T00:00:00.000Z"}' + login_resp.json.return_value = { + "token": "session-tok", + "expires": "2099-01-01T00:00:00.000Z", + } + + design_resp = MagicMock() + design_resp.status_code = 200 + design_resp.content = b'{"results":[]}' + design_resp.json.return_value = {"results": []} + + with self.settings(PCM_API_KEY="key", PCM_API_SECRET="secret"): + with patch("directmail.providers.postcard.pcm.requests.post") as post: + with patch( + "directmail.providers.postcard.pcm.requests.request" + ) as request: + post.return_value = login_resp + request.return_value = design_resp + designs = pcm_mod.list_designs() + + self.assertEqual(designs, []) + post.assert_called_once() + self.assertIn("/auth/login", post.call_args.args[0]) + self.assertEqual( + post.call_args.kwargs["json"], + {"apiKey": "key", "apiSecret": "secret"}, + ) + auth = request.call_args.kwargs["headers"]["Authorization"] + self.assertEqual(auth, "Bearer session-tok") + + def test_missing_secret_raises(self): + from directmail.providers.postcard import pcm as pcm_mod + + with self.settings(PCM_API_KEY="key", PCM_API_SECRET=""): + with self.assertRaises(pcm_mod.PcmApiError) as ctx: + pcm_mod.login(force=True) + self.assertIn("PCM_API_SECRET", str(ctx.exception)) + + def test_return_address_requires_street_fields(self): + from directmail.providers.postcard import pcm as pcm_mod + + with self.settings( + SITE_NAME="Monica Dhillon", + PCM_RETURN_ADDRESS="", + PCM_RETURN_LINE1="", + PCM_RETURN_CITY="", + PCM_RETURN_STATE="", + PCM_RETURN_ZIP="", + ): + with self.assertRaises(pcm_mod.PcmApiError) as ctx: + pcm_mod.return_address_from_settings() + self.assertIn("PCM return address incomplete", str(ctx.exception)) + + def test_return_address_from_json(self): + from directmail.providers.postcard import pcm as pcm_mod + + with self.settings( + PCM_RETURN_ADDRESS=( + '{"firstName":"Mo","lastName":"D","address":"1 Main",' + '"city":"Naperville","state":"IL","zipCode":"60540"}' + ), + ): + addr = pcm_mod.return_address_from_settings() + self.assertEqual(addr["address"], "1 Main") + self.assertEqual(addr["zipCode"], "60540") + + +class PostcardAddressDefaultConsentTests(TestCase): + def test_address_without_consent_is_postcard_eligible(self): + contact = Contact.objects.create( + email="addr@example.com", + first_name="Ann", + postal_address=Contact.make_postal_address( + line1="1 Oak St", + city="Naperville", + state="IL", + zip_code="60540", + ), + ) + self.assertTrue(contact_may_receive(contact, Channel.POSTCARD)) + prefs = channel_preferences(contact) + self.assertTrue(prefs[Channel.POSTCARD]) + self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 1) + + def test_explicit_postcard_opt_out_respected(self): + contact = Contact.objects.create( + email="out@example.com", + postal_address=Contact.make_postal_address(line1="2 Oak St"), + ) + set_channel_consent( + contact, Channel.POSTCARD, opted_in=False, reason="opt_out" + ) + self.assertFalse(contact_may_receive(contact, Channel.POSTCARD)) + self.assertEqual(opted_in_contacts(Channel.POSTCARD).count(), 0) + + +class PostcardDesignPickTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="pcm-pick", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="pcm-pick", password="test-pass-123") + self.contact = Contact.objects.create( + email="mail@example.com", + postal_address=Contact.make_postal_address(line1="9 Main"), + ) + + def test_compose_can_pick_pcm_design_id(self): + from unittest.mock import patch + + with patch( + "directmail.views._fetch_pcm_designs", + return_value=( + [{"design_id": "42", "name": "Spring card", "size": "46"}], + "", + ), + ): + url = reverse("directmail:campaign_list") + response = self.client.post( + url, + { + "name": "Mailer", + "subject": "", + "body": "", + "audience": Campaign.Audience.POSTCARD_OPT_IN, + "template_id": "d:42", + }, + ) + campaign = Campaign.objects.get(name="Mailer") + self.assertEqual(response.status_code, 302) + self.assertEqual(campaign.channel, Channel.POSTCARD) + self.assertIsNotNone(campaign.template_id) + self.assertEqual( + campaign.template.postcard_front.get("design_id"), 42 + ) + self.assertEqual(campaign.messages.count(), 1) + + diff --git a/site/directmail/urls.py b/site/directmail/urls.py new file mode 100644 index 0000000..0e8ef00 --- /dev/null +++ b/site/directmail/urls.py @@ -0,0 +1,37 @@ +from django.urls import path + +from directmail import views + +app_name = "directmail" + +urlpatterns = [ + path("campaigns/", views.campaign_list, name="campaign_list"), + path("campaigns//", views.campaign_detail, name="campaign_detail"), + path( + "campaigns//status.json", + views.campaign_status_json, + name="campaign_status_json", + ), + path("campaigns//send/", views.campaign_send, name="campaign_send"), + path( + "campaigns//messages//remove/", + views.campaign_message_remove, + name="campaign_message_remove", + ), + path("postcard/", views.postcard_designer, name="postcard_designer"), + path( + "postcard/create/", + views.postcard_design_create, + name="postcard_design_create", + ), + path( + "postcard/save/", + views.postcard_design_save, + name="postcard_design_save", + ), + path( + "webhooks/postcard/", + views.postcard_webhook, + name="postcard_webhook", + ), +] diff --git a/site/directmail/views.py b/site/directmail/views.py new file mode 100644 index 0000000..388fcf3 --- /dev/null +++ b/site/directmail/views.py @@ -0,0 +1,956 @@ +import hashlib +import hmac +import io +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.paginator import Paginator +from django.core.validators import validate_email +from django.http import FileResponse, HttpResponseForbidden, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from contacts.models import Channel +from core.models import StoredFile +from directmail.models import Campaign, Message, MessageTemplate, ProviderEvent +from directmail.providers.postcard.pcm import ( + PCM_SIZE_CHOICES, + PcmApiError, + create_custom_design, + design_id_from_template, + get_design_embed_url, + list_designs, +) +from contacts.consent import opted_in_contacts +from core.scheduling import parse_scheduled_for +from directmail.services import ( + create_campaign_draft, + enqueue_campaign_send, + message_is_removable, +) +from directmail.webhooks import ( + PROVIDER_EMAIL, + PROVIDER_PCM, + PROVIDER_SMS, + campaign_engagement_stats, + classify_smtp2go_payload, + parse_webhook_payload, + process_pcm_postcard_webhook, + process_smtp2go_email_webhook, + process_smtp2go_sms_webhook, +) + +logger = logging.getLogger(__name__) + +RECIPIENTS_PER_PAGE = 50 + +_ALLOWED_IMAGE_TYPES = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp"} +) +_MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +def _audience_choices() -> list[tuple[str, str]]: + """Labeled audience options with live opted-in counts.""" + rows = [ + (Campaign.Audience.POSTCARD_OPT_IN, Channel.EMAIL, "email"), + (Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"), + (Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"), + ] + choices = [] + for value, channel, label in rows: + count = opted_in_contacts(channel).count() + noun = "contact" if count == 1 else "contacts" + choices.append( + (value, f"Mailing list · {label} opt-in ({count} {noun})") + ) + return choices + + +def _postcard_templates(): + return MessageTemplate.objects.filter(channel=Channel.POSTCARD).order_by( + "-updated_at" + )[:50] + + +def _fetch_pcm_designs() -> tuple[list[dict], str]: + """Return (normalized design rows, api_error).""" + designs: list[dict] = [] + api_error = "" + try: + remote = list_designs(product_type="postcard") + for item in remote: + if not isinstance(item, dict): + continue + did = item.get("designID") or item.get("design_id") or item.get("id") + if did is None: + continue + size_info = item.get("size") or {} + size_key = ( + size_info.get("key") + if isinstance(size_info, dict) + else size_info + ) or "" + designs.append( + { + "design_id": str(did), + "name": item.get("friendlyName") + or item.get("name") + or f"Design {did}", + "size": str(size_key), + } + ) + except PcmApiError as exc: + api_error = str(exc) + + seen = {d["design_id"] for d in designs} + for tmpl in _postcard_templates(): + front = tmpl.postcard_front or {} + did = front.get("design_id") + if did is None: + continue + did_s = str(did) + if did_s in seen: + continue + designs.insert( + 0, + { + "design_id": did_s, + "name": tmpl.name, + "size": str(front.get("size") or ""), + }, + ) + seen.add(did_s) + return designs, api_error + + +def _postcard_design_choices() -> list[dict]: + """Options for campaign compose: PCM designs + saved templates.""" + designs, _ = _fetch_pcm_designs() + by_id = {d["design_id"]: d for d in designs} + choices: list[dict] = [] + for tmpl in _postcard_templates(): + did = design_id_from_template(tmpl) + if did is None: + continue + did_s = str(did) + choices.append( + { + "value": f"t:{tmpl.pk}", + "label": f"{tmpl.name} (design {did_s})", + "design_id": did_s, + } + ) + by_id.pop(did_s, None) + for did_s, d in by_id.items(): + choices.append( + { + "value": f"d:{did_s}", + "label": f"{d['name']} (design {did_s})", + "design_id": did_s, + "size": d.get("size") or "46", + "name": d["name"], + } + ) + return choices + + +def _resolve_postcard_template(raw: str) -> MessageTemplate | None: + """Resolve compose select value ``t:`` or ``d:``.""" + value = (raw or "").strip() + if not value: + return None + if value.startswith("t:"): + return MessageTemplate.objects.filter( + pk=value[2:], channel=Channel.POSTCARD + ).first() + if value.startswith("d:"): + design_raw = value[2:].strip() + try: + design_id = int(design_raw) + except ValueError: + return None + for tmpl in MessageTemplate.objects.filter(channel=Channel.POSTCARD): + if design_id_from_template(tmpl) == design_id: + return tmpl + name = f"PCM design {design_id}" + designs, _ = _fetch_pcm_designs() + match = next( + (d for d in designs if d["design_id"] == str(design_id)), None + ) + size = (match or {}).get("size") or "46" + if match and match.get("name"): + name = match["name"] + front = { + "design_id": design_id, + "size": size, + "name": name, + "provider": "pcm", + } + return MessageTemplate.objects.create( + channel=Channel.POSTCARD, + name=name[:120], + subject="", + body=f"PCM design {design_id}", + postcard_front=front, + postcard_back={}, + ) + # Legacy: bare MessageTemplate pk + return MessageTemplate.objects.filter( + pk=value, channel=Channel.POSTCARD + ).first() + + +def _format_postal_address(addr: dict | None) -> str: + if not addr: + return "" + line1 = (addr.get("line1") or "").strip() + line2 = (addr.get("line2") or "").strip() + city = (addr.get("city") or "").strip() + state = (addr.get("state") or "").strip() + zip_code = (addr.get("zip") or "").strip() + city_line = ", ".join(p for p in (city, state) if p) + if zip_code: + city_line = f"{city_line} {zip_code}".strip() + return ", ".join(p for p in (line1, line2, city_line) if p) + + +def _message_destination(message) -> str: + """Channel-specific destination shown on the recipients table.""" + contact = message.contact + channel = message.channel or (message.campaign.channel if message.campaign_id else "") + if channel == Channel.EMAIL: + return (contact.email or "").strip() + if channel == Channel.SMS: + return (contact.phone or "").strip() + if channel == Channel.POSTCARD: + return _format_postal_address(contact.postal_address) + return "" + + +def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]: + """Return (provider codes, panel title, empty-state hint) for campaign channel.""" + if campaign.channel == Channel.POSTCARD: + return ( + [PROVIDER_PCM], + "Recent PCM Integrations events", + "No PCM webhook events yet. PCM must POST to " + "/portal/directmail/webhooks/postcard/.", + ) + if campaign.channel == Channel.SMS: + return ( + [PROVIDER_SMS], + "Recent SMTP2GO events", + "No webhook events yet. SMTP2GO must POST SMS events to " + "/portal/directmail/webhooks/smtp2go/.", + ) + return ( + [PROVIDER_EMAIL], + "Recent SMTP2GO events", + "No webhook events yet. SMTP2GO must POST opens/clicks to " + "/portal/directmail/webhooks/smtp2go/ " + "(see directmail README). SMTP2GO’s own “Clicked” feed does not fill " + "this table by itself.", + ) + + +def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict: + qs = campaign.messages.select_related("contact").order_by( + "contact__first_name", "contact__last_name", "created_at" + ) + paginator = Paginator(qs, RECIPIENTS_PER_PAGE) + page_obj = paginator.get_page(page) + recipient_messages = list(page_obj.object_list) + for msg in recipient_messages: + msg.destination = _message_destination(msg) + msg.can_remove = message_is_removable(msg) + stats = campaign_engagement_stats(campaign) + providers, events_title, events_empty = _events_provider_filter(campaign) + recent_events = ( + ProviderEvent.objects.filter( + message__campaign=campaign, + provider__in=providers, + ) + .select_related("message", "message__contact") + .order_by("-created_at")[:25] + ) + return { + "recipient_messages": recipient_messages, + "page_obj": page_obj, + "stats": stats, + "recent_events": recent_events, + "events_title": events_title, + "events_empty": events_empty, + } + + +def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool: + """Accept Bearer / ?token= matching any configured secret (constant-time).""" + candidates: list[str] = [] + if secrets: + candidates.extend(s.strip() for s in secrets if (s or "").strip()) + single = (secret or "").strip() + if single and single not in candidates: + candidates.append(single) + if not candidates: + return True + + token = (request.GET.get("token") or "").strip() + auth = (request.headers.get("Authorization") or "").strip() + bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else "" + # Common signature-header names PCM / gateways may use (raw secret or HMAC). + sig_headers = ( + request.headers.get("X-PCM-Signature") + or request.headers.get("X-Webhook-Signature") + or request.headers.get("X-Signature") + or request.headers.get("X-Hub-Signature-256") + or "" + ).strip() + if sig_headers.lower().startswith("sha256="): + sig_headers = sig_headers[7:].strip() + + body = request.body or b"" + for candidate in candidates: + if token and hmac.compare_digest(token, candidate): + return True + if bearer and hmac.compare_digest(bearer, candidate): + return True + if sig_headers: + if hmac.compare_digest(sig_headers, candidate): + return True + digest = hmac.new( + candidate.encode("utf-8"), body, hashlib.sha256 + ).hexdigest() + if hmac.compare_digest(sig_headers, digest): + return True + return False + + +def _log_webhook_request(request, *, channel: str) -> None: + """Full request dump for Grafana / log aggregation.""" + try: + headers = {str(k): str(v) for k, v in request.headers.items()} + except Exception: # noqa: BLE001 + headers = {"_error": "unable to serialize headers"} + try: + body_text = (request.body or b"").decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + body_text = repr(request.body) + if len(body_text) > 12000: + body_text = body_text[:12000] + "…[truncated]" + logger.info( + "webhook_received channel=%s path=%s method=%s query=%s", + channel, + request.path, + request.method, + request.META.get("QUERY_STRING", ""), + ) + logger.info("webhook_headers channel=%s headers=%s", channel, headers) + logger.info("webhook_body channel=%s body=%s", channel, body_text) + + +def _log_webhook_auth_failed(request, *, channel: str) -> None: + logger.warning( + "webhook_auth_failed channel=%s path=%s " + "missing_or_invalid_authorization_or_token", + channel, + request.path, + ) + + +def _log_webhook_result( + *, + channel: str, + event=None, + error: str = "", + extra: str = "", +) -> None: + if error: + logger.error( + "webhook_error channel=%s error=%s %s", + channel, + error, + extra, + ) + return + if not event: + logger.warning( + "webhook_unmatched channel=%s no_provider_event_created %s", + channel, + extra, + ) + return + message = getattr(event, "message", None) + campaign = getattr(message, "campaign", None) if message else None + logger.info( + "webhook_processed channel=%s event_type=%s event_id=%s " + "matched=%s message_id=%s campaign_id=%s campaign_name=%s %s", + channel, + getattr(event, "event_type", ""), + getattr(event, "pk", None), + bool(message), + getattr(message, "pk", None), + getattr(campaign, "pk", None), + getattr(campaign, "name", "") or "", + extra, + ) + + +def _pcm_webhook_secrets() -> list[str]: + """All PCM subscription signature secrets from env.""" + raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip() + single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip() + out: list[str] = [] + if raw_list: + out.extend(p.strip() for p in raw_list.split(",") if p.strip()) + if single and single not in out: + out.append(single) + return out + + +@login_required +@require_http_methods(["GET", "POST"]) +def campaign_list(request): + form_errors: list[str] = [] + form_data = { + "name": "", + "subject": "", + "body": "", + "audience": Campaign.Audience.POSTCARD_OPT_IN, + "scheduled_for": "", + "template_id": "", + } + + if request.method == "POST": + name = (request.POST.get("name") or "").strip() + subject = (request.POST.get("subject") or "").strip() + body = (request.POST.get("body") or "").strip() + audience = (request.POST.get("audience") or "").strip() + scheduled_raw = request.POST.get("scheduled_for") or "" + template_id = (request.POST.get("template_id") or "").strip() + + form_data.update( + { + "name": name, + "subject": subject, + "body": body, + "audience": audience, + "scheduled_for": scheduled_raw, + "template_id": template_id, + } + ) + + template = None + if template_id: + template = _resolve_postcard_template(template_id) + + if not name: + form_errors.append("Campaign name is required.") + if audience not in Campaign.Audience.values: + form_errors.append("Choose a recipient list.") + if audience == Campaign.Audience.POSTCARD_OPT_IN: + if not template or template.channel != Channel.POSTCARD: + form_errors.append( + "Choose a postcard design (create one under Postcard design)." + ) + if not body: + body = "Postcard mailing" + else: + if not body: + form_errors.append("Body is required.") + if ( + audience == Campaign.Audience.POSTCARD_OPT_IN + and not subject + ): + form_errors.append("Subject is required for email campaigns.") + + scheduled_for = None + try: + scheduled_for = parse_scheduled_for(scheduled_raw) + except ValueError as exc: + form_errors.append(str(exc)) + + if not form_errors: + campaign = create_campaign_draft( + name=name, + audience=audience, + subject=subject, + body=body, + scheduled_for=scheduled_for, + created_by=request.user, + template=template, + ) + recipient_count = campaign.messages.count() + messages.success( + request, + f'Draft “{campaign.name}” saved ' + f"({recipient_count} recipient" + f"{'' if recipient_count == 1 else 's'}).", + ) + return redirect("directmail:campaign_detail", pk=campaign.pk) + + campaigns = Campaign.objects.all()[:100] + return render( + request, + "directmail/campaign_list.html", + { + "campaigns": campaigns, + "audience_choices": _audience_choices(), + "postcard_designs": _postcard_design_choices(), + "form_data": form_data, + "form_errors": form_errors, + "image_upload_url": reverse("directmail:campaign_image_upload"), + }, + ) + + +@login_required +def campaign_detail(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + try: + page = max(1, int(request.GET.get("page") or 1)) + except (TypeError, ValueError): + page = 1 + ctx = _campaign_report(campaign, page=page) + return render( + request, + "directmail/campaign_detail.html", + { + "campaign": campaign, + "recipient_messages": ctx["recipient_messages"], + "page_obj": ctx["page_obj"], + "stats": ctx["stats"], + "recent_events": ctx["recent_events"], + "events_title": ctx["events_title"], + "events_empty": ctx["events_empty"], + "can_send": campaign.status + in { + Campaign.Status.DRAFT, + Campaign.Status.SCHEDULED, + Campaign.Status.SENDING, + } + and campaign.messages.exclude( + status__in={ + "sent", + "delivered", + "opened", + "clicked", + "suppressed", + } + ).exists(), + }, + ) + + +@login_required +@require_GET +def campaign_status_json(request, pk): + """JSON snapshot for live-updating the campaign report page.""" + campaign = get_object_or_404(Campaign, pk=pk) + # Async queue may finish after enqueue; re-evaluate completion on poll. + from directmail.services import refresh_campaign_status + + refresh_campaign_status(campaign) + campaign.refresh_from_db() + try: + page = max(1, int(request.GET.get("page") or 1)) + except (TypeError, ValueError): + page = 1 + ctx = _campaign_report(campaign, page=page) + page_obj = ctx["page_obj"] + return JsonResponse( + { + "status": campaign.status, + "status_display": campaign.get_status_display(), + "stats": ctx["stats"], + "page": page_obj.number, + "num_pages": page_obj.paginator.num_pages, + "messages": [ + { + "id": str(m.pk), + "contact": str(m.contact), + "destination": getattr(m, "destination", "") or "", + "status": m.status, + "status_display": m.get_status_display(), + "provider_message_id": m.provider_message_id or "", + "error": (m.error or "")[:120], + "can_remove": bool(getattr(m, "can_remove", False)), + } + for m in ctx["recipient_messages"] + ], + "events": [ + { + "event_type": e.event_type, + "contact": str(e.message.contact) if e.message_id else "—", + "created_at": e.created_at.isoformat(), + } + for e in ctx["recent_events"] + ], + } + ) + + +@login_required +@require_POST +def campaign_message_remove(request, pk, message_id): + """Drop a draft/scheduled/failed recipient from the campaign.""" + campaign = get_object_or_404(Campaign, pk=pk) + message = get_object_or_404(Message, pk=message_id, campaign=campaign) + if not message_is_removable(message): + messages.error( + request, + "Only draft, scheduled, or failed recipients can be removed.", + ) + return redirect("directmail:campaign_detail", pk=campaign.pk) + + label = str(message.contact) + message.delete() + messages.success(request, f"Removed {label} from this campaign.") + page = (request.POST.get("page") or request.GET.get("page") or "").strip() + if page and page.isdigit() and int(page) > 1: + return redirect( + f"{reverse('directmail:campaign_detail', kwargs={'pk': campaign.pk})}" + f"?page={page}" + ) + return redirect("directmail:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_send(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + if campaign.status == Campaign.Status.CANCELLED: + messages.error(request, "Cancelled campaigns cannot be sent.") + return redirect("directmail:campaign_detail", pk=campaign.pk) + + count = enqueue_campaign_send(campaign) + campaign.refresh_from_db() + if count == 0: + messages.warning(request, "No draft/scheduled/failed messages to send.") + else: + sent = campaign.messages.filter(status="sent").count() + failed = campaign.messages.filter(status="failed").count() + messages.success( + request, + f"Send finished for {count} message(s): {sent} sent, {failed} failed.", + ) + return redirect("directmail:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_test_send(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + to_email = (request.POST.get("test_email") or "").strip() + if not to_email: + messages.error(request, "Enter an email address for the test send.") + return redirect("directmail:campaign_detail", pk=campaign.pk) + try: + validate_email(to_email) + except ValidationError: + messages.error(request, "That test email address is not valid.") + return redirect("directmail:campaign_detail", pk=campaign.pk) + + try: + send_campaign_test_email(campaign, to_email) + except ValueError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal + messages.error(request, f"Test send failed: {exc}") + else: + messages.success(request, f"Test email sent to {to_email}.") + return redirect("directmail:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_image_upload(request): + """Upload an image for the email rich editor; store bytes in the DB.""" + upload = request.FILES.get("image") or request.FILES.get("file") + if not upload: + return JsonResponse({"error": "No image uploaded."}, status=400) + content_type = (getattr(upload, "content_type", None) or "").lower() + if content_type not in _ALLOWED_IMAGE_TYPES: + return JsonResponse( + {"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400 + ) + if upload.size and upload.size > _MAX_IMAGE_BYTES: + return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) + + data = upload.read() + if len(data) > _MAX_IMAGE_BYTES: + return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) + + original = (getattr(upload, "name", None) or "image")[:255] + stored = StoredFile.objects.create( + kind=StoredFile.Kind.CAMPAIGN_IMAGE, + filename=original, + content_type=content_type, + size=len(data), + data=data, + uploaded_by=request.user if request.user.is_authenticated else None, + ) + path = reverse("core:stored_file", kwargs={"pk": stored.pk}) + url = request.build_absolute_uri(path) + return JsonResponse({"url": url, "id": str(stored.pk)}) + + +@require_GET +def stored_file(request, pk): + """Public fetch for email clients / preview (UUID acts as capability token).""" + stored = get_object_or_404(StoredFile, pk=pk) + response = FileResponse( + io.BytesIO(bytes(stored.data)), + content_type=stored.content_type or "application/octet-stream", + ) + if stored.filename: + response["Content-Disposition"] = f'inline; filename="{stored.filename}"' + response["Cache-Control"] = "public, max-age=86400" + return response + + +@login_required +def postcard_designer(request): + """PCM Integrations designer — list designs + embed iframe.""" + designs, api_error = _fetch_pcm_designs() + embed_url = "" + active_design_id = (request.GET.get("design_id") or "").strip() + active_name = "" + active_size = "46" + + if active_design_id: + match = next( + (d for d in designs if d["design_id"] == active_design_id), None + ) + if match: + active_name = match["name"] + active_size = match.get("size") or "46" + else: + active_name = f"Design {active_design_id}" + try: + embed_url = get_design_embed_url(active_design_id) + except PcmApiError as exc: + api_error = api_error or str(exc) + + return render( + request, + "directmail/postcard_designer.html", + { + "api_error": api_error, + "designs": designs, + "embed_url": embed_url, + "active_design_id": active_design_id, + "active_name": active_name, + "active_size": active_size, + "size_choices": PCM_SIZE_CHOICES, + "new_name": "", + "new_size": "46", + "saved_templates": _postcard_templates(), + }, + ) + + +@login_required +@require_POST +def postcard_design_create(request): + name = (request.POST.get("name") or "").strip() or "Untitled postcard" + size = (request.POST.get("size") or "46").strip() + allowed = {code for code, _ in PCM_SIZE_CHOICES} + if size not in allowed: + messages.error(request, "Invalid postcard size.") + return redirect("directmail:postcard_designer") + try: + data = create_custom_design(name=name, size=size) + except PcmApiError as exc: + messages.error(request, f"PCM create failed: {exc}") + return redirect("directmail:postcard_designer") + + design_id = data.get("designID") or data.get("design_id") + if design_id is None: + messages.error(request, "PCM did not return a design ID.") + return redirect("directmail:postcard_designer") + messages.success(request, f"Design {design_id} created — edit below.") + return redirect( + f"{reverse('directmail:postcard_designer')}?design_id={design_id}" + ) + + +@login_required +@require_POST +def postcard_design_save(request): + design_id = (request.POST.get("design_id") or "").strip() + template_name = (request.POST.get("template_name") or "").strip() + size = (request.POST.get("size") or "").strip() + if not design_id: + messages.error(request, "Missing design id.") + return redirect("directmail:postcard_designer") + if not template_name: + messages.error(request, "Template name is required.") + return redirect( + f"{reverse('directmail:postcard_designer')}?design_id={design_id}" + ) + try: + design_id_int = int(design_id) + except ValueError: + messages.error(request, "Invalid design id.") + return redirect("directmail:postcard_designer") + + front = { + "design_id": design_id_int, + "size": size, + "name": template_name, + "provider": "pcm", + } + tmpl, created = MessageTemplate.objects.update_or_create( + channel=Channel.POSTCARD, + name=template_name, + defaults={ + "subject": "", + "body": f"PCM design {design_id_int}", + "postcard_front": front, + "postcard_back": {}, + }, + ) + verb = "Created" if created else "Updated" + messages.success( + request, + f"{verb} postcard template “{tmpl.name}” (design {design_id_int}).", + ) + return redirect( + f"{reverse('directmail:postcard_designer')}?design_id={design_id}" + ) + + +@csrf_exempt +@require_POST +def postcard_webhook(request): + """ + PCM Integrations order / mail-tracking webhook. + + Configure in PCM → Webhooks (one subscription per event): + URL: https:///portal/directmail/webhooks/postcard/ + Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS + """ + channel = "postcard" + _log_webhook_request(request, channel=channel) + if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()): + _log_webhook_auth_failed(request, channel=channel) + return HttpResponseForbidden("invalid webhook token") + payload = parse_webhook_payload(request) + if not payload: + payload = request.POST.dict() or {} + try: + event = process_pcm_postcard_webhook(payload) + except Exception as exc: # noqa: BLE001 + logger.exception("PCM postcard webhook processing failed") + _log_webhook_result(channel=channel, error=str(exc)) + return JsonResponse({"ok": False, "error": "processing_failed"}, status=200) + _log_webhook_result(channel=channel, event=event) + return JsonResponse( + { + "ok": True, + "matched": bool(event and event.message_id), + "event_id": event.pk if event else None, + } + ) + + +@csrf_exempt +@require_POST +def smtp2go_webhook(request): + """ + Unified SMTP2GO webhook — email + SMS events + inbound STOP replies. + + One SMTP2GO webhook URL (paid plans cap at 10 webhooks): + + URL: https:///portal/directmail/webhooks/smtp2go/ + Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET + Output type: JSON + Users: email SMTP user(s) *and* the SMS API key used to send + Email events: all delivery/engagement boxes + Email headers: X-Monica-Message-Id + SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown) + + Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view. + Payload shape selects the processor (email vs sms_* vs inbound STOP). + """ + # Log/auth before parsing so request.body stays readable (HMAC + Grafana dump). + _log_webhook_request(request, channel="smtp2go") + if not _webhook_authorized( + request, secret=settings.SMTP2GO_WEBHOOK_SECRET or "" + ): + _log_webhook_auth_failed(request, channel="smtp2go") + return HttpResponseForbidden("invalid webhook token") + + payload = parse_webhook_payload(request) + if not payload: + payload = request.POST.dict() or {} + + kind = classify_smtp2go_payload(payload) + channel = { + "sms_inbound": "sms", + "sms": "sms", + "email": "email", + }.get(kind, "smtp2go") + logger.info("webhook_classified channel=%s kind=%s", channel, kind) + + if kind == "sms_inbound": + phone = ( + payload.get("from") + or payload.get("phone") + or payload.get("source_number") + or payload.get("destination_number") + or "" + ) + stopped = bool(phone) and record_sms_stop(str(phone)) + logger.info( + "webhook_processed channel=sms event_type=inbound_stop " + "opt_out=%s phone=%s", + stopped, + phone, + ) + return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped}) + + if kind == "unknown": + logger.warning( + "webhook_unmatched channel=smtp2go unrecognized_payload keys=%s", + sorted(str(k) for k in payload.keys()), + ) + return JsonResponse( + {"ok": False, "error": "unrecognized_payload", "channel": None}, + status=200, + ) + + try: + if kind == "sms": + event = process_smtp2go_sms_webhook(payload) + else: + event = process_smtp2go_email_webhook(payload) + except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h) + logger.exception("SMTP2GO %s webhook processing failed", kind) + _log_webhook_result(channel=channel, error=str(exc)) + return JsonResponse( + {"ok": False, "error": "processing_failed", "channel": channel}, + status=200, + ) + + _log_webhook_result(channel=channel, event=event) + return JsonResponse( + { + "ok": True, + "channel": channel, + "matched": bool(event and event.message_id), + "event_id": event.pk if event else None, + } + ) + + +# Legacy path names — same unified handler (keep SMTP2GO configs working). +email_webhook = smtp2go_webhook +sms_webhook = smtp2go_webhook + diff --git a/site/directmail/webhooks.py b/site/directmail/webhooks.py new file mode 100644 index 0000000..8d168ef --- /dev/null +++ b/site/directmail/webhooks.py @@ -0,0 +1,807 @@ +"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates.""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any + +from django.core.exceptions import ValidationError +from django.http import HttpRequest + +from contacts.models import Channel, Contact +from directmail.models import Message, ProviderEvent +from directmail.services import set_channel_consent + +logger = logging.getLogger(__name__) + +PROVIDER_EMAIL = "smtp2go_email" +PROVIDER_SMS = "smtp2go_sms" +PROVIDER_PCM = "pcm" +PROVIDER = PROVIDER_EMAIL # backward-compatible alias + +# Do not move a message backward to a weaker delivery / engagement state. +_STATUS_RANK = { + Message.Status.DRAFT: 0, + Message.Status.SCHEDULED: 1, + Message.Status.QUEUED: 2, + Message.Status.SENT: 3, + Message.Status.FAILED: 3, + Message.Status.DELIVERED: 4, + Message.Status.OPENED: 5, + Message.Status.CLICKED: 6, + Message.Status.BOUNCED: 7, + Message.Status.SUPPRESSED: 7, +} + +_ENGAGED_OR_DELIVERED = frozenset( + { + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + } +) +_SENT_OR_BETTER = frozenset( + { + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + } +) + +_MONICA_HEADER_KEYS = ( + "X-Monica-Message-Id", + "x-monica-message-id", + "X_Monica_Message_Id", + "monica-message-id", +) + +# SMTP2GO UI labels → canonical event strings from their docs. +_EMAIL_EVENT_ALIASES = { + "bounced": "bounce", + "rejected": "reject", + "opened": "open", + "clicked": "click", + "unsubscribed": "unsubscribe", + "resubscribed": "resubscribe", +} + +# API sms_events use short names (delivered); webhook body often uses sms_delivered. +# UI test labels / Opt-Out may arrive without the sms_ prefix. +_SMS_EVENT_ALIASES = { + "submitted": "sms_submitted", + "sending": "sms_sending", + "delivered": "sms_delivered", + "failed": "sms_failed", + "rejected": "sms_rejected", + "opt_out": "sms_opt_out", + "optout": "sms_opt_out", + "sms_optout": "sms_opt_out", +} + +_SMS_OPT_OUT_EVENTS = frozenset({"sms_opt_out"}) + + +def _as_str(value: Any) -> str: + """Coerce webhook field values to a stripped string (lists / None safe).""" + if value is None: + return "" + if isinstance(value, (list, tuple)): + if not value: + return "" + value = value[0] + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return str(value).strip() + + +def _json_safe(value: Any) -> Any: + """Ensure ProviderEvent.payload can be stored as JSON.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return str(value) + + +def _normalize_email_event(event: str) -> str: + event = (event or "").strip().lower() + return _EMAIL_EVENT_ALIASES.get(event, event) + + +def _normalize_sms_event(event: str) -> str: + """Map UI / short API names to docs canonical sms_* event strings.""" + event = (event or "").strip().lower().replace("-", "_").replace(" ", "_") + return _SMS_EVENT_ALIASES.get(event, event) + + +def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]: + """Accept JSON or form-encoded SMTP2GO webhook bodies.""" + content_type = (request.content_type or "").lower() + if "application/json" in content_type: + try: + data = json.loads(request.body.decode() or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + return data if isinstance(data, dict) else {} + # Form-encoded (SMTP2GO default) + return {key: request.POST.get(key) for key in request.POST.keys()} + + +def extract_monica_message_id(payload: dict[str, Any]) -> str: + """Pull our correlation id from flat keys or a nested headers object.""" + for key in _MONICA_HEADER_KEYS: + value = _as_str(payload.get(key)) + if value: + return value + + headers = payload.get("headers") or payload.get("email_headers") or {} + if isinstance(headers, dict): + for key in _MONICA_HEADER_KEYS: + value = _as_str(headers.get(key)) + if value: + return value + # Case-insensitive scan + lower_map = {str(k).lower(): v for k, v in headers.items()} + for key in _MONICA_HEADER_KEYS: + value = _as_str(lower_map.get(key.lower())) + if value: + return value + elif isinstance(headers, list): + # Some ESP shapes send [["X-Monica-Message-Id", "..."], ...] + for item in headers: + if isinstance(item, (list, tuple)) and len(item) >= 2: + if _as_str(item[0]).lower() in { + k.lower() for k in _MONICA_HEADER_KEYS + }: + value = _as_str(item[1]) + if value: + return value + elif isinstance(item, str) and ":" in item: + name, _, rest = item.partition(":") + if name.strip().lower() in {k.lower() for k in _MONICA_HEADER_KEYS}: + value = rest.strip() + if value: + return value + return "" + + +def _message_by_pk(pk: str) -> Message | None: + """Lookup Message by UUID pk without raising on malformed ids.""" + try: + uuid.UUID(str(pk)) + except (ValueError, AttributeError, TypeError): + return None + try: + return ( + Message.objects.select_related("contact", "campaign") + .filter(pk=pk) + .first() + ) + except (ValidationError, ValueError): + return None + + +def find_message_for_email_event(payload: dict[str, Any]) -> Message | None: + monica_id = extract_monica_message_id(payload) + if monica_id: + message = _message_by_pk(monica_id) + if message: + return message + + email_id = _as_str(payload.get("email_id") or payload.get("email-id")) + if email_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(provider_message_id=email_id) + .first() + ) + if message: + return message + + rcpt = _as_str(payload.get("rcpt")).lower() + if not rcpt: + recipients = payload.get("recipients") + if isinstance(recipients, str) and recipients.strip(): + rcpt = recipients.split(",")[0].strip().lower() + elif isinstance(recipients, list) and recipients: + rcpt = _as_str(recipients[0]).lower() + + if not rcpt: + return None + + contact = Contact.objects.filter(email__iexact=rcpt).first() + if not contact: + return None + + return ( + Message.objects.select_related("contact", "campaign") + .filter( + contact=contact, + channel=Channel.EMAIL, + status__in=[ + Message.Status.QUEUED, + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + Message.Status.FAILED, + Message.Status.BOUNCED, + ], + ) + .order_by("-sent_at", "-updated_at") + .first() + ) + + +def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "") -> None: + current_rank = _STATUS_RANK.get(message.status, 0) + new_rank = _STATUS_RANK.get(new_status, 0) + # Always allow bounce/suppress to overwrite delivered; allow delivered over sent. + if new_rank < current_rank and new_status not in { + Message.Status.BOUNCED, + Message.Status.SUPPRESSED, + Message.Status.FAILED, + }: + return + if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in { + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + }: + return + + fields = ["status", "updated_at"] + message.status = new_status + if error: + message.error = error[:2000] + fields.append("error") + elif new_status in _ENGAGED_OR_DELIVERED: + message.error = "" + fields.append("error") + message.save(update_fields=fields) + + +def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None: + event = _normalize_email_event(event) + bounce_kind = _as_str(payload.get("bounce")).lower() + err = _as_str(payload.get("message") or payload.get("context")) + + email_id = _as_str(payload.get("email_id") or payload.get("email-id")) + if email_id and message.provider_message_id != email_id: + message.provider_message_id = email_id + message.provider = PROVIDER_EMAIL + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if event == "processed": + if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + if event == "delivered": + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + + if event == "bounce": + status = Message.Status.BOUNCED + _maybe_upgrade_status( + message, + status, + error=err or f"{bounce_kind or 'unknown'} bounce", + ) + if bounce_kind == "hard": + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_hard_bounce", + ) + return + + if event == "reject": + _maybe_upgrade_status( + message, Message.Status.FAILED, error=err or "rejected by provider" + ) + return + + if event == "spam": + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error=err or "spam complaint" + ) + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_spam", + ) + return + + if event == "unsubscribe": + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error="provider unsubscribe" + ) + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_unsubscribe", + ) + return + + if event == "open": + # Open implies delivery; do not overwrite a stronger click status. + if message.status != Message.Status.CLICKED: + _maybe_upgrade_status(message, Message.Status.OPENED) + return + + if event == "click": + _maybe_upgrade_status(message, Message.Status.CLICKED) + return + + # resubscribe — event row only (status unchanged) + + +def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """ + Persist ProviderEvent and update Message delivery status when possible. + + Returns the stored event (even if message could not be matched). + """ + event = _normalize_email_event(_as_str(payload.get("event"))) + if not event: + logger.warning("SMTP2GO webhook missing event: %s", payload) + return None + + message = find_message_for_email_event(payload) + if message: + _apply_email_event(message, event, payload) + message.refresh_from_db() + else: + logger.info( + "SMTP2GO webhook unmatched event=%s rcpt=%s email_id=%s", + event, + payload.get("rcpt"), + payload.get("email_id"), + ) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_EMAIL, + event_type=event[:64], + payload=_json_safe(payload) if isinstance(payload, dict) else {}, + ) + + +def normalize_phone(value: str) -> str: + return "".join(ch for ch in (value or "") if ch.isdigit()) + + +def _sms_provider_message_id(payload: dict[str, Any]) -> str: + """SMS unique id from docs (`message_id`). Never use webhook `id`.""" + return _as_str(payload.get("message_id") or payload.get("sms_id")) + + +def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None: + provider_id = _sms_provider_message_id(payload) + if provider_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(channel=Channel.SMS, provider_message_id=provider_id) + .first() + ) + if message: + return message + + # Outbound delivery events use destination_number (recipient). + # Do not use source_number / from — those are the pool or inbound reply. + raw_phone = ( + payload.get("destination_number") + or payload.get("to") + or payload.get("phone") + or "" + ) + digits = normalize_phone(_as_str(raw_phone)) + if len(digits) < 7: + return None + + # Match last 10 digits so +1 / formatting differences still hit. + tail = digits[-10:] + contacts = Contact.objects.exclude(phone="").only("id", "phone") + contact = None + for row in contacts.iterator(): + if normalize_phone(row.phone).endswith(tail): + contact = row + break + if not contact: + return None + + return ( + Message.objects.select_related("contact", "campaign") + .filter( + contact=contact, + channel=Channel.SMS, + status__in=[ + Message.Status.QUEUED, + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.FAILED, + Message.Status.SUPPRESSED, + ], + ) + .order_by("-sent_at", "-updated_at") + .first() + ) + + +def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None: + event = _normalize_sms_event(event) + err = _as_str( + payload.get("message") + or payload.get("status_code") + or payload.get("context") + ) + + provider_id = _sms_provider_message_id(payload) + if provider_id and message.provider_message_id != provider_id: + message.provider_message_id = provider_id + message.provider = PROVIDER_SMS + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if event in {"sms_sending", "sms_submitted"}: + if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + if event == "sms_delivered": + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + + if event in {"sms_failed", "sms_rejected"}: + _maybe_upgrade_status( + message, + Message.Status.FAILED, + error=err or event, + ) + return + + if event in _SMS_OPT_OUT_EVENTS: + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error="sms opt-out" + ) + set_channel_consent( + message.contact, + Channel.SMS, + opted_in=False, + reason="smtp2go_sms_opt_out", + ) + return + + +def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """Persist SMS delivery/opt-out ProviderEvent and update Message when matched.""" + event = _normalize_sms_event(_as_str(payload.get("event"))) + if not event: + logger.warning("SMTP2GO SMS webhook missing event: %s", payload) + return None + + message = find_message_for_sms_event(payload) + if message: + _apply_sms_event(message, event, payload) + message.refresh_from_db() + else: + # Opt-out with no matched campaign message still suppresses by phone. + if event in _SMS_OPT_OUT_EVENTS: + phone = _as_str( + payload.get("destination_number") + or payload.get("from") + or payload.get("source_number") + ) + if phone: + from directmail.services import record_sms_stop + + record_sms_stop(phone) + logger.info( + "SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s", + event, + payload.get("destination_number"), + payload.get("message_id"), + ) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_SMS, + event_type=event[:64], + payload=_json_safe(payload) if isinstance(payload, dict) else {}, + ) + + +def is_inbound_sms_stop(payload: dict[str, Any]) -> bool: + """True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE). + + SMTP2GO auto-handles STOP/UNSUB/UNSUBSCRIBE replies; this catches the + inbound gateway POST shape (no ``event`` field) when configured to + forward replies. Prefer this over relying on a webhook Opt-Out event — + the API ``sms_events`` list does not include opt-out. + """ + if payload.get("event"): + return False + text = _as_str( + payload.get("text") + or payload.get("message") + or payload.get("message_content") + ).upper() + return text in {"STOP", "UNSUBSCRIBE", "UNSUB", "CANCEL", "END", "QUIT"} + + +def classify_smtp2go_payload(payload: dict[str, Any]) -> str: + """ + Decide email vs SMS vs inbound STOP for a unified SMTP2GO webhook URL. + + Returns one of: ``sms_inbound``, ``sms``, ``email``, ``unknown``. + """ + if not isinstance(payload, dict) or not payload: + return "unknown" + + if is_inbound_sms_stop(payload): + return "sms_inbound" + + raw = _as_str(payload.get("event")).lower().replace("-", "_").replace(" ", "_") + if not raw: + return "unknown" + + # Explicit SMS forms (docs sms_* + API/UI short names that are SMS-only). + if raw.startswith("sms_") or raw in { + "submitted", + "sending", + "opt_out", + "optout", + }: + return "sms" + + email_event = _normalize_email_event(raw) + if email_event in { + "processed", + "open", + "click", + "bounce", + "spam", + "unsubscribe", + "resubscribe", + }: + return "email" + + has_dest = bool(_as_str(payload.get("destination_number"))) + has_rcpt = bool(_as_str(payload.get("rcpt"))) + has_email_id = bool( + _as_str(payload.get("email_id") or payload.get("email-id")) + ) + has_monica = bool(extract_monica_message_id(payload)) + has_from_address = bool(_as_str(payload.get("from_address"))) + has_sms_id = bool(_as_str(payload.get("message_id") or payload.get("sms_id"))) + has_sms_body = bool( + _as_str(payload.get("message_content") or payload.get("source_number")) + ) + + email_leaning = has_rcpt or has_email_id or has_monica or has_from_address + sms_leaning = has_dest or has_sms_body or ( + has_sms_id and not email_leaning + ) + + # Ambiguous short names shared by email + SMS API lists. + if raw in {"delivered", "failed", "rejected", "reject"}: + if sms_leaning and not email_leaning: + return "sms" + if email_leaning: + return "email" + # Bare reject without fields → email docs name; rejected alone → sms lean default + if raw == "reject": + return "email" + if raw == "rejected": + return "sms" + return "email" + + if email_leaning: + return "email" + if sms_leaning: + return "sms" + # Default: email (historical primary SMTP2GO traffic). + return "email" + + +def _pcm_event_type(payload: dict[str, Any]) -> str: + for key in ("event", "eventType", "event_type", "type", "status"): + value = payload.get(key) + if value: + return str(value).strip() + return "unknown" + + +def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None: + """Correlate PCM webhook to Message via extRefNbr or orderID.""" + ext = ( + payload.get("extRefNbr") + or payload.get("ext_ref_nbr") + or payload.get("externalReference") + or "" + ) + if not ext and isinstance(payload.get("recipient"), dict): + ext = payload["recipient"].get("extRefNbr") or "" + ext = str(ext).strip() + if ext: + message = _message_by_pk(ext) + if message: + return message + + order_id = ( + payload.get("orderID") + or payload.get("orderId") + or payload.get("order_id") + or "" + ) + order_id = str(order_id).strip() + if order_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(provider_message_id=order_id, channel=Channel.POSTCARD) + .first() + ) + if message: + return message + return None + + +def _apply_pcm_status(message: Message, status: str, payload: dict[str, Any]) -> None: + status_norm = (status or "").strip().lower() + err = ( + payload.get("message") + or payload.get("error") + or payload.get("reason") + or "" + ) + err = str(err).strip() + + order_id = ( + payload.get("orderID") + or payload.get("orderId") + or payload.get("order_id") + or "" + ) + if order_id and message.provider_message_id != str(order_id): + message.provider_message_id = str(order_id) + message.provider = PROVIDER_PCM + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if status_norm in {"delivered"}: + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + if status_norm in {"undeliverable", "returned"}: + _maybe_upgrade_status( + message, + Message.Status.BOUNCED, + error=err or "undeliverable", + ) + return + if status_norm in {"canceled", "cancelled"}: + _maybe_upgrade_status( + message, Message.Status.FAILED, error=err or "canceled" + ) + return + if status_norm in {"pending", "processing", "processed", "mailed", "intransit", "in_transit"}: + if message.status in { + Message.Status.QUEUED, + Message.Status.DRAFT, + Message.Status.SCHEDULED, + }: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + +def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """Record a PCM Integrations postcard event and advance Message status.""" + if not payload: + return None + + # Nested data wrappers some webhook UIs use. + if "data" in payload and isinstance(payload["data"], dict): + inner = dict(payload["data"]) + for key in ("event", "eventType", "type"): + if key in payload and key not in inner: + inner[key] = payload[key] + payload = inner + + event_type = _pcm_event_type(payload) + message = find_message_for_pcm_event(payload) + if message: + status_for_apply = ( + payload.get("status") + or payload.get("orderStatus") + or event_type + ) + _apply_pcm_status(message, str(status_for_apply), payload) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_PCM, + event_type=event_type[:64], + payload=payload, + ) + + +def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]: + """Build bar heights (percent) for the campaign engagement chart.""" + peak = max((value for _, value in metrics), default=0) + bars: list[dict] = [] + for label, value in metrics: + if peak <= 0: + pct = 12 if value == 0 else 100 + else: + pct = max(12, int(round((value / peak) * 100))) if value else 8 + bars.append({"label": label, "value": value, "pct": pct}) + return bars + + +def campaign_engagement_stats(campaign) -> dict: + """Aggregate delivery + open/click counts for the campaign report.""" + messages_qs = campaign.messages.all() + statuses = list(messages_qs.values_list("status", flat=True)) + message_ids = list(messages_qs.values_list("pk", flat=True)) + + events = ProviderEvent.objects.filter(message_id__in=message_ids) + open_message_ids = set( + events.filter(event_type__iexact="open").values_list("message_id", flat=True) + ) + click_message_ids = set( + events.filter(event_type__iexact="click").values_list("message_id", flat=True) + ) + + # Status-based engagement also counts (webhook may set opened/clicked). + status_opened = sum(1 for s in statuses if s in {"opened", "clicked"}) + status_clicked = sum(1 for s in statuses if s == "clicked") + opens = max(len(open_message_ids), status_opened) + clicks = max(len(click_message_ids), status_clicked) + + sent = sum(1 for s in statuses if s in _SENT_OR_BETTER) + delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED) + failed = sum(1 for s in statuses if s in {"failed", "bounced"}) + suppressed = sum(1 for s in statuses if s == "suppressed") + + if campaign.channel == Channel.EMAIL: + chart_metrics = [ + ("Sent", sent), + ("Delivered", delivered), + ("Opens", opens), + ("Clicks", clicks), + ("Failed", failed), + ] + else: + chart_metrics = [ + ("Sent", sent), + ("Delivered", delivered), + ("Failed", failed), + ("Suppressed", suppressed), + ] + + return { + "total": len(statuses), + "sent": sent, + "delivered": delivered, + "failed": failed, + "bounced": sum(1 for s in statuses if s == "bounced"), + "suppressed": suppressed, + "opens": opens, + "clicks": clicks, + "open_events": events.filter(event_type__iexact="open").count(), + "click_events": events.filter(event_type__iexact="click").count(), + "chart_bars": _engagement_chart_bars(chart_metrics), + } diff --git a/site/email_sms/__init__.py b/site/email_sms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/admin.py b/site/email_sms/admin.py new file mode 100644 index 0000000..352d9b4 --- /dev/null +++ b/site/email_sms/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin + +from email_sms.models import Campaign, Message, MessageTemplate, ProviderEvent + + +@admin.register(MessageTemplate) +class MessageTemplateAdmin(admin.ModelAdmin): + list_display = ("name", "channel", "created_at") + list_filter = ("channel",) + + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ("status", "provider", "provider_message_id", "sent_at") + + +@admin.register(Campaign) +class CampaignAdmin(admin.ModelAdmin): + list_display = ( + "name", + "channel", + "audience", + "status", + "scheduled_for", + "created_at", + ) + list_filter = ("channel", "audience", "status") + inlines = [MessageInline] + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("campaign", "contact", "channel", "status", "scheduled_for") + list_filter = ("channel", "status") + + +@admin.register(ProviderEvent) +class ProviderEventAdmin(admin.ModelAdmin): + list_display = ("provider", "event_type", "created_at") diff --git a/site/email_sms/apps.py b/site/email_sms/apps.py new file mode 100644 index 0000000..9843790 --- /dev/null +++ b/site/email_sms/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class EmailSmsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "email_sms" + verbose_name = "Email & SMS" + + def ready(self): + from email_sms import hooks + + hooks.register() diff --git a/site/email_sms/channels/__init__.py b/site/email_sms/channels/__init__.py new file mode 100644 index 0000000..419401d --- /dev/null +++ b/site/email_sms/channels/__init__.py @@ -0,0 +1,24 @@ +"""Channel dispatch — email / SMS.""" + +from dataclasses import dataclass + +from contacts.models import Channel +from email_sms.models import Message +from email_sms.providers.email.smtp2go import send_email +from email_sms.providers.sms.smtp2go import send_sms + + +@dataclass +class ProviderResult: + provider: str + provider_id: str + + +def dispatch_message(message: Message) -> ProviderResult: + if message.channel == Channel.EMAIL: + result = send_email(message) + return ProviderResult(provider="smtp2go_email", provider_id=result) + if message.channel == Channel.SMS: + result = send_sms(message) + return ProviderResult(provider="smtp2go_sms", provider_id=result) + raise ValueError(f"Unsupported channel: {message.channel}") diff --git a/site/email_sms/hooks.py b/site/email_sms/hooks.py new file mode 100644 index 0000000..3ad98be --- /dev/null +++ b/site/email_sms/hooks.py @@ -0,0 +1,49 @@ +"""Register portal nav, dashboard widgets, and due-work dispatch.""" + +from django.utils import timezone + +from core.registry import ( + register_dashboard_collector, + register_dispatcher, + register_feature, + register_portal_nav, +) + + +def register() -> None: + register_feature("email_sms") + register_portal_nav( + section="campaigns", + label="Campaigns", + url_name="email_sms:campaign_list", + group="Outreach", + order=20, + ) + register_dashboard_collector(_dashboard) + register_dispatcher(_dispatch_due) + + +def _dashboard(request) -> dict: + from email_sms.models import Campaign + + upcoming = Campaign.objects.exclude( + status__in=[Campaign.Status.COMPLETED, Campaign.Status.CANCELLED] + ).order_by("scheduled_for", "-created_at")[:5] + return {"upcoming_campaigns": upcoming} + + +def _dispatch_due() -> int: + from email_sms.models import Message + from email_sms.tasks import send_campaign_message + + now = timezone.now() + enqueued = 0 + for message in Message.objects.filter( + status=Message.Status.SCHEDULED, + scheduled_for__lte=now, + ).iterator(): + message.status = Message.Status.QUEUED + message.save(update_fields=["status", "updated_at"]) + send_campaign_message.enqueue(message_id=str(message.pk)) + enqueued += 1 + return enqueued diff --git a/site/email_sms/management/__init__.py b/site/email_sms/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/management/commands/__init__.py b/site/email_sms/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/migrations/0001_initial.py b/site/email_sms/migrations/0001_initial.py new file mode 100644 index 0000000..c45fc87 --- /dev/null +++ b/site/email_sms/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# 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 = [ + ('contacts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MessageTemplate', + 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)), + ('name', models.CharField(max_length=120)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('subject', models.CharField(blank=True, max_length=255)), + ('body', models.TextField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Campaign', + 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)), + ('name', models.CharField(max_length=120)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('audience', models.CharField(blank=True, choices=[('email_opt_in', 'Mailing list · email opt-in'), ('sms_opt_in', 'Mailing list · SMS opt-in')], default='', max_length=32)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)), + ('scheduled_for', models.DateTimeField(blank=True, null=True)), + ('subject_override', models.CharField(blank=True, max_length=255)), + ('body_override', models.TextField(blank=True)), + ('notify_sent_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_sms_campaigns_created', to=settings.AUTH_USER_MODEL)), + ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_sms_campaigns', to='email_sms.messagetemplate')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Message', + 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)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('postcard', 'Postcard')], max_length=16)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('opened', 'Opened'), ('clicked', 'Clicked'), ('failed', 'Failed'), ('bounced', 'Bounced'), ('suppressed', 'Suppressed')], default='draft', max_length=16)), + ('provider', models.CharField(blank=True, max_length=64)), + ('provider_message_id', models.CharField(blank=True, max_length=255)), + ('scheduled_for', models.DateTimeField(blank=True, null=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('error', models.TextField(blank=True)), + ('body_snapshot', models.TextField(blank=True)), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='email_sms.campaign')), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_sms_messages', to='contacts.contact')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ProviderEvent', + 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)), + ('provider', models.CharField(max_length=64)), + ('event_type', models.CharField(max_length=64)), + ('payload', models.JSONField(blank=True, default=dict)), + ('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events', to='email_sms.message')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/site/email_sms/migrations/__init__.py b/site/email_sms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/models.py b/site/email_sms/models.py new file mode 100644 index 0000000..9111369 --- /dev/null +++ b/site/email_sms/models.py @@ -0,0 +1,117 @@ +from django.conf import settings +from django.db import models + +from contacts.models import Channel, Contact +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class MessageTemplate(UUIDPrimaryKeyModel, TimeStampedModel): + name = models.CharField(max_length=120) + channel = models.CharField(max_length=16, choices=Channel.choices) + subject = models.CharField(max_length=255, blank=True) + body = models.TextField() + + def __str__(self) -> str: + return f"{self.name} ({self.channel})" + + +class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCHEDULED = "scheduled", "Scheduled" + SENDING = "sending", "Sending" + COMPLETED = "completed", "Completed" + CANCELLED = "cancelled", "Cancelled" + + class Audience(models.TextChoices): + EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in" + SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in" + + name = models.CharField(max_length=120) + channel = models.CharField(max_length=16, choices=Channel.choices) + audience = models.CharField( + max_length=32, + choices=Audience.choices, + blank=True, + default="", + ) + template = models.ForeignKey( + MessageTemplate, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="email_sms_campaigns", + ) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + scheduled_for = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="email_sms_campaigns_created", + ) + subject_override = models.CharField(max_length=255, blank=True) + body_override = models.TextField(blank=True) + # Set when completion summary email is sent (campaign COMPLETED). + notify_sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.name + + +class Message(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCHEDULED = "scheduled", "Scheduled" + QUEUED = "queued", "Queued" + SENT = "sent", "Sent" + DELIVERED = "delivered", "Delivered" + OPENED = "opened", "Opened" + CLICKED = "clicked", "Clicked" + FAILED = "failed", "Failed" + BOUNCED = "bounced", "Bounced" + SUPPRESSED = "suppressed", "Suppressed" + + campaign = models.ForeignKey( + Campaign, on_delete=models.CASCADE, related_name="messages" + ) + contact = models.ForeignKey( + Contact, on_delete=models.CASCADE, related_name="email_sms_messages" + ) + channel = models.CharField(max_length=16, choices=Channel.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + provider = models.CharField(max_length=64, blank=True) + provider_message_id = models.CharField(max_length=255, blank=True) + scheduled_for = models.DateTimeField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + error = models.TextField(blank=True) + body_snapshot = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.channel} → {self.contact} ({self.status})" + + +class ProviderEvent(TimeStampedModel): + message = models.ForeignKey( + Message, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="events", + ) + provider = models.CharField(max_length=64) + event_type = models.CharField(max_length=64) + payload = models.JSONField(default=dict, blank=True) + + diff --git a/site/email_sms/providers/__init__.py b/site/email_sms/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/providers/email/__init__.py b/site/email_sms/providers/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/providers/email/smtp2go.py b/site/email_sms/providers/email/smtp2go.py new file mode 100644 index 0000000..06279d7 --- /dev/null +++ b/site/email_sms/providers/email/smtp2go.py @@ -0,0 +1,64 @@ +"""SMTP2GO email via Django's SMTP backend (mail.smtp2go.com).""" + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template + +from contacts.models import Channel +from email_sms.services import one_click_unsubscribe_url, preferences_url, render_merge_tags +from public.email_branding import ( + campaign_body_to_email_html, + campaign_body_to_plain_text, + email_brand_context, +) + +# Reported back on SMTP2GO webhooks when this header is selected in webhook settings. +MONICA_MESSAGE_HEADER = "X-Monica-Message-Id" + + +def send_email(message) -> str: + contact = message.contact + if not contact.email: + raise ValueError("Contact has no email address") + + campaign = message.campaign + subject = campaign.subject_override or ( + campaign.template.subject if campaign.template else f"Message from {settings.SITE_NAME}" + ) + body = message.body_snapshot or campaign.body_override or ( + campaign.template.body if campaign.template else "" + ) + subject = render_merge_tags(subject, contact) + body = render_merge_tags(body, contact) + + site = (settings.PUBLIC_SITE_URL or "").rstrip("/") + prefs_path = preferences_url(str(contact.pk), Channel.EMAIL) + one_click_path = one_click_unsubscribe_url(str(contact.pk), Channel.EMAIL) + prefs_url = f"{site}{prefs_path}" if site else prefs_path + one_click_url = f"{site}{one_click_path}" if site else one_click_path + + ctx = email_brand_context( + title=subject, + content=campaign_body_to_plain_text(body), + content_html=campaign_body_to_email_html(body), + prefs_url=prefs_url, + one_click_url=one_click_url, + ) + text_content = get_template("emails/marketing_email.txt").render(ctx) + html_content = get_template("emails/marketing_email.html").render(ctx) + + email = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[contact.email], + headers={ + "List-Unsubscribe": f"<{one_click_url}>", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + MONICA_MESSAGE_HEADER: str(message.pk), + }, + ) + email.attach_alternative(html_content, "text/html") + email.send(fail_silently=False) + # Placeholder until SMTP2GO webhook supplies the real email_id. + return f"smtp-{message.pk}" diff --git a/site/email_sms/providers/sms/__init__.py b/site/email_sms/providers/sms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/email_sms/providers/sms/smtp2go.py b/site/email_sms/providers/sms/smtp2go.py new file mode 100644 index 0000000..b9e6fdb --- /dev/null +++ b/site/email_sms/providers/sms/smtp2go.py @@ -0,0 +1,90 @@ +"""SMTP2GO SMS REST API.""" + +import logging +import re + +import requests +from django.conf import settings + +from email_sms.services import render_merge_tags + +logger = logging.getLogger(__name__) + + +def _format_destination(phone: str) -> str: + """Normalize stored phone to E.164-ish string SMTP2GO accepts.""" + digits = re.sub(r"\D", "", phone or "") + if not digits: + raise ValueError("Contact has no phone number") + if phone.strip().startswith("+") and digits: + return f"+{digits}" + # US 10-digit local numbers → +1… + if len(digits) == 10: + return f"+1{digits}" + if len(digits) == 11 and digits.startswith("1"): + return f"+{digits}" + return f"+{digits}" + + +def _provider_error_detail(response: requests.Response) -> str: + """Prefer SMTP2GO JSON error text over bare HTTP reason.""" + try: + data = response.json() + except ValueError: + text = (response.text or "").strip() + return text[:500] if text else response.reason + nested = data.get("data") if isinstance(data.get("data"), dict) else {} + err = nested.get("error") or data.get("error") or "" + code = nested.get("error_code") or data.get("error_code") or "" + if err and code: + return f"{err} ({code})" + return str(err or code or response.reason) + + +def send_sms(message) -> str: + contact = message.contact + if not contact.phone: + raise ValueError("Contact has no phone number") + + api_key = settings.SMTP2GO_SMS_API_KEY + if not api_key: + raise RuntimeError("SMTP2GO_SMS_API_KEY is not configured") + + campaign = message.campaign + body = message.body_snapshot or campaign.body_override or ( + campaign.template.body if campaign.template else "" + ) + body = render_merge_tags(body, contact) + destination = _format_destination(contact.phone) + + # Current SMTP2GO /v3/sms/send schema: destination[] + content. + payload = { + "api_key": api_key, + "destination": [destination], + "content": body[:1600], + } + response = requests.post( + settings.SMTP2GO_SMS_API_URL, + json=payload, + timeout=30, + ) + if not response.ok: + detail = _provider_error_detail(response) + raise requests.HTTPError( + f"{response.status_code} Client Error: {detail} for url: {response.url}", + response=response, + ) + + data = response.json() if response.content else {} + nested = data.get("data") if isinstance(data.get("data"), dict) else {} + messages = nested.get("messages") if isinstance(nested.get("messages"), list) else [] + first = messages[0] if messages and isinstance(messages[0], dict) else {} + return str( + first.get("message_id") + or nested.get("sms_id") + or nested.get("message_id") + or data.get("sms_id") + or data.get("message_id") + or data.get("request_id") + or f"sms-{message.pk}" + ) diff --git a/site/email_sms/services.py b/site/email_sms/services.py new file mode 100644 index 0000000..b66cdc6 --- /dev/null +++ b/site/email_sms/services.py @@ -0,0 +1,361 @@ +"""Campaign draft/send helpers for email and SMS.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from django.urls import reverse +from django.utils import timezone + +from contacts.consent import ( # noqa: F401 — re-export for tests + providers + channel_preferences, + contact_may_receive, + make_unsubscribe_token, + one_click_unsubscribe_url, + opted_in_contacts, + parse_unsubscribe_token, + preferences_url, + process_unsubscribe_token, + record_sms_stop, + set_channel_consent, + set_channel_preferences, + unsubscribe_all, +) +from contacts.models import Channel, Contact +from core.scheduling import parse_scheduled_for +from email_sms.models import Campaign, Message, MessageTemplate + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractBaseUser + +AUDIENCE_CHANNEL = { + Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL, + Campaign.Audience.SMS_OPT_IN: Channel.SMS, +} + +# {{first_name}} preferred; {first_name} also accepted (composer hint legacy). +_MERGE_TAG_RE = re.compile( + r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}" + r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}", + re.IGNORECASE, +) + +REMOVABLE_MESSAGE_STATUSES = frozenset( + { + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.FAILED, + } +) + + +def render_merge_tags(text: str, contact: Contact | None) -> str: + """Replace personalization tags with contact field values.""" + if not text: + return text or "" + first = (getattr(contact, "first_name", None) or "").strip() if contact else "" + last = (getattr(contact, "last_name", None) or "").strip() if contact else "" + email = (getattr(contact, "email", None) or "").strip() if contact else "" + phone = (getattr(contact, "phone", None) or "").strip() if contact else "" + full = f"{first} {last}".strip() + values = { + "first_name": first, + "last_name": last, + "email": email, + "phone": phone, + "full_name": full, + } + + def _replace(match: re.Match[str]) -> str: + key = (match.group(1) or match.group(2) or "").lower() + return values.get(key, "") + + return _MERGE_TAG_RE.sub(_replace, text) + + +def message_is_removable(message: Message) -> bool: + return message.status in REMOVABLE_MESSAGE_STATUSES + + +def channel_for_audience(audience: str) -> str: + try: + return AUDIENCE_CHANNEL[audience] + except KeyError as exc: + raise ValueError(f"Unknown audience: {audience}") from exc + + +def create_campaign_draft( + *, + name: str, + audience: str, + subject: str = "", + body: str = "", + scheduled_for=None, + created_by: AbstractBaseUser | None = None, + template: MessageTemplate | None = None, +) -> Campaign: + """Persist a draft campaign and per-recipient Message stubs.""" + channel = channel_for_audience(audience) + campaign = Campaign.objects.create( + name=name, + channel=channel, + audience=audience, + status=Campaign.Status.DRAFT, + scheduled_for=scheduled_for, + subject_override=subject, + body_override=body, + created_by=created_by, + template=template, + ) + contacts = list(opted_in_contacts(channel)) + Message.objects.bulk_create( + [ + Message( + campaign=campaign, + contact=contact, + channel=channel, + status=Message.Status.DRAFT, + scheduled_for=scheduled_for, + body_snapshot=body, + ) + for contact in contacts + ] + ) + return campaign + + +def campaign_notify_recipient(campaign: Campaign) -> str: + """Email address for the realtor summary (created_by, else CONTACT_EMAIL).""" + from django.conf import settings + + user = campaign.created_by + if user is not None: + email = (getattr(user, "email", None) or "").strip() + if email: + return email + return (settings.CONTACT_EMAIL or "").strip() + + +def send_campaign_completion_notify(campaign: Campaign) -> bool: + """ + One-shot summary email when a campaign finishes sending. + + Returns True if mail was sent (or already sent earlier). + """ + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.db.models import Count, Q + from django.template.loader import get_template + from django.urls import reverse + + from public.email_branding import email_brand_context + + campaign.refresh_from_db() + if campaign.notify_sent_at: + return True + if campaign.status != Campaign.Status.COMPLETED: + return False + + to_email = campaign_notify_recipient(campaign) + if not to_email: + return False + + # Claim the notify slot atomically so concurrent refresh calls only send once. + now = timezone.now() + claimed = Campaign.objects.filter( + pk=campaign.pk, + status=Campaign.Status.COMPLETED, + notify_sent_at__isnull=True, + ).update(notify_sent_at=now) + if not claimed: + return True + campaign.notify_sent_at = now + + counts = campaign.messages.aggregate( + sent=Count( + "id", + filter=Q( + status__in=[ + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + ] + ), + ), + delivered=Count( + "id", + filter=Q( + status__in=[ + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + ] + ), + ), + failed=Count( + "id", + filter=Q( + status__in=[ + Message.Status.FAILED, + Message.Status.BOUNCED, + ] + ), + ), + suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)), + total=Count("id"), + ) + report_path = reverse("email_sms:campaign_detail", kwargs={"pk": campaign.pk}) + public = (settings.PUBLIC_SITE_URL or "").rstrip("/") + report_url = f"{public}{report_path}" if public else report_path + + subject = f"Campaign sent: {campaign.name}" + ctx = email_brand_context( + subject=subject, + campaign_name=campaign.name, + channel_display=campaign.get_channel_display(), + total=counts["total"], + sent=counts["sent"], + delivered=counts["delivered"], + failed=counts["failed"], + suppressed=counts["suppressed"], + report_url=report_url, + ) + text_content = get_template("emails/campaign_complete.txt").render(ctx) + html_content = get_template("emails/campaign_complete.html").render(ctx) + email = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + email.attach_alternative(html_content, "text/html") + try: + email.send(fail_silently=False) + except Exception: # noqa: BLE001 — don't block completion on mail errors + import logging + + logging.getLogger(__name__).exception( + "Campaign completion notify failed for %s", campaign.pk + ) + Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None) + campaign.notify_sent_at = None + return False + + return True + + +def refresh_campaign_status(campaign: Campaign) -> Campaign: + """Set campaign to completed when no messages remain pending.""" + campaign.refresh_from_db() + pending = campaign.messages.filter( + status__in=[ + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.QUEUED, + ] + ).exists() + if pending: + return campaign + if campaign.status == Campaign.Status.SENDING: + campaign.status = Campaign.Status.COMPLETED + campaign.save(update_fields=["status", "updated_at"]) + send_campaign_completion_notify(campaign) + return campaign + + +def enqueue_campaign_send(campaign: Campaign) -> int: + """ + Queue draft/scheduled/failed messages for send. + + Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console. + """ + from email_sms.tasks import send_campaign_message + + sendable = list( + campaign.messages.filter( + status__in=[ + Message.Status.DRAFT, + Message.Status.SCHEDULED, + Message.Status.FAILED, + ] + ) + ) + if not sendable: + return 0 + + campaign.status = Campaign.Status.SENDING + campaign.save(update_fields=["status", "updated_at"]) + + enqueued = 0 + for message in sendable: + message.status = Message.Status.QUEUED + message.save(update_fields=["status", "updated_at"]) + try: + send_campaign_message.enqueue(message_id=str(message.pk)) + except Exception: # noqa: BLE001 — task already persisted FAILED + pass + enqueued += 1 + + refresh_campaign_status(campaign) + return enqueued + + +def send_campaign_test_email(campaign: Campaign, to_email: str) -> None: + """Send one preview copy to ``to_email`` without touching recipient rows.""" + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import get_template + + from public.email_branding import ( + campaign_body_to_email_html, + campaign_body_to_plain_text, + email_brand_context, + ) + + if campaign.channel != Channel.EMAIL: + raise ValueError("Test send is only available for email campaigns.") + subject = campaign.subject_override or ( + campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}" + ) + body = campaign.body_override or ( + campaign.template.body if campaign.template_id else "" + ) + if not subject.strip(): + raise ValueError("Campaign has no subject.") + if not body.strip(): + raise ValueError("Campaign has no body.") + + # Preview merge tags using first recipient when available. + sample = ( + campaign.messages.select_related("contact") + .order_by("created_at") + .first() + ) + sample_contact = sample.contact if sample else None + subject = render_merge_tags(subject, sample_contact) + body = render_merge_tags(body, sample_contact) + + notice = ( + "This is a test send from the portal. " + "Recipient list was not notified." + ) + ctx = email_brand_context( + title=f"[TEST] {subject}", + content=f"{campaign_body_to_plain_text(body)}\n\n{notice}", + content_html=( + f"{campaign_body_to_email_html(body)}" + f'

{notice}

' + ), + ) + text_content = get_template("emails/marketing_email.txt").render(ctx) + html_content = get_template("emails/marketing_email.html").render(ctx) + email = EmailMultiAlternatives( + subject=f"[TEST] {subject}", + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + email.attach_alternative(html_content, "text/html") + email.send(fail_silently=False) \ No newline at end of file diff --git a/site/email_sms/tasks.py b/site/email_sms/tasks.py new file mode 100644 index 0000000..2c9fb6b --- /dev/null +++ b/site/email_sms/tasks.py @@ -0,0 +1,52 @@ +from django.tasks import task +from django.utils import timezone + +from email_sms.channels import dispatch_message +from email_sms.models import Message +from email_sms.services import contact_may_receive, refresh_campaign_status + + +@task +def send_campaign_message(message_id: str) -> None: + campaign = None + try: + message = Message.objects.select_related("contact", "campaign").get( + pk=message_id + ) + except Message.DoesNotExist: + return + + campaign = message.campaign + + if not contact_may_receive(message.contact, message.channel): + message.status = Message.Status.SUPPRESSED + message.error = "Contact opted out or suppressed" + message.save(update_fields=["status", "error", "updated_at"]) + refresh_campaign_status(campaign) + return + + try: + result = dispatch_message(message) + message.status = Message.Status.SENT + message.provider = result.provider + message.provider_message_id = result.provider_id + message.sent_at = timezone.now() + message.error = "" + message.save( + update_fields=[ + "status", + "provider", + "provider_message_id", + "sent_at", + "error", + "updated_at", + ] + ) + except Exception as exc: # noqa: BLE001 — persist provider failures + message.status = Message.Status.FAILED + message.error = str(exc)[:2000] + message.save(update_fields=["status", "error", "updated_at"]) + refresh_campaign_status(campaign) + raise + + refresh_campaign_status(campaign) diff --git a/site/email_sms/templates/email_sms/campaign_detail.html b/site/email_sms/templates/email_sms/campaign_detail.html new file mode 100644 index 0000000..1b3a913 --- /dev/null +++ b/site/email_sms/templates/email_sms/campaign_detail.html @@ -0,0 +1,299 @@ +{% extends "portal_base.html" %} +{% block title %}{{ campaign.name }} · Campaign{% endblock %} +{% block topbar_title %}{{ campaign.name }}{% endblock %} +{% block portal_content %} +
+
+ {{ campaign.get_status_display }} + {{ campaign.get_channel_display }} + {% if campaign.scheduled_for %} + Scheduled {{ campaign.scheduled_for|date:"M j, g:i A" }} + {% endif %} + Live · updates every 10s +
+ ← Campaigns +
+ +{% if campaign.channel == "email" or can_send %} +
+

Send

+
+ {% if campaign.channel == "email" %} +
+ {% csrf_token %} +
+ + +
Sends one [TEST] copy. Does not notify the recipient list.
+
+
+ +
+
+ {% endif %} + {% if can_send %} +
+ {% csrf_token %} + +

+ {% if campaign.channel == "postcard" %} + Enqueues draft / scheduled / failed messages via PCM Integrations. + {% else %} + Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline). + {% endif %} +

+
+ {% endif %} +
+
+{% endif %} + +
+
+
Messages
+
{{ stats.total }}
+
+
+
Sent
+
{{ stats.sent }}
+
+
+
Delivered
+
{{ stats.delivered }}
+
+ {% if campaign.channel == "email" %} +
+
Opens
+
{{ stats.opens }}
+
+
+
Clicks
+
{{ stats.clicks }}
+
+ {% endif %} +
+
Bounced / failed
+
{{ stats.failed }}
+
+
+
Suppressed
+
{{ stats.suppressed }}
+
+
+ +
+
+

Engagement

+
+ {% if campaign.channel == "email" %} +

+ Unique recipients: {{ stats.opens }} opened · + {{ stats.clicks }} clicked + ({{ stats.open_events }} open events / + {{ stats.click_events }} click events from SMTP2GO). +

+ {% elif campaign.channel == "sms" %} +

+ Delivery status updates from SMTP2GO SMS webhooks. +

+ {% else %} +

+ Postcard status updates from PCM Integrations webhooks. +

+ {% endif %} + +
+
+
+

{{ events_title }}

+
+ + + + {% for event in recent_events %} + + + + + + {% empty %} + + {% endfor %} + +
WhenEventContact
{{ event.created_at|date:"M j, g:i A" }}{{ event.event_type }}{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}
{{ events_empty|safe }}
+
+
+
+ +
+
+

Recipients

+ + {{ page_obj.paginator.count }} total + {% if page_obj.paginator.num_pages > 1 %} + · page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + {% endif %} + +
+
+ + + + + + {% for message in recipient_messages %} + + + + + + + + {% empty %} + + {% endfor %} + +
ContactStatusProvider idError
+
{{ message.contact }}
+ {% if message.destination %} +
{{ message.destination }}
+ {% endif %} +
{{ message.get_status_display }}{{ message.provider_message_id|default:"—" }}{{ message.error|truncatechars:60|default:"—" }} + {% if message.can_remove %} +
+ {% csrf_token %} + + +
+ {% else %} + + {% endif %} +
No messages on this campaign.
+
+ {% if page_obj.paginator.num_pages > 1 %} +
+ {% if page_obj.has_previous %} + ← Prev + {% endif %} + Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next → + {% endif %} +
+ {% endif %} +
+{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/email_sms/templates/email_sms/campaign_list.html b/site/email_sms/templates/email_sms/campaign_list.html new file mode 100644 index 0000000..6ec4f3b --- /dev/null +++ b/site/email_sms/templates/email_sms/campaign_list.html @@ -0,0 +1,350 @@ +{% extends "portal_base.html" %} +{% block title %}Campaigns · Portal{% endblock %} +{% block topbar_title %}Campaign composer{% endblock %} +{% block extra_head %} + + +{% endblock %} +{% block portal_content %} +
+ Email + SMS +
+ +
+
+

Compose

+
+ {% if form_errors %} +
    + {% for err in form_errors %} +
  • {{ err }}
  • + {% endfor %} +
+ {% endif %} +
+ {% csrf_token %} +
+ + +
+
+ + +
Email only — ignored for SMS
+
+
+ +
+ +
Bold, fonts, sizes, links, images · merge tags: {% templatetag openvariable %}first_name{% templatetag closevariable %}, {% templatetag openvariable %}last_name{% templatetag closevariable %}
+
+ +
+
+ + +
+
+ + +
Date & time · leave blank to keep as unscheduled draft
+
+
+

Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.

+ +
+
+
+
+

Preview

+
+
+
Preview updates as you type.
+ +
+

After send → open the campaign report for delivery & engagement.

+
+
+
+ +
+

Recent campaigns

+
+ + + + + + + + + + + + {% for campaign in campaigns %} + + + + + + + + {% empty %} + + {% endfor %} + +
NameChannelStatusScheduled
{{ campaign.name }}{{ campaign.get_channel_display }}{{ campaign.get_status_display }}{% if campaign.scheduled_for %}{{ campaign.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}Report
No campaigns yet.
+
+
+{% endblock %} +{% block extra_js %} + + +{% endblock %} diff --git a/site/email_sms/tests.py b/site/email_sms/tests.py new file mode 100644 index 0000000..2156eab --- /dev/null +++ b/site/email_sms/tests.py @@ -0,0 +1,1163 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from contacts.models import Channel, ConsentRecord, Contact, Suppression +from email_sms.models import Campaign, Message, MessageTemplate, ProviderEvent +from email_sms.services import ( + channel_preferences, + contact_may_receive, + create_campaign_draft, + make_unsubscribe_token, + opted_in_contacts, + set_channel_consent, +) + + +class PreferenceCenterTests(TestCase): + def setUp(self): + self.client = Client() + self.contact = Contact.objects.create( + email="jordan.lee@example.com", + phone="5550184420", + first_name="Jordan", + last_name="Lee", + ) + for channel in Channel: + set_channel_consent( + self.contact, channel.value, opted_in=True, reason="test_seed" + ) + self.token = make_unsubscribe_token(str(self.contact.pk), Channel.EMAIL) + + def test_preferences_get_shows_form(self): + url = reverse("public:unsubscribe", kwargs={"token": self.token}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Email marketing") + self.assertContains(response, "SMS updates") + self.assertContains(response, "Postcard mailings") + self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL)) + + def test_invalid_token(self): + url = reverse("public:unsubscribe", kwargs={"token": "not-a-valid-token"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "invalid or has expired") + + def test_one_click_query_opts_out_email_only(self): + url = reverse("public:unsubscribe", kwargs={"token": self.token}) + response = self.client.get(f"{url}?one_click=1") + self.assertEqual(response.status_code, 302) + self.contact.refresh_from_db() + self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL)) + self.assertTrue(contact_may_receive(self.contact, Channel.SMS)) + self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD)) + + def test_one_click_endpoint(self): + url = reverse( + "public:unsubscribe_one_click", kwargs={"token": self.token} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL)) + + def test_one_click_post_rfc8058(self): + url = reverse( + "public:unsubscribe_one_click", kwargs={"token": self.token} + ) + response = self.client.post( + url, {"List-Unsubscribe": "One-Click"} + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL)) + + def test_save_preferences_partial_opt_out(self): + url = reverse("public:unsubscribe", kwargs={"token": self.token}) + response = self.client.post( + url, + { + "action": "save", + "consent_email": "1", + # SMS unchecked + "consent_postcard": "1", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(contact_may_receive(self.contact, Channel.EMAIL)) + self.assertFalse(contact_may_receive(self.contact, Channel.SMS)) + self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD)) + sms_sup = Suppression.objects.get( + contact=self.contact, channel=Channel.SMS + ) + self.assertTrue(sms_sup.active) + + def test_unsubscribe_all(self): + url = reverse("public:unsubscribe", kwargs={"token": self.token}) + response = self.client.post(url, {"action": "unsubscribe_all"}) + self.assertEqual(response.status_code, 302) + for channel in Channel: + self.assertFalse(contact_may_receive(self.contact, channel.value)) + + +class SmsStopWebhookTests(TestCase): + def setUp(self): + self.client = Client() + self.contact = Contact.objects.create( + email="avery@example.com", + phone="5550142291", + ) + set_channel_consent( + self.contact, Channel.SMS, opted_in=True, reason="test" + ) + + def test_stop_webhook(self): + url = reverse("email_sms:sms_webhook") + response = self.client.post( + url, {"from": "5550142291", "text": "STOP"} + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(contact_may_receive(self.contact, Channel.SMS)) + + +class PortalConsentToggleTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="monica", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="monica", password="test-pass-123") + self.contact = Contact.objects.create( + email="sam@example.com", + first_name="Sam", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + + def test_portal_can_toggle_consent(self): + url = reverse("contacts:detail", kwargs={"pk": self.contact.pk}) + response = self.client.post( + url, + { + "first_name": "Sam", + "last_name": "", + "email": "sam@example.com", + "phone": "", + "notes": "updated", + "consent_sms": "1", + "consent_postcard": "1", + # email unchecked → opt out + }, + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL)) + self.assertTrue(contact_may_receive(self.contact, Channel.SMS)) + self.assertTrue(contact_may_receive(self.contact, Channel.POSTCARD)) + email_consent = ConsentRecord.objects.get( + contact=self.contact, channel=Channel.EMAIL + ) + self.assertEqual(email_consent.reason, "portal_manual") + + def test_portal_can_update_profile(self): + url = reverse("contacts:detail", kwargs={"pk": self.contact.pk}) + response = self.client.post( + url, + { + "first_name": "Samantha", + "last_name": "Lee", + "email": "sam.lee@example.com", + "phone": "6305550100", + "address_line1": "10 Main St", + "address_city": "Naperville", + "address_state": "IL", + "address_zip": "60540", + "notes": "VIP", + "consent_email": "1", + }, + ) + self.assertEqual(response.status_code, 302) + self.contact.refresh_from_db() + self.assertEqual(self.contact.first_name, "Samantha") + self.assertEqual(self.contact.last_name, "Lee") + self.assertEqual(self.contact.email, "sam.lee@example.com") + self.assertEqual(self.contact.phone, "6305550100") + self.assertEqual(self.contact.postal_address.get("line1"), "10 Main St") + self.assertEqual(self.contact.notes, "VIP") + + +class CampaignDraftSaveTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="composer", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="composer", password="test-pass-123") + self.contact = Contact.objects.create( + email="pat@example.com", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + + def test_save_draft_creates_campaign_and_messages(self): + url = reverse("email_sms:campaign_list") + response = self.client.post( + url, + { + "name": "Spring tips", + "subject": "Hello sellers", + "body": "Hi {first_name}", + "audience": Campaign.Audience.EMAIL_OPT_IN, + "scheduled_for": "2026-08-10T09:30", + }, + ) + campaign = Campaign.objects.get(name="Spring tips") + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, + reverse("email_sms:campaign_detail", kwargs={"pk": campaign.pk}), + ) + self.assertEqual(campaign.status, Campaign.Status.DRAFT) + self.assertEqual(campaign.channel, Channel.EMAIL) + self.assertEqual(campaign.audience, Campaign.Audience.EMAIL_OPT_IN) + self.assertIsNotNone(campaign.scheduled_for) + self.assertEqual(campaign.messages.count(), 1) + msg = campaign.messages.get() + self.assertEqual(msg.contact, self.contact) + self.assertEqual(msg.status, Message.Status.DRAFT) + + def test_save_draft_requires_subject_for_email(self): + url = reverse("email_sms:campaign_list") + response = self.client.post( + url, + { + "name": "No subject", + "subject": "", + "body": "Body only", + "audience": Campaign.Audience.EMAIL_OPT_IN, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Subject is required") + self.assertFalse(Campaign.objects.filter(name="No subject").exists()) + + def test_create_campaign_draft_helper(self): + campaign = create_campaign_draft( + name="Helper draft", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Subj", + body="Body", + created_by=self.user, + ) + self.assertEqual(campaign.messages.count(), 1) + self.assertEqual(campaign.created_by, self.user) + + +class CampaignSendTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="sender", password="test-pass-123", email="sender@example.com" + ) + self.client = Client() + self.client.login(username="sender", password="test-pass-123") + self.contact = Contact.objects.create( + email="pat@example.com", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + self.campaign = create_campaign_draft( + name="Send me", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello", + body="Body text", + created_by=self.user, + ) + + def test_detail_shows_send_controls(self): + url = reverse( + "email_sms:campaign_detail", kwargs={"pk": self.campaign.pk} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Send test email") + self.assertContains(response, "Send now to recipients") + + def test_test_send_uses_locmem(self): + from django.core import mail + + url = reverse( + "email_sms:campaign_test_send", kwargs={"pk": self.campaign.pk} + ) + with self.settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="noreply@example.com", + ): + response = self.client.post(url, {"test_email": "me@example.com"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ["me@example.com"]) + self.assertTrue(mail.outbox[0].subject.startswith("[TEST]")) + self.assertTrue(mail.outbox[0].alternatives) + self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html") + # Recipients untouched + self.assertEqual( + self.campaign.messages.filter(status=Message.Status.DRAFT).count(), 1 + ) + + def test_send_now_marks_messages_sent(self): + from django.core import mail + + url = reverse( + "email_sms:campaign_send", kwargs={"pk": self.campaign.pk} + ) + with self.settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="noreply@example.com", + PUBLIC_SITE_URL="http://testserver", + ): + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.campaign.refresh_from_db() + self.assertEqual(self.campaign.status, Campaign.Status.COMPLETED) + self.assertEqual( + self.campaign.messages.filter(status=Message.Status.SENT).count(), 1 + ) + # Recipient campaign email + one realtor completion summary. + self.assertEqual(len(mail.outbox), 2) + self.assertIsNotNone(self.campaign.notify_sent_at) + summary = mail.outbox[1] + self.assertIn("Campaign sent:", summary.subject) + self.assertEqual(summary.to, [self.user.email]) + self.assertTrue(mail.outbox[0].alternatives) + self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html") + self.assertIn("#00626c", mail.outbox[0].alternatives[0][0]) + self.assertTrue(summary.alternatives) + + +class Smtp2goEmailWebhookTests(TestCase): + def setUp(self): + self.client = Client() + self.contact = Contact.objects.create( + email="pat@example.com", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + self.campaign = create_campaign_draft( + name="Webhook campaign", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello", + body="Body", + ) + self.message = self.campaign.messages.get() + self.message.status = Message.Status.SENT + self.message.provider_message_id = f"smtp-{self.message.pk}" + self.message.save() + + def test_delivered_updates_status_via_monica_header(self): + url = reverse("email_sms:email_webhook") + response = self.client.post( + url, + data={ + "event": "delivered", + "rcpt": "pat@example.com", + "email_id": "smtp2go-abc-123", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.DELIVERED) + self.assertEqual(self.message.provider_message_id, "smtp2go-abc-123") + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="delivered" + ).exists() + ) + + def test_open_records_event_sets_opened(self): + self.message.status = Message.Status.DELIVERED + self.message.save(update_fields=["status"]) + url = reverse("email_sms:email_webhook") + self.client.post( + url, + data={ + "event": "open", + "rcpt": "pat@example.com", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.OPENED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="open" + ).exists() + ) + + def test_click_sets_clicked_from_sent(self): + url = reverse("email_sms:email_webhook") + self.client.post( + url, + data={ + "event": "click", + "rcpt": "pat@example.com", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.CLICKED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="click" + ).exists() + ) + + def test_open_does_not_downgrade_clicked(self): + self.message.status = Message.Status.CLICKED + self.message.save(update_fields=["status"]) + url = reverse("email_sms:email_webhook") + self.client.post( + url, + data={ + "event": "open", + "rcpt": "pat@example.com", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.CLICKED) + + def test_hard_bounce_suppresses_contact(self): + url = reverse("email_sms:email_webhook") + self.client.post( + url, + data={ + "event": "bounce", + "bounce": "hard", + "rcpt": "pat@example.com", + "message": "550 user unknown", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.BOUNCED) + self.assertFalse(contact_may_receive(self.contact, Channel.EMAIL)) + + def test_webhook_secret_required_when_configured(self): + url = reverse("email_sms:email_webhook") + with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"): + denied = self.client.post(url, data={"event": "delivered"}) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + f"{url}?token=s3cret", + data={ + "event": "delivered", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.assertEqual(ok.status_code, 200) + + def test_status_json_includes_opens(self): + User = get_user_model() + user = User.objects.create_user(username="viewer", password="test-pass-123") + self.client.login(username="viewer", password="test-pass-123") + ProviderEvent.objects.create( + message=self.message, + provider="smtp2go_email", + event_type="open", + payload={"event": "open"}, + ) + url = reverse( + "email_sms:campaign_status_json", kwargs={"pk": self.campaign.pk} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["stats"]["opens"], 1) + self.assertIn("chart_bars", data["stats"]) + labels = [bar["label"] for bar in data["stats"]["chart_bars"]] + self.assertEqual(labels, ["Sent", "Delivered", "Opens", "Clicks", "Failed"]) + + def test_bearer_authorization_header(self): + import json + + url = reverse("email_sms:email_webhook") + with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"): + denied = self.client.post( + url, + data=json.dumps( + { + "event": "delivered", + "X-Monica-Message-Id": str(self.message.pk), + } + ), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer wrong", + ) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + url, + data=json.dumps( + { + "event": "delivered", + "X-Monica-Message-Id": str(self.message.pk), + "email_id": "e-1", + } + ), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer s3cret", + ) + self.assertEqual(ok.status_code, 200) + + def test_invalid_monica_header_does_not_500(self): + """SMTP2GO tests often send a non-UUID sample custom header.""" + import json + + url = reverse("email_sms:email_webhook") + response = self.client.post( + url, + data=json.dumps( + { + "event": "delivered", + "rcpt": "nobody@example.com", + "X-Monica-Message-Id": "Headers Unavailable", + "email_id": "smtp2go-test-id", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["ok"]) + self.assertFalse(data["matched"]) + self.assertTrue( + ProviderEvent.objects.filter(event_type="delivered").exists() + ) + + def test_ui_event_label_aliases(self): + url = reverse("email_sms:email_webhook") + response = self.client.post( + url, + data={ + "event": "bounced", + "bounce": "soft", + "X-Monica-Message-Id": str(self.message.pk), + }, + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.BOUNCED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="bounce" + ).exists() + ) + + +class Smtp2goSmsWebhookTests(TestCase): + def setUp(self): + self.client = Client() + self.contact = Contact.objects.create( + email="pat@example.com", + phone="+15550142291", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.SMS, opted_in=True, reason="test" + ) + self.campaign = create_campaign_draft( + name="SMS blast", + audience=Campaign.Audience.SMS_OPT_IN, + subject="", + body="Hi there", + ) + self.message = self.campaign.messages.get() + self.message.status = Message.Status.SENT + self.message.provider_message_id = "sms-provider-99" + self.message.save() + + def test_sms_delivered_by_message_id(self): + url = reverse("email_sms:sms_webhook") + import json + + response = self.client.post( + url, + data=json.dumps( + { + "event": "sms_delivered", + "message_id": "sms-provider-99", + "destination_number": "5550142291", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.DELIVERED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="sms_delivered" + ).exists() + ) + + def test_sms_failed_by_phone_fallback(self): + url = reverse("email_sms:sms_webhook") + import json + + self.message.provider_message_id = "other-id" + self.message.save(update_fields=["provider_message_id"]) + response = self.client.post( + url, + data=json.dumps( + { + "event": "sms_failed", + "message_id": "unknown", + "destination_number": "+1 (555) 014-2291", + "status_code": "undeliverable", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.FAILED) + + def test_sms_short_event_name_delivered(self): + """API sms_events use short names; body may omit sms_ prefix.""" + url = reverse("email_sms:sms_webhook") + import json + + response = self.client.post( + url, + data=json.dumps( + { + "event": "delivered", + "message_id": "sms-provider-99", + "destination_number": "5550142291", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.DELIVERED) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="sms_delivered" + ).exists() + ) + + def test_sms_ui_label_and_form_encoded(self): + url = reverse("email_sms:sms_webhook") + self.message.status = Message.Status.QUEUED + self.message.save(update_fields=["status"]) + response = self.client.post( + url, + data={ + "event": "Submitted", + "message_id": "sms-provider-99", + "destination_number": "5550142291", + }, + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.SENT) + self.assertTrue( + ProviderEvent.objects.filter( + message=self.message, event_type="sms_submitted" + ).exists() + ) + + def test_sms_rejected_marks_failed(self): + url = reverse("email_sms:sms_webhook") + import json + + response = self.client.post( + url, + data=json.dumps( + { + "event": "sms_rejected", + "message_id": "sms-provider-99", + "destination_number": "5550142291", + "status_code": "blocked", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.FAILED) + + def test_sms_webhook_id_is_not_used_as_message_id(self): + """Docs: `id` is the webhook notification id, not the SMS id.""" + url = reverse("email_sms:sms_webhook") + import json + + response = self.client.post( + url, + data=json.dumps( + { + "event": "sms_delivered", + "id": "sms-provider-99", + "destination_number": "19999999999", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.message.refresh_from_db() + self.assertEqual(self.message.status, Message.Status.SENT) + self.assertTrue( + ProviderEvent.objects.filter( + message=None, event_type="sms_delivered" + ).exists() + ) + + def test_sms_opt_out_unmatched_suppresses_by_phone(self): + url = reverse("email_sms:sms_webhook") + import json + + self.message.delete() + response = self.client.post( + url, + data=json.dumps( + { + "event": "opt-out", + "message_id": "unknown", + "destination_number": "+15550142291", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(contact_may_receive(self.contact, Channel.SMS)) + + def test_inbound_stop_still_works(self): + url = reverse("email_sms:sms_webhook") + response = self.client.post( + url, {"from": "5550142291", "text": "STOP"} + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(contact_may_receive(self.contact, Channel.SMS)) + + def test_inbound_unsub_keyword(self): + url = reverse("email_sms:sms_webhook") + response = self.client.post( + url, {"from": "5550142291", "text": "UNSUB"} + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(contact_may_receive(self.contact, Channel.SMS)) + + def test_sms_webhook_requires_bearer_when_secret_set(self): + url = reverse("email_sms:sms_webhook") + import json + + with self.settings(SMTP2GO_WEBHOOK_SECRET="s3cret"): + denied = self.client.post( + url, + data=json.dumps({"event": "sms_delivered", "message_id": "x"}), + content_type="application/json", + ) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + url, + data=json.dumps( + { + "event": "sms_delivered", + "message_id": "sms-provider-99", + } + ), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer s3cret", + ) + self.assertEqual(ok.status_code, 200) + + +class UnifiedSmtp2goWebhookTests(TestCase): + def setUp(self): + self.client = Client() + self.email_contact = Contact.objects.create( + email="pat@example.com", + first_name="Pat", + ) + set_channel_consent( + self.email_contact, Channel.EMAIL, opted_in=True, reason="test" + ) + self.email_campaign = create_campaign_draft( + name="Unified email", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello", + body="Body", + ) + self.email_message = self.email_campaign.messages.get() + self.email_message.status = Message.Status.SENT + self.email_message.save(update_fields=["status"]) + + self.sms_contact = Contact.objects.create( + email="sms.pat@example.com", + phone="+15550142291", + first_name="Pat", + ) + set_channel_consent( + self.sms_contact, Channel.SMS, opted_in=True, reason="test" + ) + self.sms_campaign = create_campaign_draft( + name="Unified SMS", + audience=Campaign.Audience.SMS_OPT_IN, + subject="", + body="Hi", + ) + self.sms_message = self.sms_campaign.messages.get() + self.sms_message.status = Message.Status.SENT + self.sms_message.provider_message_id = "sms-unified-1" + self.sms_message.save() + + def test_classify_helpers(self): + from email_sms.webhooks import classify_smtp2go_payload + + self.assertEqual( + classify_smtp2go_payload({"from": "555", "text": "STOP"}), + "sms_inbound", + ) + self.assertEqual( + classify_smtp2go_payload( + {"event": "sms_delivered", "message_id": "x"} + ), + "sms", + ) + self.assertEqual( + classify_smtp2go_payload( + { + "event": "delivered", + "destination_number": "5550142291", + "message_id": "x", + } + ), + "sms", + ) + self.assertEqual( + classify_smtp2go_payload( + { + "event": "delivered", + "rcpt": "pat@example.com", + "email_id": "e1", + } + ), + "email", + ) + self.assertEqual( + classify_smtp2go_payload({"event": "click", "rcpt": "a@b.c"}), + "email", + ) + + def test_unified_url_routes_email_and_sms(self): + import json + + url = reverse("email_sms:smtp2go_webhook") + email_resp = self.client.post( + url, + data=json.dumps( + { + "event": "click", + "rcpt": "pat@example.com", + "X-Monica-Message-Id": str(self.email_message.pk), + } + ), + content_type="application/json", + ) + self.assertEqual(email_resp.status_code, 200) + self.assertEqual(email_resp.json()["channel"], "email") + self.email_message.refresh_from_db() + self.assertEqual(self.email_message.status, Message.Status.CLICKED) + + sms_resp = self.client.post( + url, + data=json.dumps( + { + "event": "sms_delivered", + "message_id": "sms-unified-1", + "destination_number": "5550142291", + } + ), + content_type="application/json", + ) + self.assertEqual(sms_resp.status_code, 200) + self.assertEqual(sms_resp.json()["channel"], "sms") + self.sms_message.refresh_from_db() + self.assertEqual(self.sms_message.status, Message.Status.DELIVERED) + + def test_legacy_email_path_still_accepts_sms(self): + """Legacy /email/ alias is the unified handler.""" + import json + + url = reverse("email_sms:email_webhook") + response = self.client.post( + url, + data=json.dumps( + { + "event": "delivered", + "message_id": "sms-unified-1", + "destination_number": "5550142291", + } + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["channel"], "sms") + self.sms_message.refresh_from_db() + self.assertEqual(self.sms_message.status, Message.Status.DELIVERED) + + +class CampaignHtmlBodyTests(TestCase): + def test_html_body_preserved_in_email(self): + from django.core import mail + + User = get_user_model() + user = User.objects.create_user( + username="htmlsender", password="test-pass-123", email="s@example.com" + ) + contact = Contact.objects.create(email="pat@example.com", first_name="Pat") + set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test") + campaign = create_campaign_draft( + name="HTML tip", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Styled", + body='

Hello

', + created_by=user, + ) + from email_sms.providers.email.smtp2go import send_email + + send_email(campaign.messages.get()) + self.assertEqual(len(mail.outbox), 1) + html = mail.outbox[0].alternatives[0][0] + self.assertIn("Hello", html) + self.assertIn('src="https://example.com/a.png"', html) + + +class StoredFileUploadTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="uploader", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="uploader", password="test-pass-123") + + def test_upload_stores_bytes_in_database(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + from core.models import StoredFile + + png = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00" + b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18" + b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + upload = SimpleUploadedFile("dot.png", png, content_type="image/png") + response = self.client.post( + reverse("email_sms:campaign_image_upload"), + {"image": upload}, + ) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertIn("url", payload) + stored = StoredFile.objects.get() + self.assertEqual(bytes(stored.data), png) + self.assertEqual(stored.content_type, "image/png") + self.assertEqual(stored.uploaded_by, self.user) + + fetch = self.client.get( + reverse("core:stored_file", kwargs={"pk": stored.pk}) + ) + self.assertEqual(fetch.status_code, 200) + self.assertEqual(fetch["Content-Type"], "image/png") + self.assertEqual(b"".join(fetch.streaming_content), png) + + + +class MergeTagTests(TestCase): + def test_render_merge_tags_double_and_single_braces(self): + from email_sms.services import render_merge_tags + + contact = Contact.objects.create( + email="pat@example.com", + first_name="Pat", + last_name="Lee", + phone="6305551212", + ) + text = "Hi {{first_name}} {{last_name}} / {first_name} {email}" + out = render_merge_tags(text, contact) + self.assertEqual(out, "Hi Pat Lee / Pat pat@example.com") + + def test_email_send_substitutes_merge_tags(self): + from django.core import mail + + from email_sms.providers.email.smtp2go import send_email + from email_sms.services import create_campaign_draft, set_channel_consent + + User = get_user_model() + user = User.objects.create_user(username="merge", password="x") + contact = Contact.objects.create( + email="pat@example.com", first_name="Pat", last_name="Lee" + ) + set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test") + campaign = create_campaign_draft( + name="Merge", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello {{first_name}}", + body="

Dear {{first_name}} {{last_name}}

", + created_by=user, + ) + send_email(campaign.messages.get()) + self.assertEqual(mail.outbox[0].subject, "Hello Pat") + html = mail.outbox[0].alternatives[0][0] + self.assertIn("Dear Pat Lee", html) + self.assertNotIn("{{first_name}}", html) + + +class CampaignRecipientTableTests(TestCase): + def setUp(self): + from email_sms.services import create_campaign_draft, set_channel_consent + + User = get_user_model() + self.user = User.objects.create_user( + username="recip", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="recip", password="test-pass-123") + self.contacts = [] + for i in range(3): + c = Contact.objects.create( + email=f"c{i}@example.com", first_name=f"Name{i}" + ) + set_channel_consent(c, Channel.EMAIL, opted_in=True, reason="test") + self.contacts.append(c) + self.campaign = create_campaign_draft( + name="Recipients", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hi", + body="Body", + created_by=self.user, + ) + + def test_detail_does_not_flash_message_objects(self): + url = reverse( + "email_sms:campaign_detail", kwargs={"pk": self.campaign.pk} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "email →") + self.assertContains(response, "Recipients") + self.assertContains(response, "Remove") + + def test_remove_recipient(self): + msg = self.campaign.messages.first() + url = reverse( + "email_sms:campaign_message_remove", + kwargs={"pk": self.campaign.pk, "message_id": msg.pk}, + ) + response = self.client.post(url) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.campaign.messages.count(), 2) + + +class Smtp2goSmsSendTests(TestCase): + def setUp(self): + self.contact = Contact.objects.create( + phone="330-402-2675", + first_name="Rufus", + last_name="Firefly", + ) + set_channel_consent( + self.contact, Channel.SMS, opted_in=True, reason="test" + ) + self.campaign = create_campaign_draft( + name="SMS blast", + audience=Campaign.Audience.SMS_OPT_IN, + body="Hello {{first_name}}", + ) + self.message = self.campaign.messages.get(contact=self.contact) + + def test_format_destination_us_local(self): + from email_sms.providers.sms.smtp2go import _format_destination + + self.assertEqual(_format_destination("330-402-2675"), "+13304022675") + self.assertEqual(_format_destination("+1 (330) 402-2675"), "+13304022675") + self.assertEqual(_format_destination("13304022675"), "+13304022675") + + def test_send_sms_uses_destination_and_content(self): + from unittest.mock import MagicMock, patch + + from email_sms.providers.sms.smtp2go import send_sms + + resp = MagicMock() + resp.ok = True + resp.content = b'{"request_id":"req-1","data":{"messages":[{"message_id":"sms-abc","destination":"+13304022675","status":"queued"}],"total_sent":1}}' + resp.json.return_value = { + "request_id": "req-1", + "data": { + "messages": [ + { + "message_id": "sms-abc", + "destination": "+13304022675", + "status": "queued", + } + ], + "total_sent": 1, + }, + } + + with self.settings(SMTP2GO_SMS_API_KEY="api-test-key"): + with patch( + "email_sms.providers.sms.smtp2go.requests.post", + return_value=resp, + ) as post: + provider_id = send_sms(self.message) + + self.assertEqual(provider_id, "sms-abc") + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["api_key"], "api-test-key") + self.assertEqual(payload["destination"], ["+13304022675"]) + self.assertEqual(payload["content"], "Hello Rufus") + self.assertNotIn("to", payload) + self.assertNotIn("text", payload) + + def test_send_sms_includes_api_error_body(self): + from unittest.mock import MagicMock, patch + + import requests + + from email_sms.providers.sms.smtp2go import send_sms + + resp = MagicMock() + resp.ok = False + resp.status_code = 400 + resp.url = "https://api.smtp2go.com/v3/sms/send" + resp.reason = "Bad Request" + resp.text = '{"data":{"error":"Missing required field","error_code":"E_ApiResponseCodes.INVALID_REQUEST"}}' + resp.json.return_value = { + "data": { + "error": "Missing required field", + "error_code": "E_ApiResponseCodes.INVALID_REQUEST", + } + } + + with self.settings(SMTP2GO_SMS_API_KEY="api-test-key"): + with patch( + "email_sms.providers.sms.smtp2go.requests.post", + return_value=resp, + ): + with self.assertRaises(requests.HTTPError) as ctx: + send_sms(self.message) + + self.assertIn("Missing required field", str(ctx.exception)) + self.assertIn("INVALID_REQUEST", str(ctx.exception)) diff --git a/site/email_sms/urls.py b/site/email_sms/urls.py new file mode 100644 index 0000000..b7adf6f --- /dev/null +++ b/site/email_sms/urls.py @@ -0,0 +1,34 @@ +from django.urls import path + +from email_sms import views + +app_name = "email_sms" + +urlpatterns = [ + path("campaigns/", views.campaign_list, name="campaign_list"), + path("campaigns//", views.campaign_detail, name="campaign_detail"), + path( + "campaigns//status.json", + views.campaign_status_json, + name="campaign_status_json", + ), + path("campaigns//send/", views.campaign_send, name="campaign_send"), + path( + "campaigns//test-send/", + views.campaign_test_send, + name="campaign_test_send", + ), + path( + "campaigns//messages//remove/", + views.campaign_message_remove, + name="campaign_message_remove", + ), + path( + "campaigns/upload-image/", + views.campaign_image_upload, + name="campaign_image_upload", + ), + path("webhooks/smtp2go/", views.smtp2go_webhook, name="smtp2go_webhook"), + path("webhooks/sms/", views.sms_webhook, name="sms_webhook"), + path("webhooks/email/", views.email_webhook, name="email_webhook"), +] diff --git a/site/email_sms/views.py b/site/email_sms/views.py new file mode 100644 index 0000000..dfbf74c --- /dev/null +++ b/site/email_sms/views.py @@ -0,0 +1,621 @@ +import hashlib +import hmac +import io +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.core.paginator import Paginator +from django.core.validators import validate_email +from django.http import HttpResponseForbidden, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from contacts.consent import opted_in_contacts, record_sms_stop +from contacts.models import Channel +from core.models import StoredFile +from core.scheduling import parse_scheduled_for +from email_sms.models import Campaign, Message, ProviderEvent +from email_sms.services import ( + create_campaign_draft, + enqueue_campaign_send, + message_is_removable, + send_campaign_test_email, +) +from email_sms.webhooks import ( + PROVIDER_EMAIL, + PROVIDER_SMS, + campaign_engagement_stats, + classify_smtp2go_payload, + parse_webhook_payload, + process_smtp2go_email_webhook, + process_smtp2go_sms_webhook, +) + +logger = logging.getLogger(__name__) + +RECIPIENTS_PER_PAGE = 50 + +_ALLOWED_IMAGE_TYPES = frozenset( + {"image/jpeg", "image/png", "image/gif", "image/webp"} +) +_MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +def _audience_choices() -> list[tuple[str, str]]: + """Labeled audience options with live opted-in counts.""" + rows = [ + (Campaign.Audience.EMAIL_OPT_IN, Channel.EMAIL, "email"), + (Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"), + ] + choices = [] + for value, channel, label in rows: + count = opted_in_contacts(channel).count() + noun = "contact" if count == 1 else "contacts" + choices.append( + (value, f"Mailing list · {label} opt-in ({count} {noun})") + ) + return choices + + + + + + +def _format_postal_address(addr: dict | None) -> str: + if not addr: + return "" + line1 = (addr.get("line1") or "").strip() + line2 = (addr.get("line2") or "").strip() + city = (addr.get("city") or "").strip() + state = (addr.get("state") or "").strip() + zip_code = (addr.get("zip") or "").strip() + city_line = ", ".join(p for p in (city, state) if p) + if zip_code: + city_line = f"{city_line} {zip_code}".strip() + return ", ".join(p for p in (line1, line2, city_line) if p) + + +def _message_destination(message) -> str: + """Channel-specific destination shown on the recipients table.""" + contact = message.contact + channel = message.channel or (message.campaign.channel if message.campaign_id else "") + if channel == Channel.EMAIL: + return (contact.email or "").strip() + if channel == Channel.SMS: + return (contact.phone or "").strip() + if channel == Channel.POSTCARD: + return _format_postal_address(contact.postal_address) + return "" + + +def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]: + """Return (provider codes, panel title, empty-state hint) for campaign channel.""" + if campaign.channel == Channel.SMS: + return ( + [PROVIDER_SMS], + "Recent SMTP2GO events", + "No webhook events yet. SMTP2GO must POST SMS events to " + "/portal/email_sms/webhooks/smtp2go/.", + ) + return ( + [PROVIDER_EMAIL], + "Recent SMTP2GO events", + "No webhook events yet. SMTP2GO must POST opens/clicks to " + "/portal/email_sms/webhooks/smtp2go/ " + "(see email_sms README). SMTP2GO’s own “Clicked” feed does not fill " + "this table by itself.", + ) + + +def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict: + qs = campaign.messages.select_related("contact").order_by( + "contact__first_name", "contact__last_name", "created_at" + ) + paginator = Paginator(qs, RECIPIENTS_PER_PAGE) + page_obj = paginator.get_page(page) + recipient_messages = list(page_obj.object_list) + for msg in recipient_messages: + msg.destination = _message_destination(msg) + msg.can_remove = message_is_removable(msg) + stats = campaign_engagement_stats(campaign) + providers, events_title, events_empty = _events_provider_filter(campaign) + recent_events = ( + ProviderEvent.objects.filter( + message__campaign=campaign, + provider__in=providers, + ) + .select_related("message", "message__contact") + .order_by("-created_at")[:25] + ) + return { + "recipient_messages": recipient_messages, + "page_obj": page_obj, + "stats": stats, + "recent_events": recent_events, + "events_title": events_title, + "events_empty": events_empty, + } + + +def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool: + """Accept Bearer / ?token= matching any configured secret (constant-time).""" + candidates: list[str] = [] + if secrets: + candidates.extend(s.strip() for s in secrets if (s or "").strip()) + single = (secret or "").strip() + if single and single not in candidates: + candidates.append(single) + if not candidates: + return True + + token = (request.GET.get("token") or "").strip() + auth = (request.headers.get("Authorization") or "").strip() + bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else "" + # Common signature-header names PCM / gateways may use (raw secret or HMAC). + sig_headers = ( + request.headers.get("X-PCM-Signature") + or request.headers.get("X-Webhook-Signature") + or request.headers.get("X-Signature") + or request.headers.get("X-Hub-Signature-256") + or "" + ).strip() + if sig_headers.lower().startswith("sha256="): + sig_headers = sig_headers[7:].strip() + + body = request.body or b"" + for candidate in candidates: + if token and hmac.compare_digest(token, candidate): + return True + if bearer and hmac.compare_digest(bearer, candidate): + return True + if sig_headers: + if hmac.compare_digest(sig_headers, candidate): + return True + digest = hmac.new( + candidate.encode("utf-8"), body, hashlib.sha256 + ).hexdigest() + if hmac.compare_digest(sig_headers, digest): + return True + return False + + +def _log_webhook_request(request, *, channel: str) -> None: + """Full request dump for Grafana / log aggregation.""" + try: + headers = {str(k): str(v) for k, v in request.headers.items()} + except Exception: # noqa: BLE001 + headers = {"_error": "unable to serialize headers"} + try: + body_text = (request.body or b"").decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + body_text = repr(request.body) + if len(body_text) > 12000: + body_text = body_text[:12000] + "…[truncated]" + logger.info( + "webhook_received channel=%s path=%s method=%s query=%s", + channel, + request.path, + request.method, + request.META.get("QUERY_STRING", ""), + ) + logger.info("webhook_headers channel=%s headers=%s", channel, headers) + logger.info("webhook_body channel=%s body=%s", channel, body_text) + + +def _log_webhook_auth_failed(request, *, channel: str) -> None: + logger.warning( + "webhook_auth_failed channel=%s path=%s " + "missing_or_invalid_authorization_or_token", + channel, + request.path, + ) + + +def _log_webhook_result( + *, + channel: str, + event=None, + error: str = "", + extra: str = "", +) -> None: + if error: + logger.error( + "webhook_error channel=%s error=%s %s", + channel, + error, + extra, + ) + return + if not event: + logger.warning( + "webhook_unmatched channel=%s no_provider_event_created %s", + channel, + extra, + ) + return + message = getattr(event, "message", None) + campaign = getattr(message, "campaign", None) if message else None + logger.info( + "webhook_processed channel=%s event_type=%s event_id=%s " + "matched=%s message_id=%s campaign_id=%s campaign_name=%s %s", + channel, + getattr(event, "event_type", ""), + getattr(event, "pk", None), + bool(message), + getattr(message, "pk", None), + getattr(campaign, "pk", None), + getattr(campaign, "name", "") or "", + extra, + ) + + + +@login_required +@require_http_methods(["GET", "POST"]) +def campaign_list(request): + form_errors: list[str] = [] + form_data = { + "name": "", + "subject": "", + "body": "", + "audience": Campaign.Audience.EMAIL_OPT_IN, + "scheduled_for": "", + "template_id": "", + } + + if request.method == "POST": + name = (request.POST.get("name") or "").strip() + subject = (request.POST.get("subject") or "").strip() + body = (request.POST.get("body") or "").strip() + audience = (request.POST.get("audience") or "").strip() + scheduled_raw = request.POST.get("scheduled_for") or "" + template_id = (request.POST.get("template_id") or "").strip() + + form_data.update( + { + "name": name, + "subject": subject, + "body": body, + "audience": audience, + "scheduled_for": scheduled_raw, + "template_id": template_id, + } + ) + + if not name: + form_errors.append("Campaign name is required.") + if audience not in Campaign.Audience.values: + form_errors.append("Choose a recipient list.") + if not body: + form_errors.append("Body is required.") + if audience == Campaign.Audience.EMAIL_OPT_IN and not subject: + form_errors.append("Subject is required for email campaigns.") + + scheduled_for = None + try: + scheduled_for = parse_scheduled_for(scheduled_raw) + except ValueError as exc: + form_errors.append(str(exc)) + + if not form_errors: + campaign = create_campaign_draft( + name=name, + audience=audience, + subject=subject, + body=body, + scheduled_for=scheduled_for, + created_by=request.user, + ) + recipient_count = campaign.messages.count() + messages.success( + request, + f'Draft “{campaign.name}” saved ' + f"({recipient_count} recipient" + f"{'' if recipient_count == 1 else 's'}).", + ) + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + campaigns = Campaign.objects.all()[:100] + return render( + request, + "email_sms/campaign_list.html", + { + "campaigns": campaigns, + "audience_choices": _audience_choices(), + "form_data": form_data, + "form_errors": form_errors, + "image_upload_url": reverse("email_sms:campaign_image_upload"), + }, + ) + + +@login_required +def campaign_detail(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + try: + page = max(1, int(request.GET.get("page") or 1)) + except (TypeError, ValueError): + page = 1 + ctx = _campaign_report(campaign, page=page) + return render( + request, + "email_sms/campaign_detail.html", + { + "campaign": campaign, + "recipient_messages": ctx["recipient_messages"], + "page_obj": ctx["page_obj"], + "stats": ctx["stats"], + "recent_events": ctx["recent_events"], + "events_title": ctx["events_title"], + "events_empty": ctx["events_empty"], + "can_send": campaign.status + in { + Campaign.Status.DRAFT, + Campaign.Status.SCHEDULED, + Campaign.Status.SENDING, + } + and campaign.messages.exclude( + status__in={ + "sent", + "delivered", + "opened", + "clicked", + "suppressed", + } + ).exists(), + }, + ) + + +@login_required +@require_GET +def campaign_status_json(request, pk): + """JSON snapshot for live-updating the campaign report page.""" + campaign = get_object_or_404(Campaign, pk=pk) + # Async queue may finish after enqueue; re-evaluate completion on poll. + from email_sms.services import refresh_campaign_status + + refresh_campaign_status(campaign) + campaign.refresh_from_db() + try: + page = max(1, int(request.GET.get("page") or 1)) + except (TypeError, ValueError): + page = 1 + ctx = _campaign_report(campaign, page=page) + page_obj = ctx["page_obj"] + return JsonResponse( + { + "status": campaign.status, + "status_display": campaign.get_status_display(), + "stats": ctx["stats"], + "page": page_obj.number, + "num_pages": page_obj.paginator.num_pages, + "messages": [ + { + "id": str(m.pk), + "contact": str(m.contact), + "destination": getattr(m, "destination", "") or "", + "status": m.status, + "status_display": m.get_status_display(), + "provider_message_id": m.provider_message_id or "", + "error": (m.error or "")[:120], + "can_remove": bool(getattr(m, "can_remove", False)), + } + for m in ctx["recipient_messages"] + ], + "events": [ + { + "event_type": e.event_type, + "contact": str(e.message.contact) if e.message_id else "—", + "created_at": e.created_at.isoformat(), + } + for e in ctx["recent_events"] + ], + } + ) + + +@login_required +@require_POST +def campaign_message_remove(request, pk, message_id): + """Drop a draft/scheduled/failed recipient from the campaign.""" + campaign = get_object_or_404(Campaign, pk=pk) + message = get_object_or_404(Message, pk=message_id, campaign=campaign) + if not message_is_removable(message): + messages.error( + request, + "Only draft, scheduled, or failed recipients can be removed.", + ) + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + label = str(message.contact) + message.delete() + messages.success(request, f"Removed {label} from this campaign.") + page = (request.POST.get("page") or request.GET.get("page") or "").strip() + if page and page.isdigit() and int(page) > 1: + return redirect( + f"{reverse('email_sms:campaign_detail', kwargs={'pk': campaign.pk})}" + f"?page={page}" + ) + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_send(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + if campaign.status == Campaign.Status.CANCELLED: + messages.error(request, "Cancelled campaigns cannot be sent.") + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + count = enqueue_campaign_send(campaign) + campaign.refresh_from_db() + if count == 0: + messages.warning(request, "No draft/scheduled/failed messages to send.") + else: + sent = campaign.messages.filter(status="sent").count() + failed = campaign.messages.filter(status="failed").count() + messages.success( + request, + f"Send finished for {count} message(s): {sent} sent, {failed} failed.", + ) + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_test_send(request, pk): + campaign = get_object_or_404(Campaign, pk=pk) + to_email = (request.POST.get("test_email") or "").strip() + if not to_email: + messages.error(request, "Enter an email address for the test send.") + return redirect("email_sms:campaign_detail", pk=campaign.pk) + try: + validate_email(to_email) + except ValidationError: + messages.error(request, "That test email address is not valid.") + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + try: + send_campaign_test_email(campaign, to_email) + except ValueError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 — surface SMTP misconfig to portal + messages.error(request, f"Test send failed: {exc}") + else: + messages.success(request, f"Test email sent to {to_email}.") + return redirect("email_sms:campaign_detail", pk=campaign.pk) + + +@login_required +@require_POST +def campaign_image_upload(request): + """Upload an image for the email rich editor; store bytes in the DB.""" + upload = request.FILES.get("image") or request.FILES.get("file") + if not upload: + return JsonResponse({"error": "No image uploaded."}, status=400) + content_type = (getattr(upload, "content_type", None) or "").lower() + if content_type not in _ALLOWED_IMAGE_TYPES: + return JsonResponse( + {"error": "Use a JPEG, PNG, GIF, or WebP image."}, status=400 + ) + if upload.size and upload.size > _MAX_IMAGE_BYTES: + return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) + + data = upload.read() + if len(data) > _MAX_IMAGE_BYTES: + return JsonResponse({"error": "Image must be 5 MB or smaller."}, status=400) + + original = (getattr(upload, "name", None) or "image")[:255] + stored = StoredFile.objects.create( + kind=StoredFile.Kind.CAMPAIGN_IMAGE, + filename=original, + content_type=content_type, + size=len(data), + data=data, + uploaded_by=request.user if request.user.is_authenticated else None, + ) + path = reverse("core:stored_file", kwargs={"pk": stored.pk}) + url = request.build_absolute_uri(path) + return JsonResponse({"url": url, "id": str(stored.pk)}) + + +@csrf_exempt +@require_http_methods(["GET", "POST"]) +def smtp2go_webhook(request): + """ + Unified SMTP2GO webhook — email + SMS events + inbound STOP replies. + + One SMTP2GO webhook URL (paid plans cap at 10 webhooks): + + URL: https:///portal/email_sms/webhooks/smtp2go/ + Authorization header: Bearer + value = SMTP2GO_WEBHOOK_SECRET + Output type: JSON + Users: email SMTP user(s) *and* the SMS API key used to send + Email events: all delivery/engagement boxes + Email headers: X-Monica-Message-Id + SMS events: Submitted, Sending, Delivered, Failed, Rejected (Opt-out if shown) + + Legacy aliases ``/webhooks/email/`` and ``/webhooks/sms/`` hit this same view. + Payload shape selects the processor (email vs sms_* vs inbound STOP). + """ + # Log/auth before parsing so request.body stays readable (HMAC + Grafana dump). + _log_webhook_request(request, channel="smtp2go") + if not _webhook_authorized( + request, secret=settings.SMTP2GO_WEBHOOK_SECRET or "" + ): + _log_webhook_auth_failed(request, channel="smtp2go") + return HttpResponseForbidden("invalid webhook token") + + payload = parse_webhook_payload(request) + if not payload: + payload = request.POST.dict() or {} + + kind = classify_smtp2go_payload(payload) + channel = { + "sms_inbound": "sms", + "sms": "sms", + "email": "email", + }.get(kind, "smtp2go") + logger.info("webhook_classified channel=%s kind=%s", channel, kind) + + if kind == "sms_inbound": + phone = ( + payload.get("from") + or payload.get("phone") + or payload.get("source_number") + or payload.get("destination_number") + or "" + ) + stopped = bool(phone) and record_sms_stop(str(phone)) + logger.info( + "webhook_processed channel=sms event_type=inbound_stop " + "opt_out=%s phone=%s", + stopped, + phone, + ) + return JsonResponse({"ok": True, "channel": "sms", "opt_out": stopped}) + + if kind == "unknown": + logger.warning( + "webhook_unmatched channel=smtp2go unrecognized_payload keys=%s", + sorted(str(k) for k in payload.keys()), + ) + return JsonResponse( + {"ok": False, "error": "unrecognized_payload", "channel": None}, + status=200, + ) + + try: + if kind == "sms": + event = process_smtp2go_sms_webhook(payload) + else: + event = process_smtp2go_email_webhook(payload) + except Exception as exc: # noqa: BLE001 — never 500 SMTP2GO (they retry for 48h) + logger.exception("SMTP2GO %s webhook processing failed", kind) + _log_webhook_result(channel=channel, error=str(exc)) + return JsonResponse( + {"ok": False, "error": "processing_failed", "channel": channel}, + status=200, + ) + + _log_webhook_result(channel=channel, event=event) + return JsonResponse( + { + "ok": True, + "channel": channel, + "matched": bool(event and event.message_id), + "event_id": event.pk if event else None, + } + ) + + +# Legacy path names — same unified handler (keep SMTP2GO configs working). +email_webhook = smtp2go_webhook +sms_webhook = smtp2go_webhook + diff --git a/site/email_sms/webhooks.py b/site/email_sms/webhooks.py new file mode 100644 index 0000000..a959f06 --- /dev/null +++ b/site/email_sms/webhooks.py @@ -0,0 +1,807 @@ +"""SMTP2GO email/SMS event webhooks → Message + ProviderEvent updates.""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any + +from django.core.exceptions import ValidationError +from django.http import HttpRequest + +from contacts.models import Channel, Contact +from email_sms.models import Message, ProviderEvent +from email_sms.services import set_channel_consent + +logger = logging.getLogger(__name__) + +PROVIDER_EMAIL = "smtp2go_email" +PROVIDER_SMS = "smtp2go_sms" +PROVIDER_PCM = "pcm" +PROVIDER = PROVIDER_EMAIL # backward-compatible alias + +# Do not move a message backward to a weaker delivery / engagement state. +_STATUS_RANK = { + Message.Status.DRAFT: 0, + Message.Status.SCHEDULED: 1, + Message.Status.QUEUED: 2, + Message.Status.SENT: 3, + Message.Status.FAILED: 3, + Message.Status.DELIVERED: 4, + Message.Status.OPENED: 5, + Message.Status.CLICKED: 6, + Message.Status.BOUNCED: 7, + Message.Status.SUPPRESSED: 7, +} + +_ENGAGED_OR_DELIVERED = frozenset( + { + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + } +) +_SENT_OR_BETTER = frozenset( + { + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + } +) + +_MONICA_HEADER_KEYS = ( + "X-Monica-Message-Id", + "x-monica-message-id", + "X_Monica_Message_Id", + "monica-message-id", +) + +# SMTP2GO UI labels → canonical event strings from their docs. +_EMAIL_EVENT_ALIASES = { + "bounced": "bounce", + "rejected": "reject", + "opened": "open", + "clicked": "click", + "unsubscribed": "unsubscribe", + "resubscribed": "resubscribe", +} + +# API sms_events use short names (delivered); webhook body often uses sms_delivered. +# UI test labels / Opt-Out may arrive without the sms_ prefix. +_SMS_EVENT_ALIASES = { + "submitted": "sms_submitted", + "sending": "sms_sending", + "delivered": "sms_delivered", + "failed": "sms_failed", + "rejected": "sms_rejected", + "opt_out": "sms_opt_out", + "optout": "sms_opt_out", + "sms_optout": "sms_opt_out", +} + +_SMS_OPT_OUT_EVENTS = frozenset({"sms_opt_out"}) + + +def _as_str(value: Any) -> str: + """Coerce webhook field values to a stripped string (lists / None safe).""" + if value is None: + return "" + if isinstance(value, (list, tuple)): + if not value: + return "" + value = value[0] + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return str(value).strip() + + +def _json_safe(value: Any) -> Any: + """Ensure ProviderEvent.payload can be stored as JSON.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return str(value) + + +def _normalize_email_event(event: str) -> str: + event = (event or "").strip().lower() + return _EMAIL_EVENT_ALIASES.get(event, event) + + +def _normalize_sms_event(event: str) -> str: + """Map UI / short API names to docs canonical sms_* event strings.""" + event = (event or "").strip().lower().replace("-", "_").replace(" ", "_") + return _SMS_EVENT_ALIASES.get(event, event) + + +def parse_webhook_payload(request: HttpRequest) -> dict[str, Any]: + """Accept JSON or form-encoded SMTP2GO webhook bodies.""" + content_type = (request.content_type or "").lower() + if "application/json" in content_type: + try: + data = json.loads(request.body.decode() or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + return data if isinstance(data, dict) else {} + # Form-encoded (SMTP2GO default) + return {key: request.POST.get(key) for key in request.POST.keys()} + + +def extract_monica_message_id(payload: dict[str, Any]) -> str: + """Pull our correlation id from flat keys or a nested headers object.""" + for key in _MONICA_HEADER_KEYS: + value = _as_str(payload.get(key)) + if value: + return value + + headers = payload.get("headers") or payload.get("email_headers") or {} + if isinstance(headers, dict): + for key in _MONICA_HEADER_KEYS: + value = _as_str(headers.get(key)) + if value: + return value + # Case-insensitive scan + lower_map = {str(k).lower(): v for k, v in headers.items()} + for key in _MONICA_HEADER_KEYS: + value = _as_str(lower_map.get(key.lower())) + if value: + return value + elif isinstance(headers, list): + # Some ESP shapes send [["X-Monica-Message-Id", "..."], ...] + for item in headers: + if isinstance(item, (list, tuple)) and len(item) >= 2: + if _as_str(item[0]).lower() in { + k.lower() for k in _MONICA_HEADER_KEYS + }: + value = _as_str(item[1]) + if value: + return value + elif isinstance(item, str) and ":" in item: + name, _, rest = item.partition(":") + if name.strip().lower() in {k.lower() for k in _MONICA_HEADER_KEYS}: + value = rest.strip() + if value: + return value + return "" + + +def _message_by_pk(pk: str) -> Message | None: + """Lookup Message by UUID pk without raising on malformed ids.""" + try: + uuid.UUID(str(pk)) + except (ValueError, AttributeError, TypeError): + return None + try: + return ( + Message.objects.select_related("contact", "campaign") + .filter(pk=pk) + .first() + ) + except (ValidationError, ValueError): + return None + + +def find_message_for_email_event(payload: dict[str, Any]) -> Message | None: + monica_id = extract_monica_message_id(payload) + if monica_id: + message = _message_by_pk(monica_id) + if message: + return message + + email_id = _as_str(payload.get("email_id") or payload.get("email-id")) + if email_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(provider_message_id=email_id) + .first() + ) + if message: + return message + + rcpt = _as_str(payload.get("rcpt")).lower() + if not rcpt: + recipients = payload.get("recipients") + if isinstance(recipients, str) and recipients.strip(): + rcpt = recipients.split(",")[0].strip().lower() + elif isinstance(recipients, list) and recipients: + rcpt = _as_str(recipients[0]).lower() + + if not rcpt: + return None + + contact = Contact.objects.filter(email__iexact=rcpt).first() + if not contact: + return None + + return ( + Message.objects.select_related("contact", "campaign") + .filter( + contact=contact, + channel=Channel.EMAIL, + status__in=[ + Message.Status.QUEUED, + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + Message.Status.FAILED, + Message.Status.BOUNCED, + ], + ) + .order_by("-sent_at", "-updated_at") + .first() + ) + + +def _maybe_upgrade_status(message: Message, new_status: str, *, error: str = "") -> None: + current_rank = _STATUS_RANK.get(message.status, 0) + new_rank = _STATUS_RANK.get(new_status, 0) + # Always allow bounce/suppress to overwrite delivered; allow delivered over sent. + if new_rank < current_rank and new_status not in { + Message.Status.BOUNCED, + Message.Status.SUPPRESSED, + Message.Status.FAILED, + }: + return + if message.status in {Message.Status.BOUNCED, Message.Status.SUPPRESSED} and new_status in { + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.OPENED, + Message.Status.CLICKED, + }: + return + + fields = ["status", "updated_at"] + message.status = new_status + if error: + message.error = error[:2000] + fields.append("error") + elif new_status in _ENGAGED_OR_DELIVERED: + message.error = "" + fields.append("error") + message.save(update_fields=fields) + + +def _apply_email_event(message: Message, event: str, payload: dict[str, Any]) -> None: + event = _normalize_email_event(event) + bounce_kind = _as_str(payload.get("bounce")).lower() + err = _as_str(payload.get("message") or payload.get("context")) + + email_id = _as_str(payload.get("email_id") or payload.get("email-id")) + if email_id and message.provider_message_id != email_id: + message.provider_message_id = email_id + message.provider = PROVIDER_EMAIL + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if event == "processed": + if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + if event == "delivered": + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + + if event == "bounce": + status = Message.Status.BOUNCED + _maybe_upgrade_status( + message, + status, + error=err or f"{bounce_kind or 'unknown'} bounce", + ) + if bounce_kind == "hard": + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_hard_bounce", + ) + return + + if event == "reject": + _maybe_upgrade_status( + message, Message.Status.FAILED, error=err or "rejected by provider" + ) + return + + if event == "spam": + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error=err or "spam complaint" + ) + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_spam", + ) + return + + if event == "unsubscribe": + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error="provider unsubscribe" + ) + set_channel_consent( + message.contact, + Channel.EMAIL, + opted_in=False, + reason="smtp2go_unsubscribe", + ) + return + + if event == "open": + # Open implies delivery; do not overwrite a stronger click status. + if message.status != Message.Status.CLICKED: + _maybe_upgrade_status(message, Message.Status.OPENED) + return + + if event == "click": + _maybe_upgrade_status(message, Message.Status.CLICKED) + return + + # resubscribe — event row only (status unchanged) + + +def process_smtp2go_email_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """ + Persist ProviderEvent and update Message delivery status when possible. + + Returns the stored event (even if message could not be matched). + """ + event = _normalize_email_event(_as_str(payload.get("event"))) + if not event: + logger.warning("SMTP2GO webhook missing event: %s", payload) + return None + + message = find_message_for_email_event(payload) + if message: + _apply_email_event(message, event, payload) + message.refresh_from_db() + else: + logger.info( + "SMTP2GO webhook unmatched event=%s rcpt=%s email_id=%s", + event, + payload.get("rcpt"), + payload.get("email_id"), + ) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_EMAIL, + event_type=event[:64], + payload=_json_safe(payload) if isinstance(payload, dict) else {}, + ) + + +def normalize_phone(value: str) -> str: + return "".join(ch for ch in (value or "") if ch.isdigit()) + + +def _sms_provider_message_id(payload: dict[str, Any]) -> str: + """SMS unique id from docs (`message_id`). Never use webhook `id`.""" + return _as_str(payload.get("message_id") or payload.get("sms_id")) + + +def find_message_for_sms_event(payload: dict[str, Any]) -> Message | None: + provider_id = _sms_provider_message_id(payload) + if provider_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(channel=Channel.SMS, provider_message_id=provider_id) + .first() + ) + if message: + return message + + # Outbound delivery events use destination_number (recipient). + # Do not use source_number / from — those are the pool or inbound reply. + raw_phone = ( + payload.get("destination_number") + or payload.get("to") + or payload.get("phone") + or "" + ) + digits = normalize_phone(_as_str(raw_phone)) + if len(digits) < 7: + return None + + # Match last 10 digits so +1 / formatting differences still hit. + tail = digits[-10:] + contacts = Contact.objects.exclude(phone="").only("id", "phone") + contact = None + for row in contacts.iterator(): + if normalize_phone(row.phone).endswith(tail): + contact = row + break + if not contact: + return None + + return ( + Message.objects.select_related("contact", "campaign") + .filter( + contact=contact, + channel=Channel.SMS, + status__in=[ + Message.Status.QUEUED, + Message.Status.SENT, + Message.Status.DELIVERED, + Message.Status.FAILED, + Message.Status.SUPPRESSED, + ], + ) + .order_by("-sent_at", "-updated_at") + .first() + ) + + +def _apply_sms_event(message: Message, event: str, payload: dict[str, Any]) -> None: + event = _normalize_sms_event(event) + err = _as_str( + payload.get("message") + or payload.get("status_code") + or payload.get("context") + ) + + provider_id = _sms_provider_message_id(payload) + if provider_id and message.provider_message_id != provider_id: + message.provider_message_id = provider_id + message.provider = PROVIDER_SMS + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if event in {"sms_sending", "sms_submitted"}: + if message.status in {Message.Status.QUEUED, Message.Status.DRAFT}: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + if event == "sms_delivered": + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + + if event in {"sms_failed", "sms_rejected"}: + _maybe_upgrade_status( + message, + Message.Status.FAILED, + error=err or event, + ) + return + + if event in _SMS_OPT_OUT_EVENTS: + _maybe_upgrade_status( + message, Message.Status.SUPPRESSED, error="sms opt-out" + ) + set_channel_consent( + message.contact, + Channel.SMS, + opted_in=False, + reason="smtp2go_sms_opt_out", + ) + return + + +def process_smtp2go_sms_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """Persist SMS delivery/opt-out ProviderEvent and update Message when matched.""" + event = _normalize_sms_event(_as_str(payload.get("event"))) + if not event: + logger.warning("SMTP2GO SMS webhook missing event: %s", payload) + return None + + message = find_message_for_sms_event(payload) + if message: + _apply_sms_event(message, event, payload) + message.refresh_from_db() + else: + # Opt-out with no matched campaign message still suppresses by phone. + if event in _SMS_OPT_OUT_EVENTS: + phone = _as_str( + payload.get("destination_number") + or payload.get("from") + or payload.get("source_number") + ) + if phone: + from email_sms.services import record_sms_stop + + record_sms_stop(phone) + logger.info( + "SMTP2GO SMS webhook unmatched event=%s phone=%s message_id=%s", + event, + payload.get("destination_number"), + payload.get("message_id"), + ) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_SMS, + event_type=event[:64], + payload=_json_safe(payload) if isinstance(payload, dict) else {}, + ) + + +def is_inbound_sms_stop(payload: dict[str, Any]) -> bool: + """True for gateway-style inbound reply payloads (STOP / UNSUBSCRIBE). + + SMTP2GO auto-handles STOP/UNSUB/UNSUBSCRIBE replies; this catches the + inbound gateway POST shape (no ``event`` field) when configured to + forward replies. Prefer this over relying on a webhook Opt-Out event — + the API ``sms_events`` list does not include opt-out. + """ + if payload.get("event"): + return False + text = _as_str( + payload.get("text") + or payload.get("message") + or payload.get("message_content") + ).upper() + return text in {"STOP", "UNSUBSCRIBE", "UNSUB", "CANCEL", "END", "QUIT"} + + +def classify_smtp2go_payload(payload: dict[str, Any]) -> str: + """ + Decide email vs SMS vs inbound STOP for a unified SMTP2GO webhook URL. + + Returns one of: ``sms_inbound``, ``sms``, ``email``, ``unknown``. + """ + if not isinstance(payload, dict) or not payload: + return "unknown" + + if is_inbound_sms_stop(payload): + return "sms_inbound" + + raw = _as_str(payload.get("event")).lower().replace("-", "_").replace(" ", "_") + if not raw: + return "unknown" + + # Explicit SMS forms (docs sms_* + API/UI short names that are SMS-only). + if raw.startswith("sms_") or raw in { + "submitted", + "sending", + "opt_out", + "optout", + }: + return "sms" + + email_event = _normalize_email_event(raw) + if email_event in { + "processed", + "open", + "click", + "bounce", + "spam", + "unsubscribe", + "resubscribe", + }: + return "email" + + has_dest = bool(_as_str(payload.get("destination_number"))) + has_rcpt = bool(_as_str(payload.get("rcpt"))) + has_email_id = bool( + _as_str(payload.get("email_id") or payload.get("email-id")) + ) + has_monica = bool(extract_monica_message_id(payload)) + has_from_address = bool(_as_str(payload.get("from_address"))) + has_sms_id = bool(_as_str(payload.get("message_id") or payload.get("sms_id"))) + has_sms_body = bool( + _as_str(payload.get("message_content") or payload.get("source_number")) + ) + + email_leaning = has_rcpt or has_email_id or has_monica or has_from_address + sms_leaning = has_dest or has_sms_body or ( + has_sms_id and not email_leaning + ) + + # Ambiguous short names shared by email + SMS API lists. + if raw in {"delivered", "failed", "rejected", "reject"}: + if sms_leaning and not email_leaning: + return "sms" + if email_leaning: + return "email" + # Bare reject without fields → email docs name; rejected alone → sms lean default + if raw == "reject": + return "email" + if raw == "rejected": + return "sms" + return "email" + + if email_leaning: + return "email" + if sms_leaning: + return "sms" + # Default: email (historical primary SMTP2GO traffic). + return "email" + + +def _pcm_event_type(payload: dict[str, Any]) -> str: + for key in ("event", "eventType", "event_type", "type", "status"): + value = payload.get(key) + if value: + return str(value).strip() + return "unknown" + + +def find_message_for_pcm_event(payload: dict[str, Any]) -> Message | None: + """Correlate PCM webhook to Message via extRefNbr or orderID.""" + ext = ( + payload.get("extRefNbr") + or payload.get("ext_ref_nbr") + or payload.get("externalReference") + or "" + ) + if not ext and isinstance(payload.get("recipient"), dict): + ext = payload["recipient"].get("extRefNbr") or "" + ext = str(ext).strip() + if ext: + message = _message_by_pk(ext) + if message: + return message + + order_id = ( + payload.get("orderID") + or payload.get("orderId") + or payload.get("order_id") + or "" + ) + order_id = str(order_id).strip() + if order_id: + message = ( + Message.objects.select_related("contact", "campaign") + .filter(provider_message_id=order_id, channel=Channel.POSTCARD) + .first() + ) + if message: + return message + return None + + +def _apply_pcm_status(message: Message, status: str, payload: dict[str, Any]) -> None: + status_norm = (status or "").strip().lower() + err = ( + payload.get("message") + or payload.get("error") + or payload.get("reason") + or "" + ) + err = str(err).strip() + + order_id = ( + payload.get("orderID") + or payload.get("orderId") + or payload.get("order_id") + or "" + ) + if order_id and message.provider_message_id != str(order_id): + message.provider_message_id = str(order_id) + message.provider = PROVIDER_PCM + message.save( + update_fields=["provider_message_id", "provider", "updated_at"] + ) + + if status_norm in {"delivered"}: + _maybe_upgrade_status(message, Message.Status.DELIVERED) + return + if status_norm in {"undeliverable", "returned"}: + _maybe_upgrade_status( + message, + Message.Status.BOUNCED, + error=err or "undeliverable", + ) + return + if status_norm in {"canceled", "cancelled"}: + _maybe_upgrade_status( + message, Message.Status.FAILED, error=err or "canceled" + ) + return + if status_norm in {"pending", "processing", "processed", "mailed", "intransit", "in_transit"}: + if message.status in { + Message.Status.QUEUED, + Message.Status.DRAFT, + Message.Status.SCHEDULED, + }: + _maybe_upgrade_status(message, Message.Status.SENT) + return + + +def process_pcm_postcard_webhook(payload: dict[str, Any]) -> ProviderEvent | None: + """Record a PCM Integrations postcard event and advance Message status.""" + if not payload: + return None + + # Nested data wrappers some webhook UIs use. + if "data" in payload and isinstance(payload["data"], dict): + inner = dict(payload["data"]) + for key in ("event", "eventType", "type"): + if key in payload and key not in inner: + inner[key] = payload[key] + payload = inner + + event_type = _pcm_event_type(payload) + message = find_message_for_pcm_event(payload) + if message: + status_for_apply = ( + payload.get("status") + or payload.get("orderStatus") + or event_type + ) + _apply_pcm_status(message, str(status_for_apply), payload) + + return ProviderEvent.objects.create( + message=message, + provider=PROVIDER_PCM, + event_type=event_type[:64], + payload=payload, + ) + + +def _engagement_chart_bars(metrics: list[tuple[str, int]]) -> list[dict]: + """Build bar heights (percent) for the campaign engagement chart.""" + peak = max((value for _, value in metrics), default=0) + bars: list[dict] = [] + for label, value in metrics: + if peak <= 0: + pct = 12 if value == 0 else 100 + else: + pct = max(12, int(round((value / peak) * 100))) if value else 8 + bars.append({"label": label, "value": value, "pct": pct}) + return bars + + +def campaign_engagement_stats(campaign) -> dict: + """Aggregate delivery + open/click counts for the campaign report.""" + messages_qs = campaign.messages.all() + statuses = list(messages_qs.values_list("status", flat=True)) + message_ids = list(messages_qs.values_list("pk", flat=True)) + + events = ProviderEvent.objects.filter(message_id__in=message_ids) + open_message_ids = set( + events.filter(event_type__iexact="open").values_list("message_id", flat=True) + ) + click_message_ids = set( + events.filter(event_type__iexact="click").values_list("message_id", flat=True) + ) + + # Status-based engagement also counts (webhook may set opened/clicked). + status_opened = sum(1 for s in statuses if s in {"opened", "clicked"}) + status_clicked = sum(1 for s in statuses if s == "clicked") + opens = max(len(open_message_ids), status_opened) + clicks = max(len(click_message_ids), status_clicked) + + sent = sum(1 for s in statuses if s in _SENT_OR_BETTER) + delivered = sum(1 for s in statuses if s in _ENGAGED_OR_DELIVERED) + failed = sum(1 for s in statuses if s in {"failed", "bounced"}) + suppressed = sum(1 for s in statuses if s == "suppressed") + + if campaign.channel == Channel.EMAIL: + chart_metrics = [ + ("Sent", sent), + ("Delivered", delivered), + ("Opens", opens), + ("Clicks", clicks), + ("Failed", failed), + ] + else: + chart_metrics = [ + ("Sent", sent), + ("Delivered", delivered), + ("Failed", failed), + ("Suppressed", suppressed), + ] + + return { + "total": len(statuses), + "sent": sent, + "delivered": delivered, + "failed": failed, + "bounced": sum(1 for s in statuses if s == "bounced"), + "suppressed": suppressed, + "opens": opens, + "clicks": clicks, + "open_events": events.filter(event_type__iexact="open").count(), + "click_events": events.filter(event_type__iexact="click").count(), + "chart_bars": _engagement_chart_bars(chart_metrics), + } diff --git a/site/leads/__init__.py b/site/leads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/leads/admin.py b/site/leads/admin.py new file mode 100644 index 0000000..e692591 --- /dev/null +++ b/site/leads/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from leads.models import Lead, LeadNote + + +class LeadNoteInline(admin.TabularInline): + model = LeadNote + extra = 0 + + +@admin.register(Lead) +class LeadAdmin(admin.ModelAdmin): + list_display = ("contact", "status", "created_at") + list_filter = ("status",) + search_fields = ("contact__email", "contact__first_name", "contact__last_name") + inlines = [LeadNoteInline] diff --git a/site/leads/apps.py b/site/leads/apps.py new file mode 100644 index 0000000..c00714e --- /dev/null +++ b/site/leads/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig + + +class LeadsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "leads" + + def ready(self): + from core.registry import register_portal_nav + + register_portal_nav( + section="leads", + label="Leads", + url_name="leads:list", + group="Overview", + order=20, + ) diff --git a/site/leads/migrations/0001_initial.py b/site/leads/migrations/0001_initial.py new file mode 100644 index 0000000..47d6f08 --- /dev/null +++ b/site/leads/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# 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 = [ + ('contacts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Lead', + 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)), + ('message', models.TextField(blank=True)), + ('status', models.CharField(choices=[('new', 'New'), ('contacted', 'Contacted'), ('won', 'Won'), ('lost', 'Lost')], default='new', max_length=16)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leads', to='contacts.contact')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='LeadNote', + 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)), + ('body', models.TextField()), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('lead', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='leads.lead')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/site/leads/migrations/__init__.py b/site/leads/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/leads/models.py b/site/leads/models.py new file mode 100644 index 0000000..70e50a3 --- /dev/null +++ b/site/leads/models.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.db import models + +from contacts.models import Contact +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class Lead(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + NEW = "new", "New" + CONTACTED = "contacted", "Contacted" + WON = "won", "Won" + LOST = "lost", "Lost" + + contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="leads") + message = models.TextField(blank=True) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.NEW + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="leads", + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"Lead {self.contact} ({self.status})" + + +class LeadNote(TimeStampedModel): + lead = models.ForeignKey(Lead, on_delete=models.CASCADE, related_name="notes") + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + body = models.TextField() + + class Meta: + ordering = ["-created_at"] diff --git a/site/leads/templates/leads/detail.html b/site/leads/templates/leads/detail.html new file mode 100644 index 0000000..c369467 --- /dev/null +++ b/site/leads/templates/leads/detail.html @@ -0,0 +1,88 @@ +{% extends "portal_base.html" %} +{% block title %}{{ lead.contact }} · Lead{% endblock %} +{% block topbar_title %}Lead · {{ lead.contact }}{% endblock %} +{% block portal_content %} +
+
+
+

Contact

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Status edits and note posting come next in functionality work.

+ ← Back to inbox +
+
+
+

Notes

+
+ {% for note in lead.notes.all %} +
+ {% if note.author %}{{ note.author }}{% else %}System{% endif %} + · {{ note.created_at|date:"M j, g:i A" }} — {{ note.body }} +
+ {% empty %} +

No notes yet.

+ {% endfor %} +
+
+
+
+
+

Attribution

+
+ {% if lead.attribution %} +

+ utm_source {{ lead.attribution.utm_source|default:"—" }}
+ utm_medium {{ lead.attribution.utm_medium|default:"—" }}
+ utm_campaign {{ lead.attribution.utm_campaign|default:"—" }}
+ {% if lead.attribution.visit %} + Landing {{ lead.attribution.visit.path|default:"—" }}
+ First touch {{ lead.attribution.visit.created_at|date:"M j, g:i A" }} + {% endif %} +
Converted {{ lead.created_at|date:"M j, g:i A" }} +

+ {% else %} +

No UTM attribution on this lead.

+ {% endif %} +
+
+
+

Linked contact · consent

+
+ +

+ Form submit defaulted email opt-in with notice. Postcard requires address + separate consent. +

+

+ Open contact record → +

+
+
+
+
+{% endblock %} diff --git a/site/leads/templates/leads/list.html b/site/leads/templates/leads/list.html new file mode 100644 index 0000000..c7d5960 --- /dev/null +++ b/site/leads/templates/leads/list.html @@ -0,0 +1,57 @@ +{% extends "portal_base.html" %} +{% block title %}Leads · Portal{% endblock %} +{% block topbar_title %}Lead inbox{% endblock %} +{% block portal_content %} +
+
+ + + +
+
+ +
+
+ + + + + + + + + + + + {% for lead in leads %} + + + + + + + + {% empty %} + + {% endfor %} + +
LeadInterestUTM / sourceStatusReceived
+ {{ lead.contact }}
+ {{ lead.contact.email }}{% if lead.contact.phone %} · {{ lead.contact.phone }}{% endif %} +
{{ lead.message|truncatechars:40|default:"—" }} + {% if lead.attribution %} + {{ lead.attribution.utm_source|default:"direct" }} + {% if lead.attribution.utm_medium %}/ {{ lead.attribution.utm_medium }}{% endif %} + {% if lead.attribution.utm_campaign %}/ {{ lead.attribution.utm_campaign }}{% endif %} + {% else %} + — + {% endif %} + {{ lead.get_status_display }}{{ lead.created_at|date:"M j, g:i A" }}
No leads match.
+
+
+{% endblock %} diff --git a/site/leads/urls.py b/site/leads/urls.py new file mode 100644 index 0000000..4d9e605 --- /dev/null +++ b/site/leads/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from leads import views + +app_name = "leads" + +urlpatterns = [ + path("", views.lead_list, name="list"), + path("/", views.lead_detail, name="detail"), +] diff --git a/site/leads/views.py b/site/leads/views.py new file mode 100644 index 0000000..7e3c9b7 --- /dev/null +++ b/site/leads/views.py @@ -0,0 +1,51 @@ +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.shortcuts import get_object_or_404, render + +from contacts.models import Channel +from leads.models import Lead + + +@login_required +def lead_list(request): + leads = Lead.objects.select_related("contact", "attribution").all() + q = (request.GET.get("q") or "").strip() + status = (request.GET.get("status") or "").strip() + if q: + leads = leads.filter( + Q(contact__first_name__icontains=q) + | Q(contact__last_name__icontains=q) + | Q(contact__email__icontains=q) + | Q(contact__phone__icontains=q) + | Q(message__icontains=q) + ) + if status: + leads = leads.filter(status=status) + return render( + request, + "leads/list.html", + { + "leads": leads[:200], + "status_choices": Lead.Status.choices, + "q": q, + "status_filter": status, + }, + ) + + +@login_required +def lead_detail(request, pk): + lead = get_object_or_404( + Lead.objects.select_related( + "contact", "attribution", "attribution__visit" + ).prefetch_related("notes", "contact__consents"), + pk=pk, + ) + consent_map = {c.value: False for c in Channel} + for record in lead.contact.consents.all(): + consent_map[record.channel] = record.opted_in + return render( + request, + "leads/detail.html", + {"lead": lead, "consent_map": consent_map}, + ) diff --git a/site/manage.py b/site/manage.py new file mode 100644 index 0000000..5fc98ac --- /dev/null +++ b/site/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "client_site.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/site/payments/__init__.py b/site/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/payments/admin.py b/site/payments/admin.py new file mode 100644 index 0000000..e21a738 --- /dev/null +++ b/site/payments/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from payments.models import Invoice + + +@admin.register(Invoice) +class InvoiceAdmin(admin.ModelAdmin): + list_display = ("number", "contact", "amount", "currency", "status", "due_date") + list_filter = ("status", "currency") + search_fields = ("number", "description", "stripe_invoice_id") diff --git a/site/payments/apps.py b/site/payments/apps.py new file mode 100644 index 0000000..9435cf6 --- /dev/null +++ b/site/payments/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "payments" + verbose_name = "Payments" + + def ready(self): + from payments import hooks + + hooks.register() diff --git a/site/payments/hooks.py b/site/payments/hooks.py new file mode 100644 index 0000000..69b1e6b --- /dev/null +++ b/site/payments/hooks.py @@ -0,0 +1,22 @@ +from core.registry import register_dashboard_collector, register_feature, register_portal_nav + + +def register() -> None: + register_feature("payments") + register_portal_nav( + section="payments", + label="Invoices", + url_name="payments:invoice_list", + group="Billing", + order=10, + ) + register_dashboard_collector(_dashboard) + + +def _dashboard(request) -> dict: + from payments.models import Invoice + + open_count = Invoice.objects.exclude( + status__in=[Invoice.Status.PAID, Invoice.Status.VOID] + ).count() + return {"open_invoices": open_count} diff --git a/site/payments/migrations/0001_initial.py b/site/payments/migrations/0001_initial.py new file mode 100644 index 0000000..593a18a --- /dev/null +++ b/site/payments/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# 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 = [ + ('contacts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + 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)), + ('number', models.CharField(max_length=32, unique=True)), + ('description', models.CharField(max_length=255)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('currency', models.CharField(default='usd', max_length=8)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('void', 'Void'), ('uncollectible', 'Uncollectible')], default='draft', max_length=16)), + ('due_date', models.DateField(blank=True, null=True)), + ('stripe_invoice_id', models.CharField(blank=True, max_length=255)), + ('stripe_checkout_session_id', models.CharField(blank=True, max_length=255)), + ('hosted_invoice_url', models.URLField(blank=True)), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='contacts.contact')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_invoices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/site/payments/migrations/__init__.py b/site/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/payments/models.py b/site/payments/models.py new file mode 100644 index 0000000..12682f4 --- /dev/null +++ b/site/payments/models.py @@ -0,0 +1,52 @@ +from decimal import Decimal + +from django.conf import settings +from django.db import models + +from contacts.models import Contact +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + + +class Invoice(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + OPEN = "open", "Open" + PAID = "paid", "Paid" + VOID = "void", "Void" + UNCOLLECTIBLE = "uncollectible", "Uncollectible" + + number = models.CharField(max_length=32, unique=True) + contact = models.ForeignKey( + Contact, + on_delete=models.PROTECT, + related_name="invoices", + ) + description = models.CharField(max_length=255) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=8, default="usd") + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + due_date = models.DateField(null=True, blank=True) + stripe_invoice_id = models.CharField(max_length=255, blank=True) + stripe_checkout_session_id = models.CharField(max_length=255, blank=True) + hosted_invoice_url = models.URLField(blank=True) + paid_at = models.DateTimeField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_invoices", + ) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.number} · {self.contact} · {self.amount} {self.currency}" + + @property + def amount_cents(self) -> int: + return int((self.amount * Decimal("100")).quantize(Decimal("1"))) diff --git a/site/payments/public_urls.py b/site/payments/public_urls.py new file mode 100644 index 0000000..1d4ed3b --- /dev/null +++ b/site/payments/public_urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from payments import views + +app_name = "payments_public" + +urlpatterns = [ + path("/", views.pay_invoice, name="pay"), + path("/success/", views.pay_success, name="pay_success"), + path("/cancel/", views.pay_cancel, name="pay_cancel"), +] diff --git a/site/payments/services.py b/site/payments/services.py new file mode 100644 index 0000000..9bde5d4 --- /dev/null +++ b/site/payments/services.py @@ -0,0 +1,116 @@ +"""Stripe invoice + Checkout pay-link helpers. Requires FEATURE_EMAIL_SMS to send.""" + +from __future__ import annotations + +import logging +from datetime import date + +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from payments.models import Invoice + +logger = logging.getLogger(__name__) + + +class PaymentsError(RuntimeError): + pass + + +def _stripe(): + secret = (settings.STRIPE_SECRET_KEY or "").strip() + if not secret: + raise PaymentsError("STRIPE_SECRET_KEY is not configured") + try: + import stripe + except ImportError as exc: + raise PaymentsError("stripe package is not installed") from exc + stripe.api_key = secret + return stripe + + +def next_invoice_number() -> str: + today = date.today().strftime("%Y%m%d") + prefix = f"INV-{today}-" + existing = Invoice.objects.filter(number__startswith=prefix).count() + return f"{prefix}{existing + 1:03d}" + + +def create_checkout_session(invoice: Invoice, *, success_url: str, cancel_url: str) -> str: + stripe = _stripe() + session = stripe.checkout.Session.create( + mode="payment", + customer_email=invoice.contact.email or None, + line_items=[ + { + "quantity": 1, + "price_data": { + "currency": (invoice.currency or "usd").lower(), + "unit_amount": invoice.amount_cents, + "product_data": { + "name": invoice.description or f"Invoice {invoice.number}", + }, + }, + } + ], + metadata={"invoice_id": str(invoice.pk), "invoice_number": invoice.number}, + success_url=success_url, + cancel_url=cancel_url, + ) + invoice.stripe_checkout_session_id = session.id + invoice.hosted_invoice_url = session.url or "" + invoice.status = Invoice.Status.OPEN + invoice.save( + update_fields=[ + "stripe_checkout_session_id", + "hosted_invoice_url", + "status", + "updated_at", + ] + ) + return session.url or "" + + +def mark_paid(invoice: Invoice, *, stripe_id: str = "") -> None: + invoice.status = Invoice.Status.PAID + invoice.paid_at = timezone.now() + if stripe_id and not invoice.stripe_invoice_id: + invoice.stripe_invoice_id = stripe_id + invoice.save( + update_fields=["status", "paid_at", "stripe_invoice_id", "updated_at"] + ) + + +def send_invoice_email(invoice: Invoice, pay_url: str) -> bool: + """Send pay-link email through Django mail (SMTP2GO).""" + to_email = (invoice.contact.email or "").strip() + if not to_email: + raise PaymentsError("Contact has no email address") + from django.core.mail import EmailMultiAlternatives + + name = invoice.contact.full_name or "there" + amount = f"{invoice.amount} {invoice.currency.upper()}" + subject = f"Invoice {invoice.number} from {settings.SITE_NAME}" + text = ( + f"Hi {name},\n\n" + f"Invoice {invoice.number} for {amount} is ready.\n" + f"{invoice.description}\n\n" + f"Pay securely: {pay_url}\n" + ) + html = ( + f"

Hi {name},

" + f"

Invoice {invoice.number} for " + f"{amount} is ready.

" + f"

{invoice.description}

" + f'

Pay this invoice

' + ) + mail = EmailMultiAlternatives( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + ) + mail.attach_alternative(html, "text/html") + mail.send(fail_silently=False) + return True diff --git a/site/payments/templates/payments/cancel.html b/site/payments/templates/payments/cancel.html new file mode 100644 index 0000000..e9a7889 --- /dev/null +++ b/site/payments/templates/payments/cancel.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Payment cancelled{% endblock %} +{% block content %} +
+

Payment cancelled

+

No charge was made for {{ invoice.number }}. You can return to the pay link anytime.

+
+{% endblock %} diff --git a/site/payments/templates/payments/create.html b/site/payments/templates/payments/create.html new file mode 100644 index 0000000..0345d06 --- /dev/null +++ b/site/payments/templates/payments/create.html @@ -0,0 +1,22 @@ +{% extends "portal_base.html" %} +{% block title %}New invoice · Portal{% endblock %} +{% block topbar_title %}New invoice{% endblock %} +{% block portal_content %} +
+ {% csrf_token %} +
+ + +
+
+
+
+
+ +
+{% endblock %} diff --git a/site/payments/templates/payments/detail.html b/site/payments/templates/payments/detail.html new file mode 100644 index 0000000..49072b4 --- /dev/null +++ b/site/payments/templates/payments/detail.html @@ -0,0 +1,17 @@ +{% extends "portal_base.html" %} +{% block title %}{{ invoice.number }} · Portal{% endblock %} +{% block topbar_title %}{{ invoice.number }}{% endblock %} +{% block portal_content %} +

{{ invoice.contact }} · {{ invoice.amount }} {{ invoice.currency|upper }} · {{ invoice.get_status_display }}

+

{{ invoice.description }}

+{% if invoice.hosted_invoice_url %}

Stripe pay link

{% endif %} +{% if invoice.status != 'void' and invoice.status != 'paid' %} +
{% csrf_token %} + +
+
{% csrf_token %} + +
+{% endif %} +

← All invoices

+{% endblock %} diff --git a/site/payments/templates/payments/list.html b/site/payments/templates/payments/list.html new file mode 100644 index 0000000..f1a4ee2 --- /dev/null +++ b/site/payments/templates/payments/list.html @@ -0,0 +1,21 @@ +{% extends "portal_base.html" %} +{% block title %}Invoices · Portal{% endblock %} +{% block topbar_title %}Invoices{% endblock %} +{% block portal_content %} +

New invoice

+ + + + {% for inv in invoices %} + + + + + + + {% empty %} + + {% endfor %} + +
NumberContactAmountStatus
{{ inv.number }}{{ inv.contact }}{{ inv.amount }} {{ inv.currency|upper }}{{ inv.get_status_display }}
No invoices yet.
+{% endblock %} diff --git a/site/payments/templates/payments/pay.html b/site/payments/templates/payments/pay.html new file mode 100644 index 0000000..dd8c953 --- /dev/null +++ b/site/payments/templates/payments/pay.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Pay {{ invoice.number }}{% endblock %} +{% block content %} +
+

Invoice {{ invoice.number }}

+{% if closed %}

This invoice is no longer payable.

+{% elif error %}

{{ error }}

+{% else %}

Redirecting to checkout…

{% endif %} +
+{% endblock %} diff --git a/site/payments/templates/payments/success.html b/site/payments/templates/payments/success.html new file mode 100644 index 0000000..3067603 --- /dev/null +++ b/site/payments/templates/payments/success.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Payment received{% endblock %} +{% block content %} +
+

Thank you

+

Payment for {{ invoice.number }} was received{% if invoice.status == 'paid' %}{% endif %}.

+
+{% endblock %} diff --git a/site/payments/tests.py b/site/payments/tests.py new file mode 100644 index 0000000..8b9113d --- /dev/null +++ b/site/payments/tests.py @@ -0,0 +1,50 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from contacts.models import Contact +from payments.models import Invoice +from payments.services import next_invoice_number + + +class PaymentsPortalTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user("biller", password="test-pass-123") + self.client = Client() + self.client.login(username="biller", password="test-pass-123") + self.contact = Contact.objects.create( + email="pay@example.com", first_name="Pat", last_name="Lee" + ) + + def test_list_requires_login(self): + anon = Client() + self.assertEqual(anon.get(reverse("payments:invoice_list")).status_code, 302) + + def test_create_draft_invoice(self): + response = self.client.post( + reverse("payments:invoice_create"), + { + "contact": str(self.contact.pk), + "description": "Website package", + "amount": "150.00", + }, + ) + self.assertEqual(response.status_code, 302) + inv = Invoice.objects.get() + self.assertEqual(inv.amount, Decimal("150.00")) + self.assertEqual(inv.status, Invoice.Status.DRAFT) + self.assertTrue(inv.number.startswith("INV-")) + + def test_next_number_increments(self): + n1 = next_invoice_number() + Invoice.objects.create( + number=n1, + contact=self.contact, + description="a", + amount=Decimal("1.00"), + ) + n2 = next_invoice_number() + self.assertNotEqual(n1, n2) diff --git a/site/payments/urls.py b/site/payments/urls.py new file mode 100644 index 0000000..d263336 --- /dev/null +++ b/site/payments/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from payments import views + +app_name = "payments" + +urlpatterns = [ + path("", views.invoice_list, name="invoice_list"), + path("new/", views.invoice_create, name="invoice_create"), + path("/", views.invoice_detail, name="invoice_detail"), + path("/send/", views.invoice_send, name="invoice_send"), + path("/void/", views.invoice_void, name="invoice_void"), + path("webhooks/stripe/", views.stripe_webhook, name="stripe_webhook"), +] diff --git a/site/payments/views.py b/site/payments/views.py new file mode 100644 index 0000000..42adb0b --- /dev/null +++ b/site/payments/views.py @@ -0,0 +1,190 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseBadRequest +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods, require_POST + +from contacts.models import Contact +from payments.models import Invoice +from payments.services import ( + PaymentsError, + create_checkout_session, + mark_paid, + next_invoice_number, + send_invoice_email, +) + +logger = logging.getLogger(__name__) + + +def _site_base(request) -> str: + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if base: + return base + return request.build_absolute_uri("/").rstrip("/") + + +@login_required +def invoice_list(request): + invoices = Invoice.objects.select_related("contact")[:200] + return render(request, "payments/list.html", {"invoices": invoices}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def invoice_create(request): + contacts = Contact.objects.exclude(email="").order_by("first_name", "last_name") + if request.method == "POST": + contact_id = (request.POST.get("contact") or "").strip() + description = (request.POST.get("description") or "").strip() + amount_raw = (request.POST.get("amount") or "").strip() + due = (request.POST.get("due_date") or "").strip() + notes = (request.POST.get("notes") or "").strip() + errors = [] + contact = Contact.objects.filter(pk=contact_id).first() + if not contact: + errors.append("Choose a contact with an email address.") + if not description: + errors.append("Description is required.") + try: + from decimal import Decimal, InvalidOperation + + amount = Decimal(amount_raw) + if amount <= 0: + raise InvalidOperation + except Exception: + amount = None + errors.append("Enter a valid amount greater than zero.") + if errors: + for err in errors: + messages.error(request, err) + else: + invoice = Invoice.objects.create( + number=next_invoice_number(), + contact=contact, + description=description, + amount=amount, + currency=(settings.STRIPE_CURRENCY or "usd").lower(), + due_date=due or None, + notes=notes, + created_by=request.user, + ) + messages.success(request, f"Draft {invoice.number} created.") + return redirect("payments:invoice_detail", pk=invoice.pk) + return render(request, "payments/create.html", {"contacts": contacts}) + + +@login_required +def invoice_detail(request, pk): + invoice = get_object_or_404(Invoice.objects.select_related("contact"), pk=pk) + return render(request, "payments/detail.html", {"invoice": invoice}) + + +@login_required +@require_POST +def invoice_send(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + if invoice.status == Invoice.Status.VOID: + messages.error(request, "Void invoices cannot be sent.") + return redirect("payments:invoice_detail", pk=invoice.pk) + base = _site_base(request) + success = base + reverse("payments_public:pay_success", kwargs={"pk": invoice.pk}) + cancel = base + reverse("payments_public:pay_cancel", kwargs={"pk": invoice.pk}) + try: + url = create_checkout_session( + invoice, success_url=success + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel, + ) + send_invoice_email(invoice, url or (base + reverse("payments_public:pay", kwargs={"pk": invoice.pk}))) + except PaymentsError as exc: + messages.error(request, str(exc)) + except Exception as exc: # noqa: BLE001 + logger.exception("invoice send failed") + messages.error(request, f"Could not send invoice: {exc}") + else: + messages.success(request, f"Pay link emailed to {invoice.contact.email}.") + return redirect("payments:invoice_detail", pk=invoice.pk) + + +@login_required +@require_POST +def invoice_void(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + invoice.status = Invoice.Status.VOID + invoice.save(update_fields=["status", "updated_at"]) + messages.success(request, f"{invoice.number} voided.") + return redirect("payments:invoice_detail", pk=invoice.pk) + + +def pay_invoice(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + if invoice.status == Invoice.Status.PAID: + return redirect("payments_public:pay_success", pk=invoice.pk) + if invoice.status == Invoice.Status.VOID: + return render(request, "payments/pay.html", {"invoice": invoice, "closed": True}) + if invoice.hosted_invoice_url: + return redirect(invoice.hosted_invoice_url) + base = _site_base(request) + success = base + reverse("payments_public:pay_success", kwargs={"pk": invoice.pk}) + cancel = base + reverse("payments_public:pay_cancel", kwargs={"pk": invoice.pk}) + try: + url = create_checkout_session( + invoice, + success_url=success + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel, + ) + except PaymentsError as exc: + return render( + request, + "payments/pay.html", + {"invoice": invoice, "error": str(exc)}, + ) + return redirect(url) + + +def pay_success(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + return render(request, "payments/success.html", {"invoice": invoice}) + + +def pay_cancel(request, pk): + invoice = get_object_or_404(Invoice, pk=pk) + return render(request, "payments/cancel.html", {"invoice": invoice}) + + +@csrf_exempt +@require_http_methods(["POST"]) +def stripe_webhook(request): + secret = (settings.STRIPE_WEBHOOK_SECRET or "").strip() + if not secret: + logger.error("STRIPE_WEBHOOK_SECRET unset") + return HttpResponseBadRequest("webhook not configured") + try: + import stripe + except ImportError: + return HttpResponseBadRequest("stripe not installed") + sig = request.headers.get("Stripe-Signature", "") + try: + event = stripe.Webhook.construct_event(request.body, sig, secret) + except Exception: + logger.exception("stripe webhook signature failed") + return HttpResponseBadRequest("invalid signature") + + obj = event.get("data", {}).get("object", {}) or {} + if event.get("type") in {"checkout.session.completed", "invoice.paid"}: + invoice_id = (obj.get("metadata") or {}).get("invoice_id") or "" + invoice = None + if invoice_id: + invoice = Invoice.objects.filter(pk=invoice_id).first() + if invoice is None: + session_id = obj.get("id") or "" + invoice = Invoice.objects.filter(stripe_checkout_session_id=session_id).first() + if invoice and invoice.status != Invoice.Status.PAID: + mark_paid(invoice, stripe_id=obj.get("id") or "") + logger.info("invoice %s marked paid via Stripe %s", invoice.number, event.get("type")) + return HttpResponse("ok") diff --git a/site/public/__init__.py b/site/public/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/public/admin.py b/site/public/admin.py new file mode 100644 index 0000000..8add86c --- /dev/null +++ b/site/public/admin.py @@ -0,0 +1 @@ +from django.contrib import admin # noqa: F401 diff --git a/site/public/apps.py b/site/public/apps.py new file mode 100644 index 0000000..ce864bb --- /dev/null +++ b/site/public/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PublicConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "public" diff --git a/site/public/context_processors.py b/site/public/context_processors.py new file mode 100644 index 0000000..f26f11c --- /dev/null +++ b/site/public/context_processors.py @@ -0,0 +1,79 @@ +import re + +from django.conf import settings + + +def _phone_tel(phone: str) -> str: + digits = re.sub(r"[^\d+]", "", phone or "") + return digits + + +def site_branding(request): + phone = settings.CONTACT_PHONE or "" + public_site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/") + if not public_site_url and getattr(request, "build_absolute_uri", None): + public_site_url = request.build_absolute_uri("/").rstrip("/") + return { + "SITE_NAME": settings.SITE_NAME, + "SITE_TAGLINE": settings.SITE_TAGLINE, + "PUBLIC_SITE_URL": public_site_url, + "CONTACT_PHONE": phone, + "CONTACT_PHONE_TEL": _phone_tel(phone), + "CONTACT_EMAIL": settings.CONTACT_EMAIL or "", + "CONTACT_ADDRESS": getattr(settings, "CONTACT_ADDRESS", "") or "", + "CONTACT_SERVICE_AREA": getattr( + settings, + "CONTACT_SERVICE_AREA", + "", + ), + "CREDIT_NAME": getattr(settings, "CREDIT_NAME", "AI ML Operations, LLC"), + "CREDIT_URL": getattr( + settings, "CREDIT_URL", "https://aimloperations.com" + ), + "SITE_UNDER_CONSTRUCTION": settings.SITE_UNDER_CONSTRUCTION, + } + + +def tianji_tracking(request): + match = getattr(request, "resolver_match", None) + url_name = getattr(match, "url_name", "") or "" + namespace = getattr(match, "namespace", "") or "" + full = f"{namespace}:{url_name}" if namespace else url_name + + nav_section = "" + if full == "dashboard:home": + nav_section = "dashboard" + elif namespace == "leads": + nav_section = "leads" + elif full == "analytics:report": + nav_section = "analytics" + elif namespace == "contacts": + nav_section = "contacts" + elif full == "directmail:postcard_designer": + nav_section = "postcard" + elif namespace == "email_sms": + nav_section = "campaigns" + elif namespace == "directmail": + nav_section = "directmail" + elif namespace == "blog": + nav_section = "blog" + elif namespace == "payments": + nav_section = "payments" + elif full == "social:account_list": + nav_section = "social_accounts" + elif namespace == "social": + nav_section = "social" + + return { + "tianji_enabled": getattr(settings, "TIANJI_ENABLED", False) + and bool(getattr(settings, "TIANJI_WEBSITE_ID", "")) + and bool(getattr(settings, "TIANJI_TRACKER_URL", "")), + "tianji_tracker_url": getattr( + settings, + "TIANJI_TRACKER_URL", + "https://tianji.aimloperations.com/tracker.js", + ), + "tianji_website_id": getattr(settings, "TIANJI_WEBSITE_ID", ""), + "page_name": url_name, + "nav_section": nav_section, + } diff --git a/site/public/email_branding.py b/site/public/email_branding.py new file mode 100644 index 0000000..e28ae40 --- /dev/null +++ b/site/public/email_branding.py @@ -0,0 +1,133 @@ +"""Shared context for branded HTML/text emails (public site palette).""" + +from __future__ import annotations + +import html +import logging +import re + +from django.conf import settings +from django.contrib.staticfiles.storage import staticfiles_storage + +logger = logging.getLogger(__name__) + +_URL_RE = re.compile(r"(https?://[^\s<]+)") +_LOGO_STATIC_PATH = "brand/logo.png" + + +def _absolute_static_url(site_url: str, relative: str) -> str: + """ + Build an absolute URL for a static asset. + + Prefer the hashed Manifest URL when available. Fall back to the stable + path when the manifest is missing (dj-queue worker does not run + collectstatic) so sending mail never crashes. + """ + try: + path = staticfiles_storage.url(relative) + except ValueError: + logger.debug( + "staticfiles manifest miss for %s; using unhashed URL", relative + ) + static_prefix = settings.STATIC_URL or "/static/" + path = f"{static_prefix}{relative.lstrip('/')}" + + if path.startswith("http://") or path.startswith("https://"): + return path + if not path.startswith("/"): + path = f"/{path}" + return f"{site_url}{path}" + + +def _tagline_with_site_link(tagline: str, site_url: str) -> str: + """Escape the tagline; keep a hook for per-client domain linking.""" + raw = (tagline or "").strip() + if not raw: + return "" + return html.escape(raw) + + +def email_brand_context(**extra): + site_url = (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/") + if not site_url: + site_url = "https://example.com" + + logo_url = _absolute_static_url(site_url, _LOGO_STATIC_PATH) + + brand_name = getattr(settings, "SITE_NAME", None) or "Your Company" + brand_legal = getattr(settings, "CREDIT_NAME", None) or brand_name + tagline = getattr(settings, "SITE_TAGLINE", None) or "" + host_label = site_url.replace("https://", "").replace("http://", "") + + return { + "site_url": site_url, + "logo_url": logo_url, + "brand_name": brand_name, + "brand_legal": brand_legal, + "brand_tagline": tagline, + "brand_tagline_html": _tagline_with_site_link(tagline, site_url), + "host_label": host_label, + **extra, + } + + +def plain_text_to_email_html(text: str) -> str: + """Escape plain text and turn paragraphs / URLs into simple HTML.""" + raw = (text or "").replace("\r\n", "\n").strip() + if not raw: + return "" + + blocks: list[str] = [] + for para in re.split(r"\n\s*\n", raw): + lines = [html.escape(line) for line in para.split("\n")] + joined = "
\n".join(lines) + joined = _URL_RE.sub( + r'\1', + joined, + ) + blocks.append( + f'

{joined}

' + ) + return "\n".join(blocks) + + +_HTML_TAG_RE = re.compile( + r"<\s*(p|div|br|span|strong|em|b|i|u|a|img|h[1-6]|ul|ol|li|font|table)\b", + re.I, +) + + +def sanitize_email_html(raw: str) -> str: + """Light cleanup for staff-authored HTML (Quill) before sending.""" + text = raw or "" + text = re.sub(r"(?is)]*>.*?", "", text) + text = re.sub(r"(?is)]*>.*?", "", text) + text = re.sub(r"(?is)]*>.*?", "", text) + text = re.sub(r"(?i)\son\w+\s*=\s*([\"']).*?\1", "", text) + text = re.sub(r"(?i)\son\w+\s*=\s*[^\s>]+", "", text) + text = re.sub(r"(?i)javascript:", "", text) + return text.strip() + + +def campaign_body_to_email_html(body: str) -> str: + """Render campaign body for email — HTML as-is when Quill markup, else plain.""" + raw = (body or "").strip() + if not raw: + return "" + if _HTML_TAG_RE.search(raw): + return sanitize_email_html(raw) + return plain_text_to_email_html(raw) + + +def campaign_body_to_plain_text(body: str) -> str: + """Plain-text alternative for multipart emails.""" + from django.utils.html import strip_tags + + raw = (body or "").strip() + if not raw: + return "" + if _HTML_TAG_RE.search(raw): + text = strip_tags(sanitize_email_html(raw)) + return html.unescape(re.sub(r"[ \t]+\n", "\n", text)).strip() + return raw diff --git a/site/public/forms.py b/site/public/forms.py new file mode 100644 index 0000000..361b92b --- /dev/null +++ b/site/public/forms.py @@ -0,0 +1,115 @@ +from django import forms +from django.conf import settings + + +class ContactForm(forms.Form): + INTEREST_CHOICES = [ + ("general", "General inquiry"), + ("quote", "Request a quote"), + ("support", "Support"), + ("other", "Something else"), + ] + + first_name = forms.CharField( + max_length=100, + widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-first-name"}), + ) + last_name = forms.CharField( + max_length=100, + required=False, + widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-last-name"}), + ) + email = forms.EmailField( + widget=forms.EmailInput(attrs={"class": "form-input", "id": "contact-email"}), + ) + phone = forms.CharField( + max_length=32, + required=False, + widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-phone"}), + ) + address_line1 = forms.CharField( + max_length=200, + required=False, + label="Street address", + widget=forms.TextInput( + attrs={ + "class": "form-input", + "id": "contact-address-line1", + "autocomplete": "off", + "data-ac": "line1", + } + ), + ) + address_line2 = forms.CharField( + max_length=200, + required=False, + label="Apt / suite", + widget=forms.TextInput( + attrs={ + "class": "form-input", + "id": "contact-address-line2", + "autocomplete": "address-line2", + "data-ac": "line2", + } + ), + ) + address_city = forms.CharField( + max_length=100, + required=False, + label="City", + widget=forms.TextInput( + attrs={ + "class": "form-input", + "id": "contact-address-city", + "autocomplete": "address-level2", + "data-ac": "city", + } + ), + ) + address_state = forms.CharField( + max_length=32, + required=False, + label="State", + widget=forms.TextInput( + attrs={ + "class": "form-input", + "id": "contact-address-state", + "autocomplete": "address-level1", + "data-ac": "state", + } + ), + ) + address_zip = forms.CharField( + max_length=20, + required=False, + label="ZIP", + widget=forms.TextInput( + attrs={ + "class": "form-input", + "id": "contact-address-zip", + "autocomplete": "postal-code", + "data-ac": "zip", + } + ), + ) + interest = forms.ChoiceField( + choices=INTEREST_CHOICES, + widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}), + ) + message = forms.CharField( + widget=forms.Textarea(attrs={"class": "form-input", "id": "contact-message"}), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if settings.RECAPTCHA_PUBLIC_KEY and settings.RECAPTCHA_PRIVATE_KEY: + from django_recaptcha.fields import ReCaptchaField + from django_recaptcha.widgets import ReCaptchaV3 + + self.fields["captcha"] = ReCaptchaField(widget=ReCaptchaV3) + + +class NotifyForm(forms.Form): + email = forms.EmailField( + widget=forms.EmailInput(attrs={"class": "form-input"}), + ) diff --git a/site/public/middleware.py b/site/public/middleware.py new file mode 100644 index 0000000..a1e71a1 --- /dev/null +++ b/site/public/middleware.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.shortcuts import redirect + + +class UnderConstructionMiddleware: + """ + When SITE_UNDER_CONSTRUCTION is true, redirect public traffic to the holding page. + + Exempt: health, static/media, admin, auth login/logout, SMS webhook, and the + under-construction page itself (so notify-me POSTs work). + """ + + EXEMPT_PREFIXES = ( + "/healthz", + "/static/", + "/media/", + "/admin/", + "/accounts/login", + "/accounts/logout", + "/under-construction", + "/portal/messaging/webhooks/", + "/unsubscribe/", + "/api/address-suggest", + "/robots.txt", + "/sitemap.xml", + ) + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if settings.SITE_UNDER_CONSTRUCTION and not self._is_exempt(request): + return redirect("public:under_construction") + return self.get_response(request) + + def _is_exempt(self, request) -> bool: + path = request.path + if any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES): + return True + user = getattr(request, "user", None) + if ( + user is not None + and getattr(user, "is_authenticated", False) + and getattr(user, "is_staff", False) + and path.startswith("/portal/") + ): + return True + return False diff --git a/site/public/migrations/__init__.py b/site/public/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/public/models.py b/site/public/models.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/site/public/models.py @@ -0,0 +1 @@ + diff --git a/site/public/notifications.py b/site/public/notifications.py new file mode 100644 index 0000000..b8a5352 --- /dev/null +++ b/site/public/notifications.py @@ -0,0 +1,78 @@ +"""Outbound notifications for public-site events.""" + +import logging + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template +from django.urls import reverse + +from leads.models import Lead +from public.email_branding import email_brand_context + +logger = logging.getLogger(__name__) + + +def notify_admins_of_contact_form(lead: Lead) -> bool: + """ + Email CONTACT_EMAIL when someone submits the public contact form. + + Returns True if a message was sent. Failures are logged; callers should + not block the visitor success path on delivery errors. + """ + to_email = (settings.CONTACT_EMAIL or "").strip() + if not to_email: + logger.warning("CONTACT_EMAIL unset; skipping contact-form notification") + return False + + contact = lead.contact + name = contact.full_name or "(no name)" + phone = contact.phone or "(none)" + email = contact.email or "(none)" + message = (lead.message or "").strip() or "(no message)" + + site = (settings.PUBLIC_SITE_URL or "").rstrip("/") + portal_path = reverse("leads:detail", kwargs={"pk": lead.pk}) + portal_url = f"{site}{portal_path}" if site else portal_path + + postal = contact.postal_address or {} + address_bits = [ + postal.get("line1") or "", + postal.get("line2") or "", + ", ".join( + part + for part in [ + postal.get("city") or "", + postal.get("state") or "", + postal.get("zip") or "", + ] + if part + ), + ] + address = "\n".join(bit for bit in address_bits if bit) or "(none)" + + ctx = email_brand_context( + name=name, + email=email, + phone=phone, + address=address, + message=message, + portal_url=portal_url, + ) + text_content = get_template("emails/contact_email.txt").render(ctx) + html_content = get_template("emails/contact_email.html").render(ctx) + + mail = EmailMultiAlternatives( + subject=f"New contact form inquiry from {name}", + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[to_email], + reply_to=[contact.email] if contact.email else None, + ) + mail.attach_alternative(html_content, "text/html") + try: + mail.send(fail_silently=False) + except Exception: + logger.exception("Failed to send contact-form notification to %s", to_email) + return False + return True diff --git a/site/public/templates/emails/base_email.html b/site/public/templates/emails/base_email.html new file mode 100644 index 0000000..89c5d27 --- /dev/null +++ b/site/public/templates/emails/base_email.html @@ -0,0 +1,119 @@ + + + + + + + + {% block title %}{{ brand_name }}{% endblock %} + + + + + {% block preheader %}{% endblock %} + + + + +
+ + + + + + + + + + + + + +
+ {% if logo_url %} + + {{ brand_name|default:'Your Company' }} · EXIT Realty + + {% else %} +

+ {{ brand_name }} +

+ {% endif %} + {% if brand_name %} +

{{ brand_name }}

+ {% endif %} + {% if brand_tagline_html %} +

{{ brand_tagline_html|safe }}

+ {% elif brand_tagline %} +

{{ brand_tagline }}

+ {% endif %} + {% block header_extra %}{% endblock %} +
 
+ {% block content %}{% endblock %} +
+ {% block footer %} +

+ {% block footer_note %}{% endblock %} +

+

+ © {% now "Y" %} {{ brand_name }}. All rights reserved. +

+

+ {% if brand_tagline_html %} + {{ brand_tagline_html|safe }} + {% else %} + {{ host_label|default:"mkdrealtor.com" }} + {% if brand_tagline %} +  ·  {{ brand_tagline }} + {% endif %} + {% endif %} +

+ {% endblock %} +
+
+ + diff --git a/site/public/templates/emails/campaign_complete.html b/site/public/templates/emails/campaign_complete.html new file mode 100644 index 0000000..02596c5 --- /dev/null +++ b/site/public/templates/emails/campaign_complete.html @@ -0,0 +1,40 @@ +{% extends "emails/base_email.html" %} + +{% block title %}{{ subject }}{% endblock %} + +{% block content %} +

Your {{ channel_display }} campaign has finished sending.

+ +

{{ campaign_name }}

+ + + + + + + + + + + + + + + + + + + + + + +
Recipients{{ total }}
Sent{{ sent }}
Delivered{{ delivered }}
Failed / bounced{{ failed }}
Suppressed{{ suppressed }}
+ +{% if report_url %} +

+ +

+{% endif %} +{% endblock %} + +{% block footer_note %}Campaign summary from {{ brand_name|default:"Monica Dhillon" }}.{% endblock %} diff --git a/site/public/templates/emails/campaign_complete.txt b/site/public/templates/emails/campaign_complete.txt new file mode 100644 index 0000000..5515173 --- /dev/null +++ b/site/public/templates/emails/campaign_complete.txt @@ -0,0 +1,16 @@ +Campaign sent: {{ campaign_name }} + +Your {{ channel_display }} campaign "{{ campaign_name }}" has finished sending. + +Recipients: {{ total }} +Sent: {{ sent }} +Delivered: {{ delivered }} +Failed / bounced: {{ failed }} +Suppressed: {{ suppressed }} + +{% if report_url %}Report: {{ report_url }} +{% endif %} +— +{{ brand_name|default:"Monica Dhillon" }} +{{ site_url|default:"https://mkdrealtor.com" }} +{% if brand_tagline %}{{ brand_tagline }}{% endif %} diff --git a/site/public/templates/emails/contact_email.html b/site/public/templates/emails/contact_email.html new file mode 100644 index 0000000..fc697e0 --- /dev/null +++ b/site/public/templates/emails/contact_email.html @@ -0,0 +1,33 @@ +{% extends "emails/base_email.html" %} + +{% block title %}New Contact Request{% endblock %} + +{% block content %} +

Hello,

+

A new contact request was submitted on the site.

+ +

Name

+

{{ name }}

+ +

Email

+

+ {{ email }} +

+ +

Phone

+

{{ phone }}

+ +

Address

+

{{ address }}

+ +

Message

+

{{ message }}

+ +{% if portal_url %} +

+ +

+{% endif %} +{% endblock %} + +{% block footer_note %}This is an automated message from {{ brand_name }}.{% endblock %} diff --git a/site/public/templates/emails/contact_email.txt b/site/public/templates/emails/contact_email.txt new file mode 100644 index 0000000..ae466b1 --- /dev/null +++ b/site/public/templates/emails/contact_email.txt @@ -0,0 +1,17 @@ +New contact form inquiry — {{ brand_name|default:"Monica Dhillon" }} + +Name: {{ name }} +Email: {{ email }} +Phone: {{ phone }} +Address: +{{ address }} + +Message: +{{ message }} + +{% if portal_url %}View in portal: {{ portal_url }} +{% endif %} +— +{{ brand_name|default:"Monica Dhillon" }} +{{ site_url|default:"https://mkdrealtor.com" }} +{% if brand_tagline %}{{ brand_tagline }}{% endif %} diff --git a/site/public/templates/emails/marketing_email.html b/site/public/templates/emails/marketing_email.html new file mode 100644 index 0000000..a553127 --- /dev/null +++ b/site/public/templates/emails/marketing_email.html @@ -0,0 +1,25 @@ +{% extends "emails/base_email.html" %} + +{% block title %}{{ title }}{% endblock %} + +{% block header_extra %} +{% if title %} +

{{ title }}

+{% endif %} +{% endblock %} + +{% block content %} +{{ content_html|safe }} +{% endblock %} + +{% block footer_note %} +{% if prefs_url or one_click_url %} +{% if prefs_url %} +Manage preferences +{% endif %} +{% if prefs_url and one_click_url %} · {% endif %} +{% if one_click_url %} +Unsubscribe from email +{% endif %} +{% endif %} +{% endblock %} diff --git a/site/public/templates/emails/marketing_email.txt b/site/public/templates/emails/marketing_email.txt new file mode 100644 index 0000000..5f0e874 --- /dev/null +++ b/site/public/templates/emails/marketing_email.txt @@ -0,0 +1,10 @@ +{% if title %}{{ title }} + +{% endif %}{{ content }} + +— +{% if prefs_url %}Manage preferences: {{ prefs_url }} +{% endif %}{% if one_click_url %}Unsubscribe from email: {{ one_click_url }} +{% endif %}{{ brand_name|default:"Monica Dhillon" }} +{{ site_url|default:"https://mkdrealtor.com" }} +{% if brand_tagline %}{{ brand_tagline }}{% endif %} diff --git a/site/public/templates/public/404.html b/site/public/templates/public/404.html new file mode 100644 index 0000000..732cf79 --- /dev/null +++ b/site/public/templates/public/404.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}404 · {{ SITE_NAME }}{% endblock %} +{% block body %} +
+
+
+
+
+
+
+
+

404

+
+
+
+
Page not found
+

That page may have moved or the link is outdated. Let’s get you back on track.

+ Back to home + Contact Monica +
+
+
+
+
+ +
+
+
+{% endblock %} diff --git a/site/public/templates/public/about.html b/site/public/templates/public/about.html new file mode 100644 index 0000000..ac6a557 --- /dev/null +++ b/site/public/templates/public/about.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}About {{ SITE_NAME }}{% endblock %} +{% block og_title %}About {{ SITE_NAME }}{% endblock %} +{% block twitter_title %}About {{ SITE_NAME }}{% endblock %} +{% block meta_description %}About {{ SITE_NAME }}. {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block og_description %}About {{ SITE_NAME }}. {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block twitter_description %}About {{ SITE_NAME }}. {{ SITE_TAGLINE }}. {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block structured_data %} + +{% endblock %} +{% block content %} + +
+
+
+
+

Who we are

+

{% if SITE_TAGLINE %}{{ SITE_TAGLINE }}. {% endif %}Replace this paragraph with the client’s story, team, and what customers should expect.

+

{% if CONTACT_SERVICE_AREA %}{{ CONTACT_SERVICE_AREA }}. {% endif %}Every site seeded from this template gets a unique public app — swap photos, copy, and service pages per brand.

+ Contact us +
+
+ {% if CONTACT_PHONE %}

Phone
{{ CONTACT_PHONE }}

{% endif %} + {% if CONTACT_EMAIL %}

Email
{{ CONTACT_EMAIL }}

{% endif %} + {% if CONTACT_ADDRESS %}

Address
{{ CONTACT_ADDRESS }}

{% endif %} +
+
+
+
+{% endblock %} diff --git a/site/public/templates/public/contact.html b/site/public/templates/public/contact.html new file mode 100644 index 0000000..fa35eff --- /dev/null +++ b/site/public/templates/public/contact.html @@ -0,0 +1,198 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} +{% block og_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} +{% block twitter_title %}Contact {{ SITE_NAME }} · Get in Touch{% endblock %} +{% block meta_description %}Contact {{ SITE_NAME }}. {{ CONTACT_SERVICE_AREA }}. Call{% if CONTACT_PHONE %} {{ CONTACT_PHONE }}{% endif %}{% if CONTACT_EMAIL %} or email {{ CONTACT_EMAIL }}{% endif %}.{% endblock %} +{% block og_description %}Contact {{ SITE_NAME }}. {{ CONTACT_SERVICE_AREA }}. Call{% if CONTACT_PHONE %} {{ CONTACT_PHONE }}{% endif %}{% if CONTACT_EMAIL %} or email {{ CONTACT_EMAIL }}{% endif %}.{% endblock %} +{% block twitter_description %}Contact {{ SITE_NAME }}. {{ CONTACT_SERVICE_AREA }}. Call{% if CONTACT_PHONE %} {{ CONTACT_PHONE }}{% endif %}{% if CONTACT_EMAIL %} or email {{ CONTACT_EMAIL }}{% endif %}.{% endblock %} +{% block structured_data %} + +{% endblock %} +{% block content %} + +
+
+
+ {% if CONTACT_PHONE %} + + {% endif %} + {% if CONTACT_EMAIL %} + + {% endif %} + {% if CONTACT_ADDRESS or CONTACT_SERVICE_AREA %} +
+
+
+ {% if CONTACT_ADDRESS %}{{ CONTACT_ADDRESS }}{% else %}{{ CONTACT_SERVICE_AREA }}{% endif %} +
+
+ {% endif %} +
+
+
+
+
+
+
+

Send a message

+
+ {% csrf_token %} +
+
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+
+
+ + {{ form.email }} + {{ form.email.errors }} +
+
+
+
+ + {{ form.phone }} + {{ form.phone.errors }} +
+
+
+

Mailing address (optional — for postcards)

+
+
+
+ + {{ form.address_line1 }} + {{ form.address_line1.errors }} +
+
+
+
+ + {{ form.address_line2 }} + {{ form.address_line2.errors }} +
+
+
+
+ + {{ form.address_city }} + {{ form.address_city.errors }} +
+
+
+
+ + {{ form.address_state }} + {{ form.address_state.errors }} +
+
+
+
+ + {{ form.address_zip }} + {{ form.address_zip.errors }} +
+
+
+
+
+
+ + {{ form.interest }} + {{ form.interest.errors }} +
+
+
+
+ + {{ form.message }} + {{ form.message.errors }} +
+
+ {% if form.captcha %} +
{{ form.captcha }}{{ form.captcha.errors }}
+ {% endif %} +
+

By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}

+ +
+
+
+
+
+{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block tracking_events %} +{% if "sent" in request.GET %} + +{% endif %} +{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/public/templates/public/home.html b/site/public/templates/public/home.html new file mode 100644 index 0000000..64e8023 --- /dev/null +++ b/site/public/templates/public/home.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}{{ SITE_NAME }}{% if SITE_TAGLINE %} · {{ SITE_TAGLINE }}{% endif %}{% endblock %} +{% block og_title %}{{ SITE_NAME }}{% endblock %} +{% block twitter_title %}{{ SITE_NAME }}{% endblock %} +{% block meta_description %}{{ SITE_NAME }}{% if SITE_TAGLINE %} — {{ SITE_TAGLINE }}.{% endif %} {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block og_description %}{{ SITE_NAME }}{% if SITE_TAGLINE %} — {{ SITE_TAGLINE }}.{% endif %} {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block twitter_description %}{{ SITE_NAME }}{% if SITE_TAGLINE %} — {{ SITE_TAGLINE }}.{% endif %} {{ CONTACT_SERVICE_AREA }}.{% endblock %} +{% block structured_data %} + +{% endblock %} +{% block content %} +
+
+
+
+
+
+
{{ SITE_NAME }}
+
+

{% if SITE_TAGLINE %}{{ SITE_TAGLINE }}{% else %}Welcome{% endif %}

+

{% if CONTACT_SERVICE_AREA %}{{ CONTACT_SERVICE_AREA }}. {% endif %}Tell us what you need — we will follow up promptly.

+ +
+
+
+
+
+
+
+

How we can help

+
+
+
+
+
+

Talk with us

+

A real person answers. Share your goals and we will map the next step.

+
+
+
+
+
+
+
+

Stay informed

+

Opt in on the contact form for email, SMS, or mail — only the channels you choose.

+
+
+
+
+
+
+
+

Work with us

+

Replace this copy with your services. Brand, photos, and pages are unique to each client site.

+
+
+
+
+
+ About us +
+
+
+{% endblock %} diff --git a/site/public/templates/public/terms.html b/site/public/templates/public/terms.html new file mode 100644 index 0000000..d1b1e91 --- /dev/null +++ b/site/public/templates/public/terms.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}Terms of Service · {{ SITE_NAME }}{% endblock %} +{% block og_title %}Terms of Service · {{ SITE_NAME }}{% endblock %} +{% block twitter_title %}Terms of Service · {{ SITE_NAME }}{% endblock %} +{% block meta_description %}Terms of Service for {{ SITE_NAME }} / MKDRealtor.com — how we use analytics, protect your information, and never sell or share personal data with third parties for marketing.{% endblock %} +{% block og_description %}Terms of Service for {{ SITE_NAME }} / MKDRealtor.com — how we use analytics, protect your information, and never sell or share personal data with third parties for marketing.{% endblock %} +{% block twitter_description %}Terms of Service for {{ SITE_NAME }} / MKDRealtor.com — how we use analytics, protect your information, and never sell or share personal data with third parties for marketing.{% endblock %} +{% block content %} + +
+ +
+{% endblock %} diff --git a/site/public/templates/public/under_construction.html b/site/public/templates/public/under_construction.html new file mode 100644 index 0000000..7494142 --- /dev/null +++ b/site/public/templates/public/under_construction.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}Under construction · {{ SITE_NAME }}{% endblock %} +{% block body %} +
+
+
+
+
+
+
+
+
We’re getting ready to launch
+

{{ SITE_NAME }} · MKDRealtor.com’s new site is almost here. Leave your email and I’ll let you know when it’s live — or reach out now if you need help buying or selling.

+
+
+ {% csrf_token %} +
+ + + {{ form.email.errors }} +
+
+ +
+
+
+

+ Contact Monica now +

+
+
+
+
+ +
+
+
+{% endblock %} diff --git a/site/public/templates/public/unsubscribe.html b/site/public/templates/public/unsubscribe.html new file mode 100644 index 0000000..1d0a952 --- /dev/null +++ b/site/public/templates/public/unsubscribe.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Communication preferences · {{ SITE_NAME }}{% endblock %} +{% block content %} +
+
+

Communication preferences

+ {% if not valid %} +

This preferences link is invalid or has expired.

+

If you still get messages, reply STOP on SMS or contact Monica directly.

+

+ Contact Monica + Back to home +

+ {% else %} +

You are subscribed as {{ identity }}.

+
+ {% csrf_token %} +
+ +
+
+ +
+
+ +
+

+ SMS: reply STOP anytime. Changes take effect immediately. + Postcard mailings also need a postal address on file. +

+
+ + +
+
+

+ Back to home +

+ {% endif %} +
+
+{% endblock %} diff --git a/site/public/urls.py b/site/public/urls.py new file mode 100644 index 0000000..514d8a8 --- /dev/null +++ b/site/public/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from public import views + +app_name = "public" + +urlpatterns = [ + path("", views.home, name="home"), + path("about/", views.about, name="about"), + path("contact/", views.contact, name="contact"), + path("terms/", views.terms, name="terms"), + path("robots.txt", views.robots_txt, name="robots_txt"), + path("sitemap.xml", views.sitemap_xml, name="sitemap_xml"), + path("under-construction/", views.under_construction, name="under_construction"), + path("unsubscribe//", views.unsubscribe, name="unsubscribe"), + path( + "unsubscribe//one-click/", + views.unsubscribe_one_click, + name="unsubscribe_one_click", + ), +] diff --git a/site/public/views.py b/site/public/views.py new file mode 100644 index 0000000..8f79903 --- /dev/null +++ b/site/public/views.py @@ -0,0 +1,257 @@ +from django.conf import settings +from django.contrib import messages +from django.http import HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_http_methods + +from analytics.services import attribute_lead_from_request +from contacts.models import Channel, ConsentRecord, Contact +from contacts.services import upsert_contact +from leads.models import Lead +from contacts.consent import ( + channel_preferences, + parse_unsubscribe_token, + process_unsubscribe_token, + set_channel_preferences, + unsubscribe_all, +) +from public.forms import ContactForm, NotifyForm +from public.notifications import notify_admins_of_contact_form + + +def _public_site_base(request) -> str: + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if base: + return base + return request.build_absolute_uri("/").rstrip("/") + + +def home(request): + return render(request, "public/home.html") + + +def about(request): + return render(request, "public/about.html") + + +def terms(request): + return render(request, "public/terms.html") + + +@require_GET +def robots_txt(request): + site = _public_site_base(request) + body = "\n".join( + [ + "User-agent: *", + "Allow: /", + "Disallow: /portal/", + "Disallow: /accounts/", + "Disallow: /admin/", + "Disallow: /api/", + f"Sitemap: {site}/sitemap.xml", + "", + ] + ) + return HttpResponse(body, content_type="text/plain; charset=utf-8") + + +@require_GET +def sitemap_xml(request): + site = _public_site_base(request) + paths = [ + ("public:home", "1.0", "weekly"), + ("public:about", "0.8", "monthly"), + ("public:contact", "0.9", "monthly"), + ("public:terms", "0.5", "yearly"), + ] + from django.apps import apps as django_apps + if django_apps.is_installed("blog"): + paths.append(("blog:list", "0.7", "weekly")) + urls = [] + for name, priority, changefreq in paths: + path = reverse(name) + urls.append( + " \n" + f" {site}{path}\n" + f" {changefreq}\n" + f" {priority}\n" + " " + ) + body = ( + '\n' + '\n' + + "\n".join(urls) + + "\n\n" + ) + return HttpResponse(body, content_type="application/xml; charset=utf-8") + + +@require_http_methods(["GET", "POST"]) +def contact(request): + if request.method == "POST": + form = ContactForm(request.POST) + if form.is_valid(): + data = form.cleaned_data + postal = Contact.make_postal_address( + line1=data.get("address_line1") or "", + line2=data.get("address_line2") or "", + city=data.get("address_city") or "", + state=data.get("address_state") or "", + zip_code=data.get("address_zip") or "", + ) + submitted_email = data["email"].lower() + contact_obj, _created, match_reason = upsert_contact( + email=submitted_email, + first_name=data["first_name"], + last_name=data.get("last_name") or "", + phone=data.get("phone") or "", + postal_address=postal + if Contact.postal_address_has_content(postal) + else None, + source=Contact.Source.CONTACT_FORM, + ) + ConsentRecord.objects.update_or_create( + contact=contact_obj, + channel=Channel.EMAIL, + defaults={"opted_in": True, "reason": "contact_form"}, + ) + if (data.get("phone") or "").strip(): + ConsentRecord.objects.update_or_create( + contact=contact_obj, + channel=Channel.SMS, + defaults={"opted_in": True, "reason": "contact_form"}, + ) + if Contact.postal_address_has_content(postal): + ConsentRecord.objects.get_or_create( + contact=contact_obj, + channel=Channel.POSTCARD, + defaults={"opted_in": True, "reason": "contact_form"}, + ) + interest = data.get("interest") or "" + interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest) + body = data.get("message") or "" + if interest_label: + body = f"Interest: {interest_label}\n\n{body}".strip() + if ( + match_reason in {"phone", "address"} + and (contact_obj.email or "").lower() != submitted_email + ): + body = ( + f"Submitted email: {submitted_email} " + f"(merged by {match_reason} with " + f"{contact_obj.email or 'existing contact'})\n\n{body}" + ).strip() + lead = Lead.objects.create( + contact=contact_obj, + message=body, + status=Lead.Status.NEW, + ) + attribute_lead_from_request(request, lead) + notify_admins_of_contact_form(lead) + messages.success(request, "Thanks — we will be in touch soon.") + return redirect(f"{reverse('public:contact')}?sent=1") + else: + form = ContactForm() + return render(request, "public/contact.html", {"form": form}) + + +@require_http_methods(["GET", "POST"]) +def under_construction(request): + """Direct route (also used by middleware). Accepts notify-me emails.""" + if request.method == "POST": + form = NotifyForm(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"].lower() + Contact.objects.get_or_create( + email=email, + defaults={ + "first_name": "", + "source": Contact.Source.NOTIFY_ME, + }, + ) + messages.success(request, "You're on the list — we'll email when we launch.") + return redirect("public:under_construction") + else: + form = NotifyForm() + return render(request, "public/under_construction.html", {"form": form}) + + +@csrf_exempt +@require_http_methods(["GET", "POST"]) +def unsubscribe_one_click(request, token: str): + """ + One-click opt-out for the channel encoded in the token. + + CSRF-exempt so mail clients can POST List-Unsubscribe=One-Click (RFC 8058). + """ + ok = process_unsubscribe_token(token) + if not ok: + return render(request, "public/unsubscribe.html", {"valid": False}) + return redirect("public:unsubscribe", token=token) + + +@require_http_methods(["GET", "POST"]) +def unsubscribe(request, token: str): + """ + Signed-token preference center for email / SMS / postcard. + + GET ?one_click=1 opts out the token channel then redirects here. + POST saves checkboxes or unsubscribes from all channels. + """ + contact, token_channel = parse_unsubscribe_token(token) + if not contact: + return render( + request, + "public/unsubscribe.html", + {"valid": False}, + ) + + if request.method == "GET" and request.GET.get("one_click") in { + "1", + "true", + "yes", + }: + process_unsubscribe_token(token) + return redirect("public:unsubscribe", token=token) + + if request.method == "POST": + action = (request.POST.get("action") or "save").strip() + if action == "unsubscribe_all": + unsubscribe_all(contact, reason="preferences_unsubscribe_all") + messages.success( + request, "You are unsubscribed from all marketing channels." + ) + else: + prefs = { + Channel.EMAIL: "consent_email" in request.POST, + Channel.SMS: "consent_sms" in request.POST, + Channel.POSTCARD: "consent_postcard" in request.POST, + } + set_channel_preferences( + contact, prefs, reason="preferences_save" + ) + messages.success(request, "Your communication preferences were saved.") + return redirect("public:unsubscribe", token=token) + + contact = Contact.objects.prefetch_related("consents").get(pk=contact.pk) + prefs = channel_preferences(contact) + identity = contact.email or contact.phone or contact.full_name or "your profile" + return render( + request, + "public/unsubscribe.html", + { + "valid": True, + "contact": contact, + "identity": identity, + "prefs": prefs, + "token_channel": token_channel, + "token": token, + }, + ) + + +def page_not_found(request, exception): + return render(request, "public/404.html", status=404) diff --git a/site/social/__init__.py b/site/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social/admin.py b/site/social/admin.py new file mode 100644 index 0000000..341a0e4 --- /dev/null +++ b/site/social/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from social.models import SocialAccount, SocialAppCredentials, SocialPost, SocialPostTarget + + +class SocialPostTargetInline(admin.TabularInline): + model = SocialPostTarget + extra = 0 + + +@admin.register(SocialAppCredentials) +class SocialAppCredentialsAdmin(admin.ModelAdmin): + list_display = ("platform", "client_id", "updated_at") + readonly_fields = ("encrypted_client_secret", "created_at", "updated_at") + + +@admin.register(SocialAccount) +class SocialAccountAdmin(admin.ModelAdmin): + list_display = ("label", "platform", "is_active", "external_id") + list_filter = ("platform", "is_active") + + +@admin.register(SocialPost) +class SocialPostAdmin(admin.ModelAdmin): + list_display = ("pk", "status", "scheduled_for", "created_at") + list_filter = ("status",) + inlines = [SocialPostTargetInline] diff --git a/site/social/apps.py b/site/social/apps.py new file mode 100644 index 0000000..d011590 --- /dev/null +++ b/site/social/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class SocialConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "social" + + def ready(self): + from social import hooks + + hooks.register() diff --git a/site/social/connectors/__init__.py b/site/social/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social/connectors/base.py b/site/social/connectors/base.py new file mode 100644 index 0000000..4eff144 --- /dev/null +++ b/site/social/connectors/base.py @@ -0,0 +1,10 @@ +from typing import Protocol + + +class SocialConnector(Protocol): + platform: str + + def publish(self, post, target) -> str: + """Publish and return remote post id.""" + + def refresh_token(self, account) -> None: ... diff --git a/site/social/connectors/linkedin.py b/site/social/connectors/linkedin.py new file mode 100644 index 0000000..a9d3205 --- /dev/null +++ b/site/social/connectors/linkedin.py @@ -0,0 +1,163 @@ +"""LinkedIn API connector.""" + +from __future__ import annotations + +import json +import logging + +import requests + +from core.models import StoredFile +from social.crypto import decrypt_tokens +from social.media import split_media + +logger = logging.getLogger(__name__) + + +class LinkedInConnector: + platform = "linkedin" + + def publish(self, post, target) -> str: + tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}") + access_token = tokens.get("access_token") + author_urn = target.account.external_id or tokens.get("author_urn") + if not access_token or not author_urn: + raise RuntimeError("LinkedIn account missing access_token/author_urn") + + media = post.media if isinstance(post.media, list) else [] + images, videos = split_media(media) + headers = { + "Authorization": f"Bearer {access_token}", + "X-Restli-Protocol-Version": "2.0.0", + "Content-Type": "application/json", + } + + if videos: + raise RuntimeError( + "LinkedIn video publish is not wired yet — attach images or post text-only." + ) + + if images: + # Share first image (multi-image LinkedIn needs multi-image recipe). + asset_urn = self._upload_image( + access_token=access_token, + author_urn=author_urn, + image=images[0], + ) + payload = { + "author": author_urn, + "lifecycleState": "PUBLISHED", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": post.body or ""}, + "shareMediaCategory": "IMAGE", + "media": [ + { + "status": "READY", + "description": {"text": post.body or ""}, + "media": asset_urn, + "title": {"text": images[0].get("filename") or "Image"}, + } + ], + } + }, + "visibility": { + "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" + }, + } + else: + payload = { + "author": author_urn, + "lifecycleState": "PUBLISHED", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": post.body or ""}, + "shareMediaCategory": "NONE", + } + }, + "visibility": { + "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" + }, + } + + response = requests.post( + "https://api.linkedin.com/v2/ugcPosts", + json=payload, + headers=headers, + timeout=60, + ) + if response.status_code >= 400: + logger.warning( + "LinkedIn publish failed: %s %s", + response.status_code, + response.text[:500], + ) + raise RuntimeError( + f"LinkedIn publish failed ({response.status_code}): {response.text[:400]}" + ) + return str(response.headers.get("x-restli-id") or response.json().get("id") or "") + + def _upload_image( + self, *, access_token: str, author_urn: str, image: dict + ) -> str: + file_id = image.get("id") + if not file_id: + raise RuntimeError("LinkedIn image missing StoredFile id") + stored = StoredFile.objects.get(pk=file_id) + binary = bytes(stored.data) + + register = requests.post( + "https://api.linkedin.com/v2/assets?action=registerUpload", + json={ + "registerUploadRequest": { + "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"], + "owner": author_urn, + "serviceRelationships": [ + { + "relationshipType": "OWNER", + "identifier": "urn:li:userGeneratedContent", + } + ], + } + }, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "X-Restli-Protocol-Version": "2.0.0", + }, + timeout=30, + ) + if register.status_code >= 400: + raise RuntimeError( + f"LinkedIn image register failed ({register.status_code}): " + f"{register.text[:400]}" + ) + value = register.json().get("value") or {} + asset = value.get("asset") + upload_mech = ( + (value.get("uploadMechanism") or {}).get( + "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest" + ) + or {} + ) + upload_url = upload_mech.get("uploadUrl") + upload_headers = upload_mech.get("headers") or {} + if not asset or not upload_url: + raise RuntimeError("LinkedIn registerUpload missing asset/uploadUrl") + + put_headers = {"Authorization": f"Bearer {access_token}"} + put_headers.update(upload_headers) + put = requests.put( + upload_url, + data=binary, + headers=put_headers, + timeout=120, + ) + if put.status_code >= 400: + raise RuntimeError( + f"LinkedIn image upload failed ({put.status_code}): {put.text[:400]}" + ) + return str(asset) + + def refresh_token(self, account) -> None: + logger.info("LinkedIn token refresh stub for %s", account.pk) diff --git a/site/social/connectors/meta.py b/site/social/connectors/meta.py new file mode 100644 index 0000000..5ec4efc --- /dev/null +++ b/site/social/connectors/meta.py @@ -0,0 +1,250 @@ +"""Meta Graph API connector (Facebook Page + Instagram via Facebook Login).""" + +from __future__ import annotations + +import json +import logging +import time + +import requests + +from social.crypto import decrypt_tokens +from social.media import split_media + +logger = logging.getLogger(__name__) + + +class MetaConnector: + platform = "meta" + GRAPH = "https://graph.facebook.com/v21.0" + + def publish(self, post, target) -> str: + tokens = json.loads(decrypt_tokens(target.account.encrypted_tokens) or "{}") + access_token = tokens.get("access_token") + if not access_token: + raise RuntimeError("Meta/Instagram account missing access_token") + + media = post.media if isinstance(post.media, list) else [] + images, videos = split_media(media) + + if target.platform == "instagram": + ig_user_id = target.account.external_id or tokens.get("ig_user_id") + if not ig_user_id: + raise RuntimeError("Instagram account missing ig_user_id") + return self._publish_instagram( + ig_user_id=ig_user_id, + access_token=access_token, + caption=post.body or "", + images=images, + videos=videos, + ) + + page_id = target.account.external_id or tokens.get("page_id") + if not page_id: + raise RuntimeError("Meta account missing page_id") + return self._publish_facebook( + page_id=page_id, + access_token=access_token, + message=post.body or "", + images=images, + videos=videos, + ) + + def _publish_facebook( + self, + *, + page_id: str, + access_token: str, + message: str, + images: list[dict], + videos: list[dict], + ) -> str: + if videos: + video = videos[0] + response = requests.post( + f"{self.GRAPH}/{page_id}/videos", + data={ + "file_url": video["url"], + "description": message, + "access_token": access_token, + }, + timeout=120, + ) + self._raise_graph(response, "Facebook video publish") + return str(response.json().get("id") or "") + + if len(images) == 1: + response = requests.post( + f"{self.GRAPH}/{page_id}/photos", + data={ + "url": images[0]["url"], + "caption": message, + "access_token": access_token, + }, + timeout=60, + ) + self._raise_graph(response, "Facebook photo publish") + return str(response.json().get("id") or response.json().get("post_id") or "") + + if len(images) > 1: + attached = [] + for image in images: + resp = requests.post( + f"{self.GRAPH}/{page_id}/photos", + data={ + "url": image["url"], + "published": "false", + "access_token": access_token, + }, + timeout=60, + ) + self._raise_graph(resp, "Facebook multi-photo upload") + photo_id = resp.json().get("id") + if photo_id: + attached.append({"media_fbid": photo_id}) + data = { + "message": message, + "access_token": access_token, + } + for idx, item in enumerate(attached): + data[f"attached_media[{idx}]"] = json.dumps(item) + response = requests.post( + f"{self.GRAPH}/{page_id}/feed", + data=data, + timeout=60, + ) + self._raise_graph(response, "Facebook multi-photo feed") + return str(response.json().get("id") or "") + + response = requests.post( + f"{self.GRAPH}/{page_id}/feed", + data={"message": message, "access_token": access_token}, + timeout=30, + ) + self._raise_graph(response, "Facebook text feed") + return str(response.json().get("id") or "") + + def _publish_instagram( + self, + *, + ig_user_id: str, + access_token: str, + caption: str, + images: list[dict], + videos: list[dict], + ) -> str: + if not images and not videos: + raise RuntimeError( + "Instagram requires an image or video attachment (caption-only not allowed)." + ) + + if videos: + creation_id = self._ig_create_container( + ig_user_id, + access_token, + { + "media_type": "VIDEO", + "video_url": videos[0]["url"], + "caption": caption, + }, + ) + self._ig_wait_container(creation_id, access_token) + return self._ig_publish(ig_user_id, access_token, creation_id) + + if len(images) == 1: + creation_id = self._ig_create_container( + ig_user_id, + access_token, + {"image_url": images[0]["url"], "caption": caption}, + ) + return self._ig_publish(ig_user_id, access_token, creation_id) + + # Carousel: children first, then parent container. + children = [] + for image in images[:10]: + child_id = self._ig_create_container( + ig_user_id, + access_token, + {"image_url": image["url"], "is_carousel_item": "true"}, + ) + children.append(child_id) + creation_id = self._ig_create_container( + ig_user_id, + access_token, + { + "media_type": "CAROUSEL", + "children": ",".join(children), + "caption": caption, + }, + ) + return self._ig_publish(ig_user_id, access_token, creation_id) + + def _ig_create_container( + self, ig_user_id: str, access_token: str, fields: dict + ) -> str: + data = {**fields, "access_token": access_token} + response = requests.post( + f"{self.GRAPH}/{ig_user_id}/media", + data=data, + timeout=60, + ) + self._raise_graph(response, "Instagram media container") + creation_id = response.json().get("id") + if not creation_id: + raise RuntimeError("Instagram media container response missing id") + return str(creation_id) + + def _ig_wait_container( + self, creation_id: str, access_token: str, *, attempts: int = 20 + ) -> None: + """Poll video container until FINISHED (or fail).""" + for _ in range(attempts): + response = requests.get( + f"{self.GRAPH}/{creation_id}", + params={ + "fields": "status_code,status", + "access_token": access_token, + }, + timeout=30, + ) + self._raise_graph(response, "Instagram container status") + status = (response.json().get("status_code") or "").upper() + if status == "FINISHED": + return + if status in {"ERROR", "EXPIRED"}: + raise RuntimeError( + f"Instagram video processing failed ({status}): " + f"{response.json().get('status') or ''}" + ) + time.sleep(3) + raise RuntimeError("Instagram video still processing — try again shortly.") + + def _ig_publish( + self, ig_user_id: str, access_token: str, creation_id: str + ) -> str: + response = requests.post( + f"{self.GRAPH}/{ig_user_id}/media_publish", + data={"creation_id": creation_id, "access_token": access_token}, + timeout=60, + ) + self._raise_graph(response, "Instagram media_publish") + return str(response.json().get("id") or "") + + @staticmethod + def _raise_graph(response: requests.Response, label: str) -> None: + if response.status_code < 400: + return + detail = response.text[:500] + try: + err = response.json().get("error") or {} + detail = err.get("message") or detail + except Exception: # noqa: BLE001 + pass + logger.warning("%s failed: %s %s", label, response.status_code, detail) + raise RuntimeError(f"{label} failed ({response.status_code}): {detail}") + + def refresh_token(self, account) -> None: + logger.info( + "Meta/Instagram token refresh not yet implemented for account %s", + account.pk, + ) diff --git a/site/social/crypto.py b/site/social/crypto.py new file mode 100644 index 0000000..f92655d --- /dev/null +++ b/site/social/crypto.py @@ -0,0 +1,67 @@ +"""Token encryption helpers for SocialAccount / SocialAppCredentials.""" + +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings + +# Copied from .env.prod.example / deploy templates — not valid Fernet material. +_PLACEHOLDER_KEYS = frozenset( + { + "replace-with-fernet-key", + "changeme", + "change-me", + "todo", + } +) + + +class SocialCryptoError(ValueError): + """Raised when SOCIAL_TOKEN_ENCRYPTION_KEY is missing or not a Fernet key.""" + + +def _normalize_key(raw: str | bytes | None) -> bytes | None: + if raw is None: + return None + if isinstance(raw, bytes): + text = raw.decode("utf-8", errors="ignore").strip() + else: + text = str(raw).strip() + if not text or text.lower() in _PLACEHOLDER_KEYS: + return None + return text.encode() + + +def _fernet() -> Fernet: + key = _normalize_key(settings.SOCIAL_TOKEN_ENCRYPTION_KEY) + if key is None: + if settings.DEBUG: + # Dev-only unstable fallback — set SOCIAL_TOKEN_ENCRYPTION_KEY for real use. + return Fernet(Fernet.generate_key()) + raise SocialCryptoError( + "SOCIAL_TOKEN_ENCRYPTION_KEY is not set. Generate one with: " + 'python -c "from cryptography.fernet import Fernet; ' + 'print(Fernet.generate_key().decode())" ' + "and add it to the beta/prod app env, then redeploy/restart." + ) + try: + return Fernet(key) + except (ValueError, TypeError) as exc: + raise SocialCryptoError( + "SOCIAL_TOKEN_ENCRYPTION_KEY is invalid (must be 32 url-safe " + "base64-encoded bytes from Fernet.generate_key()). " + "Replace the placeholder in the beta/prod env and restart." + ) from exc + + +def encrypt_tokens(plaintext: str) -> str: + return _fernet().encrypt(plaintext.encode()).decode() + + +def decrypt_tokens(ciphertext: str) -> str: + if not ciphertext: + return "" + try: + return _fernet().decrypt(ciphertext.encode()).decode() + except InvalidToken as exc: + raise ValueError("Unable to decrypt social tokens") from exc + except SocialCryptoError: + raise diff --git a/site/social/hooks.py b/site/social/hooks.py new file mode 100644 index 0000000..f51706a --- /dev/null +++ b/site/social/hooks.py @@ -0,0 +1,57 @@ +"""Register portal nav, dashboard widgets, and due-work dispatch.""" + +from django.utils import timezone + +from core.registry import ( + register_dashboard_collector, + register_dispatcher, + register_feature, + register_portal_nav, +) + + +def register() -> None: + register_feature("social") + register_portal_nav( + section="social", + label="Compose & preview", + url_name="social:composer", + group="Social", + order=10, + ) + register_portal_nav( + section="social_accounts", + label="Accounts", + url_name="social:account_list", + group="Social", + order=20, + ) + register_dashboard_collector(_dashboard) + register_dispatcher(_dispatch_due) + + +def _dashboard(request) -> dict: + from social.models import SocialPost + + return { + "scheduled_posts": SocialPost.objects.filter( + status__in=[SocialPost.Status.SCHEDULED, SocialPost.Status.QUEUED] + ).count() + } + + +def _dispatch_due() -> int: + from social.models import SocialPost + from social.tasks import publish_social_post + + now = timezone.now() + enqueued = 0 + for post in SocialPost.objects.filter( + status=SocialPost.Status.SCHEDULED, + scheduled_for__lte=now, + ).iterator(): + post.status = SocialPost.Status.QUEUED + post.save(update_fields=["status", "updated_at"]) + publish_social_post.enqueue(post_id=str(post.pk)) + enqueued += 1 + return enqueued diff --git a/site/social/management/__init__.py b/site/social/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social/management/commands/__init__.py b/site/social/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social/media.py b/site/social/media.py new file mode 100644 index 0000000..c784816 --- /dev/null +++ b/site/social/media.py @@ -0,0 +1,91 @@ +"""Helpers for social post media (StoredFile-backed).""" + +from __future__ import annotations + +import json +from typing import Any + +from django.conf import settings +from django.urls import reverse + +from core.models import StoredFile + +ALLOWED_IMAGE_TYPES = frozenset( + {"image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"} +) +ALLOWED_VIDEO_TYPES = frozenset( + {"video/mp4", "video/quicktime", "video/webm"} +) +MAX_IMAGE_BYTES = 8 * 1024 * 1024 # 8 MB +MAX_VIDEO_BYTES = 100 * 1024 * 1024 # 100 MB +MAX_IMAGES = 10 + + +def public_file_url(file_id: str) -> str: + """Absolute URL Meta/LinkedIn can fetch (UUID = capability token).""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + path = reverse("core:stored_file", kwargs={"pk": file_id}) + if not base: + return path + return f"{base}{path}" + + +def media_item_from_stored(stored: StoredFile) -> dict[str, Any]: + kind = stored.kind + media_type = "video" if kind == StoredFile.Kind.SOCIAL_VIDEO else "image" + return { + "id": str(stored.pk), + "type": media_type, + "url": public_file_url(str(stored.pk)), + "content_type": stored.content_type, + "filename": stored.filename or "", + "size": stored.size, + } + + +def parse_media_json(raw: str) -> list[dict[str, Any]]: + """Parse composer hidden media JSON into a cleaned list.""" + raw = (raw or "").strip() + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("Invalid media payload.") from exc + if not isinstance(data, list): + raise ValueError("Media payload must be a list.") + + items: list[dict[str, Any]] = [] + for entry in data: + if not isinstance(entry, dict): + continue + file_id = str(entry.get("id") or "").strip() + if not file_id: + continue + try: + stored = StoredFile.objects.get(pk=file_id) + except (StoredFile.DoesNotExist, ValueError) as exc: + raise ValueError(f"Unknown media file: {file_id}") from exc + if stored.kind not in { + StoredFile.Kind.SOCIAL_IMAGE, + StoredFile.Kind.SOCIAL_VIDEO, + StoredFile.Kind.CAMPAIGN_IMAGE, + }: + raise ValueError("File is not a social media attachment.") + items.append(media_item_from_stored(stored)) + + videos = [m for m in items if m["type"] == "video"] + images = [m for m in items if m["type"] == "image"] + if len(videos) > 1: + raise ValueError("Attach at most one video per post.") + if videos and images: + raise ValueError("Use either images or one video — not both.") + if len(images) > MAX_IMAGES: + raise ValueError(f"Attach at most {MAX_IMAGES} images.") + return items + + +def split_media(media: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]: + images = [m for m in media if m.get("type") == "image"] + videos = [m for m in media if m.get("type") == "video"] + return images, videos diff --git a/site/social/migrations/0001_initial.py b/site/social/migrations/0001_initial.py new file mode 100644 index 0000000..d328a5a --- /dev/null +++ b/site/social/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# 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='SocialAppCredentials', + 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)), + ('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16, unique=True)), + ('client_id', models.CharField(blank=True, max_length=255)), + ('encrypted_client_secret', models.TextField(blank=True)), + ], + options={ + 'verbose_name_plural': 'social app credentials', + 'ordering': ['platform'], + }, + ), + migrations.CreateModel( + name='SocialAccount', + 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)), + ('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)), + ('label', models.CharField(max_length=120)), + ('external_id', models.CharField(blank=True, max_length=255)), + ('encrypted_tokens', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='social_accounts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['platform', 'label'], + }, + ), + migrations.CreateModel( + name='SocialPost', + 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)), + ('body', models.TextField()), + ('media', models.JSONField(blank=True, default=list)), + ('scheduled_for', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('queued', 'Queued'), ('publishing', 'Publishing'), ('published', 'Published'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='draft', max_length=16)), + ('ollama_prompt', models.TextField(blank=True)), + ('error', models.TextField(blank=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SocialPostTarget', + 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)), + ('platform', models.CharField(choices=[('facebook', 'Facebook'), ('instagram', 'Instagram'), ('linkedin', 'LinkedIn')], max_length=16)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('published', 'Published'), ('failed', 'Failed')], default='pending', max_length=16)), + ('remote_id', models.CharField(blank=True, max_length=255)), + ('error', models.TextField(blank=True)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialaccount')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='social.socialpost')), + ], + options={ + 'unique_together': {('post', 'account')}, + }, + ), + ] diff --git a/site/social/migrations/__init__.py b/site/social/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social/models.py b/site/social/models.py new file mode 100644 index 0000000..6b7c77c --- /dev/null +++ b/site/social/models.py @@ -0,0 +1,149 @@ +from django.conf import settings +from django.db import models + +from core.models import TimeStampedModel, UUIDPrimaryKeyModel + +_GENERIC_ACCOUNT_LABELS = frozenset( + { + "linkedin member", + "instagram account", + "facebook page", + } +) + + +def is_generic_account_label(label: str) -> bool: + """True when OAuth left a placeholder or unreadable name.""" + text = (label or "").strip() + if not text: + return True + lowered = text.lower() + if lowered in _GENERIC_ACCOUNT_LABELS: + return True + if lowered.startswith("page ") and text.split()[-1].isdigit(): + return True + if text.startswith("urn:"): + return True + return False + + +class Platform(models.TextChoices): + FACEBOOK = "facebook", "Facebook" + INSTAGRAM = "instagram", "Instagram" + LINKEDIN = "linkedin", "LinkedIn" + + +class SocialAppCredentials(UUIDPrimaryKeyModel, TimeStampedModel): + """ + Developer-app OAuth credentials entered in the portal (not env). + + client_secret is Fernet-encrypted. One row per platform. + """ + + platform = models.CharField(max_length=16, choices=Platform.choices, unique=True) + client_id = models.CharField(max_length=255, blank=True) + encrypted_client_secret = models.TextField(blank=True) + + class Meta: + verbose_name_plural = "social app credentials" + ordering = ["platform"] + + def __str__(self) -> str: + return f"{self.platform} app credentials" + + @property + def is_configured(self) -> bool: + return bool(self.client_id and self.encrypted_client_secret) + + +class SocialAccount(UUIDPrimaryKeyModel, TimeStampedModel): + platform = models.CharField(max_length=16, choices=Platform.choices) + label = models.CharField(max_length=120) + external_id = models.CharField(max_length=255, blank=True) + # Fernet-encrypted JSON blob of OAuth tokens + encrypted_tokens = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="social_accounts", + ) + + class Meta: + ordering = ["platform", "label"] + + def __str__(self) -> str: + return f"{self.platform}: {self.label}" + + @property + def display_name(self) -> str: + return (self.label or "").strip() or self.get_platform_display() + + @staticmethod + def resolve_label(existing: "SocialAccount | None", incoming: str) -> str: + """Keep a custom rename; otherwise take the OAuth name.""" + incoming = (incoming or "").strip()[:120] + if existing is not None and not is_generic_account_label(existing.label): + return existing.label + if incoming: + return incoming + if existing and existing.label: + return existing.label + return "Account" + + +class SocialPost(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SCHEDULED = "scheduled", "Scheduled" + QUEUED = "queued", "Queued" + PUBLISHING = "publishing", "Publishing" + PUBLISHED = "published", "Published" + FAILED = "failed", "Failed" + CANCELLED = "cancelled", "Cancelled" + + body = models.TextField() + media = models.JSONField(default=list, blank=True) + scheduled_for = models.DateTimeField(null=True, blank=True) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.DRAFT + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + ollama_prompt = models.TextField(blank=True) + error = models.TextField(blank=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"Post {self.pk} ({self.status})" + + +class SocialPostTarget(UUIDPrimaryKeyModel, TimeStampedModel): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + PUBLISHED = "published", "Published" + FAILED = "failed", "Failed" + + post = models.ForeignKey( + SocialPost, on_delete=models.CASCADE, related_name="targets" + ) + account = models.ForeignKey( + SocialAccount, on_delete=models.CASCADE, related_name="targets" + ) + platform = models.CharField(max_length=16, choices=Platform.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING + ) + remote_id = models.CharField(max_length=255, blank=True) + error = models.TextField(blank=True) + + class Meta: + unique_together = ("post", "account") diff --git a/site/social/oauth_linkedin.py b/site/social/oauth_linkedin.py new file mode 100644 index 0000000..ad3aa53 --- /dev/null +++ b/site/social/oauth_linkedin.py @@ -0,0 +1,188 @@ +"""LinkedIn 3-legged OAuth helpers (Authorization Code flow).""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from social.crypto import decrypt_tokens +from social.models import Platform, SocialAppCredentials + +logger = logging.getLogger(__name__) + +AUTHORIZE_URL = "https://www.linkedin.com/oauth/v2/authorization" +TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" +USERINFO_URL = "https://api.linkedin.com/v2/userinfo" +ME_URL = "https://api.linkedin.com/v2/me" + +# openid/profile/email: Sign In with LinkedIn (OpenID Connect) +# w_member_social: Share on LinkedIn product (required to post as the member) +DEFAULT_SCOPES = ("openid", "profile", "email", "w_member_social") + + +class LinkedInOAuthError(RuntimeError): + """Raised when LinkedIn OAuth or profile lookup fails.""" + + +def get_app_credentials() -> SocialAppCredentials | None: + return SocialAppCredentials.objects.filter(platform=Platform.LINKEDIN).first() + + +def load_client_credentials() -> tuple[str, str]: + """Return (client_id, client_secret) from portal-saved credentials.""" + app = get_app_credentials() + if not app or not app.client_id or not app.encrypted_client_secret: + raise LinkedInOAuthError( + "LinkedIn app credentials are not saved yet. " + "Enter Client ID and Client Secret on the Connect LinkedIn form." + ) + try: + secret = decrypt_tokens(app.encrypted_client_secret) + except ValueError as exc: + raise LinkedInOAuthError( + "Could not decrypt LinkedIn client secret. " + "Re-enter the Client Secret on the Connect LinkedIn form." + ) from exc + if not secret: + raise LinkedInOAuthError("LinkedIn client secret is empty — save it again.") + return app.client_id.strip(), secret.strip() + + +def credentials_configured() -> bool: + app = get_app_credentials() + return bool(app and app.is_configured) + + +def redirect_uri() -> str: + """Absolute callback URL — must match Authorized redirect URLs in LinkedIn Auth tab.""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if not base: + raise LinkedInOAuthError( + "PUBLIC_SITE_URL is not set; cannot build LinkedIn OAuth redirect URI." + ) + return f"{base}{reverse('social:linkedin_oauth_callback')}" + + +def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str: + client_id, _secret = load_client_credentials() + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri(), + "state": state, + "scope": " ".join(scopes), + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" + + +def exchange_code(code: str) -> dict: + """Exchange authorization code for access token payload.""" + client_id, client_secret = load_client_credentials() + response = requests.post( + TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri(), + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30, + ) + if response.status_code >= 400: + logger.warning( + "LinkedIn token exchange failed: %s %s", + response.status_code, + response.text[:500], + ) + raise LinkedInOAuthError( + f"LinkedIn token exchange failed ({response.status_code}). " + "Check client ID/secret, redirect URI, and that Share on LinkedIn is enabled." + ) + payload = response.json() + if not payload.get("access_token"): + raise LinkedInOAuthError("LinkedIn token response missing access_token.") + return payload + + +def fetch_member_profile(access_token: str) -> dict: + """ + Resolve member display name + person URN. + + Prefers OpenID userinfo (`sub`); falls back to legacy /v2/me. + """ + headers = {"Authorization": f"Bearer {access_token}"} + userinfo = requests.get(USERINFO_URL, headers=headers, timeout=30) + if userinfo.status_code == 200: + data = userinfo.json() + sub = (data.get("sub") or "").strip() + if sub: + name = ( + (data.get("name") or "").strip() + or " ".join( + p + for p in ( + (data.get("given_name") or "").strip(), + (data.get("family_name") or "").strip(), + ) + if p + ) + or "LinkedIn member" + ) + return { + "author_urn": f"urn:li:person:{sub}", + "label": name, + "email": (data.get("email") or "").strip(), + } + + me = requests.get( + ME_URL, + headers={ + **headers, + "X-Restli-Protocol-Version": "2.0.0", + }, + timeout=30, + ) + if me.status_code >= 400: + logger.warning( + "LinkedIn profile lookup failed: userinfo=%s me=%s %s", + userinfo.status_code, + me.status_code, + me.text[:500], + ) + raise LinkedInOAuthError( + "Could not load LinkedIn profile. Ensure Sign In with LinkedIn " + "(openid/profile) is approved on the app." + ) + data = me.json() + person_id = (data.get("id") or "").strip() + if not person_id: + raise LinkedInOAuthError("LinkedIn /v2/me response missing id.") + localized = data.get("localizedFirstName") or "" + last = data.get("localizedLastName") or "" + label = f"{localized} {last}".strip() or "LinkedIn member" + return {"author_urn": f"urn:li:person:{person_id}", "label": label, "email": ""} + + +def token_blob_from_oauth(token_payload: dict, *, author_urn: str) -> dict: + expires_in = int(token_payload.get("expires_in") or 0) + blob: dict = { + "access_token": token_payload["access_token"], + "author_urn": author_urn, + "token_type": token_payload.get("token_type") or "Bearer", + "scope": token_payload.get("scope") or "", + } + if token_payload.get("refresh_token"): + blob["refresh_token"] = token_payload["refresh_token"] + if expires_in: + blob["expires_at"] = ( + timezone.now() + timedelta(seconds=expires_in) + ).isoformat() + return blob diff --git a/site/social/oauth_meta.py b/site/social/oauth_meta.py new file mode 100644 index 0000000..85b1cdf --- /dev/null +++ b/site/social/oauth_meta.py @@ -0,0 +1,185 @@ +"""Facebook Login for Business (Instagram API with Facebook Login). + +Docs: +https://developers.facebook.com/documentation/instagram-platform/instagram-api-with-facebook-login/business-login-for-instagram + +Uses response_type=token (tokens arrive in the URL fragment). A thin callback +page reads the fragment and POSTs tokens to the server. +""" + +from __future__ import annotations + +import json +import logging +from datetime import timedelta +from urllib.parse import urlencode + +import requests +from django.conf import settings +from django.urls import reverse +from django.utils import timezone + +from social.crypto import decrypt_tokens +from social.models import Platform, SocialAppCredentials + +logger = logging.getLogger(__name__) + +GRAPH_VERSION = "v21.0" +GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}" +AUTHORIZE_URL = f"https://www.facebook.com/{GRAPH_VERSION}/dialog/oauth" + +# Permissions for Page posting + Instagram content publish via Facebook Login. +DEFAULT_SCOPES = ( + "instagram_basic", + "instagram_content_publish", + "pages_show_list", + "pages_read_engagement", + "pages_manage_posts", +) + +# Triggers Instagram Professional onboarding inside Facebook Login for Business. +IG_ONBOARDING_EXTRAS = {"setup": {"channel": "IG_API_ONBOARDING"}} + + +class MetaOAuthError(RuntimeError): + """Raised when Facebook Login for Business or Page/IG lookup fails.""" + + +def get_app_credentials() -> SocialAppCredentials | None: + """Meta App ID/Secret — stored on the facebook credentials row (shared).""" + fb = SocialAppCredentials.objects.filter(platform=Platform.FACEBOOK).first() + if fb and fb.is_configured: + return fb + # Back-compat if credentials were saved from the Instagram form earlier. + return SocialAppCredentials.objects.filter(platform=Platform.INSTAGRAM).first() + + +def load_client_credentials() -> tuple[str, str]: + app = get_app_credentials() + if not app or not app.client_id or not app.encrypted_client_secret: + raise MetaOAuthError( + "Meta app credentials are not saved yet. " + "Enter Meta App ID and App Secret on the Connect Facebook or Instagram form." + ) + try: + secret = decrypt_tokens(app.encrypted_client_secret) + except ValueError as exc: + raise MetaOAuthError( + "Could not decrypt Meta app secret. Re-enter the App Secret." + ) from exc + if not secret: + raise MetaOAuthError("Meta app secret is empty — save it again.") + return app.client_id.strip(), secret.strip() + + +def credentials_configured() -> bool: + app = get_app_credentials() + return bool(app and app.is_configured) + + +def redirect_uri() -> str: + """Must match Valid OAuth Redirect URIs (Facebook Login for Business settings).""" + base = (settings.PUBLIC_SITE_URL or "").rstrip("/") + if not base: + raise MetaOAuthError( + "PUBLIC_SITE_URL is not set; cannot build Meta OAuth redirect URI." + ) + return f"{base}{reverse('social:meta_oauth_callback')}" + + +def authorization_url(*, state: str, scopes: tuple[str, ...] = DEFAULT_SCOPES) -> str: + client_id, _secret = load_client_credentials() + params = { + "client_id": client_id, + "display": "page", + "extras": json.dumps(IG_ONBOARDING_EXTRAS, separators=(",", ":")), + "redirect_uri": redirect_uri(), + "response_type": "token", + "scope": ",".join(scopes), + "state": state, + } + return f"{AUTHORIZE_URL}?{urlencode(params)}" + + +def fetch_pages(user_access_token: str) -> list[dict]: + """ + GET /me/accounts — Pages the user can manage, with linked IG business accounts. + + Each item: id, name, access_token (page), instagram_business_account (optional). + """ + response = requests.get( + f"{GRAPH}/me/accounts", + params={ + "fields": "id,name,access_token,instagram_business_account{id,username,name}", + "access_token": user_access_token, + }, + timeout=30, + ) + if response.status_code >= 400: + logger.warning( + "Meta /me/accounts failed: %s %s", + response.status_code, + response.text[:500], + ) + raise MetaOAuthError( + f"Could not list Facebook Pages ({response.status_code}). " + "Confirm pages_show_list was granted and the user manages a Page." + ) + data = response.json().get("data") or [] + if not data: + raise MetaOAuthError( + "No Facebook Pages found for this user. Create a Page and link a " + "Professional Instagram account, then try again." + ) + return data + + +def facebook_token_blob(page: dict, *, user_token: str = "") -> dict: + page_token = page.get("access_token") or "" + if not page_token: + raise MetaOAuthError(f"Page {page.get('id')} missing access_token.") + blob: dict = { + "access_token": page_token, + "page_id": str(page["id"]), + "user_access_token": user_token, + "auth_type": "facebook_login_for_business", + } + ig = page.get("instagram_business_account") or {} + if ig.get("id"): + blob["ig_user_id"] = str(ig["id"]) + return blob + + +def instagram_token_blob(page: dict, *, user_token: str = "") -> dict: + ig = page.get("instagram_business_account") or {} + ig_id = str(ig.get("id") or "").strip() + if not ig_id: + raise MetaOAuthError( + f"Page “{page.get('name') or page.get('id')}” has no linked " + "Instagram Professional account." + ) + page_token = page.get("access_token") or "" + if not page_token: + raise MetaOAuthError("Page access token missing — required for Instagram Graph API.") + username = (ig.get("username") or "").strip() + name = (ig.get("name") or "").strip() + label = f"@{username}" if username else (name or "Instagram account") + return { + "access_token": page_token, + "page_id": str(page["id"]), + "ig_user_id": ig_id, + "username": username, + "label": label, + "user_access_token": user_token, + "auth_type": "facebook_login_for_business", + } + + +def expires_at_from_fragment(expires_in: str | int | None) -> str | None: + try: + seconds = int(expires_in or 0) + except (TypeError, ValueError): + return None + if seconds <= 0: + return None + return (timezone.now() + timedelta(seconds=seconds)).isoformat() diff --git a/site/social/tasks.py b/site/social/tasks.py new file mode 100644 index 0000000..1a1abde --- /dev/null +++ b/site/social/tasks.py @@ -0,0 +1,59 @@ +from django.tasks import task + +from social.connectors.linkedin import LinkedInConnector +from social.connectors.meta import MetaConnector +from social.models import Platform, SocialPost, SocialPostTarget + + +def _connector_for(platform: str): + if platform in {Platform.FACEBOOK, Platform.INSTAGRAM}: + return MetaConnector() + if platform == Platform.LINKEDIN: + return LinkedInConnector() + raise ValueError(f"Unknown platform: {platform}") + + +@task +def publish_social_post(post_id: str) -> None: + try: + post = SocialPost.objects.prefetch_related("targets__account").get(pk=post_id) + except SocialPost.DoesNotExist: + return + + post.status = SocialPost.Status.PUBLISHING + post.save(update_fields=["status", "updated_at"]) + + any_ok = False + for target in post.targets.all(): + try: + remote_id = _connector_for(target.platform).publish(post, target) + target.status = SocialPostTarget.Status.PUBLISHED + target.remote_id = remote_id + target.error = "" + target.save(update_fields=["status", "remote_id", "error", "updated_at"]) + any_ok = True + except Exception as exc: # noqa: BLE001 + target.status = SocialPostTarget.Status.FAILED + target.error = str(exc)[:2000] + target.save(update_fields=["status", "error", "updated_at"]) + + post.status = ( + SocialPost.Status.PUBLISHED if any_ok else SocialPost.Status.FAILED + ) + if not any_ok: + post.error = "All targets failed" + post.save(update_fields=["status", "error", "updated_at"]) + + +@task +def publish_social_target(target_id: str) -> None: + try: + target = SocialPostTarget.objects.select_related("post", "account").get( + pk=target_id + ) + except SocialPostTarget.DoesNotExist: + return + remote_id = _connector_for(target.platform).publish(target.post, target) + target.status = SocialPostTarget.Status.PUBLISHED + target.remote_id = remote_id + target.save(update_fields=["status", "remote_id", "updated_at"]) diff --git a/site/social/templates/social/account_list.html b/site/social/templates/social/account_list.html new file mode 100644 index 0000000..14085fe --- /dev/null +++ b/site/social/templates/social/account_list.html @@ -0,0 +1,248 @@ +{% extends "portal_base.html" %} +{% block title %}Social accounts · Portal{% endblock %} +{% block topbar_title %}Social accounts{% endblock %} +{% block portal_content %} +
+

Add account

+
+ + + {% if connect_meta %} +
+

{{ connect_meta.title }}

+
    + {% for step in connect_meta.steps %} +
  1. {{ step }}
  2. + {% endfor %} + {% if connect_meta.docs_url %} +
  3. Reference: platform docs
  4. + {% endif %} +
+ + {% if connect_meta.oauth %} +
+ +
+ + +
+

+ Must match exactly (including http/https and trailing slash). +

+
+ +
+ {% csrf_token %} + + +
+ + +
+
+ + + {% if oauth_secret_saved %} +

Secret already saved (encrypted). Enter a new value only to replace it.

+ {% endif %} +
+
+ + {% if oauth_ready and oauth_start_url_name %} + {{ connect_meta.connect_label }} + {% else %} + + {% endif %} + Cancel +
+
+ {% else %} +
+ {% csrf_token %} + + + {% for name, label, placeholder in connect_meta.fields %} +
+ + {% if name == 'access_token' %} + + {% else %} + + {% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+ {% endif %} +
+ {% endif %} +
+
+ +
+

Connected accounts

+
+ + + + + + + + + + + {% for account in accounts %} + + + + + + + {% empty %} + + {% endfor %} + +
AccountPlatformStatus
+ + {% if account.external_id %} + {{ account.external_id }} + {% endif %} + + + {{ account.get_platform_display }} + + + {% if account.is_active %} + Active + {% else %} + Inactive + {% endif %} + +
+ {% if account.platform == 'linkedin' %} + Re-authorize + {% elif account.platform == 'instagram' %} + Re-authorize + {% elif account.platform == 'facebook' %} + Re-authorize + {% else %} + Update tokens + {% endif %} + {% if account.is_active %} +
+ {% csrf_token %} + + + +
+ {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+
No accounts connected yet — pick a platform above.
+
+
+ +
+
+

When to re-auth

+
+
    +
  • LinkedIn / Instagram access tokens expire on a short cycle
  • +
  • Meta password changes or app review updates
  • +
  • Publishing fails with an auth error
  • +
+
+
+
+

Next

+ +
+
+{% endblock %} +{% block extra_js %} +{% if connect_platform %} + +{% endif %} +{% if connect_meta and connect_meta.oauth %} + +{% endif %} +{% endblock %} diff --git a/site/social/templates/social/composer.html b/site/social/templates/social/composer.html new file mode 100644 index 0000000..8af04b7 --- /dev/null +++ b/site/social/templates/social/composer.html @@ -0,0 +1,414 @@ +{% extends "portal_base.html" %} +{% block title %}Compose · Social{% endblock %} +{% block topbar_title %}Compose & preview{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block portal_content %} +{% if not accounts %} +
+ No social accounts connected. + Connect Facebook, Instagram, or LinkedIn first. +
+{% endif %} + +
+
+
+

Compose

+ Manage accounts +
+
+ {% if error %} +
  • {{ error }}
+ {% endif %} +
+ {% csrf_token %} + +
+ + +
0 characters · IG soft limit ~2,200
+
+
+ +
+ +
+ Images (JPEG/PNG/GIF/WebP, ≤8 MB) or one video (MP4/MOV/WebM, ≤100 MB). + Instagram requires media. Don’t mix images + video on one post. +
+ +
+
+
+
+ + {% for account in accounts %} + + {% empty %} +

No active accounts.

+ {% endfor %} + {% if accounts %} + + {% endif %} +
+
+
+ + +
Needed only if you click Schedule. Leave blank to Send now.
+
+
+
+ + + +
+
+
+
+ + {% if 'social_ai' in enabled_features %} +
+

AI assist

+
+
+
+ + +
+ +

Generated text fills the caption field. Review before publishing.

+
+
+
+ {% endif %} +
+ +
+

Preview

+
+
+
Preview updates as you type or attach media.
+ +
+
+
+ +

+ View recent posts +

+{% endblock %} +{% block extra_js %} + +{% if 'social_ai' in enabled_features %} + +{% endif %} + + +{% endblock %} diff --git a/site/social/templates/social/meta_oauth_callback.html b/site/social/templates/social/meta_oauth_callback.html new file mode 100644 index 0000000..9ba8148 --- /dev/null +++ b/site/social/templates/social/meta_oauth_callback.html @@ -0,0 +1,63 @@ +{% extends "portal_base.html" %} +{% block title %}Connecting Meta…{% endblock %} +{% block topbar_title %}Connecting…{% endblock %} +{% block portal_content %} +
+

Finishing Facebook Login

+
+

Capturing access token from Facebook…

+ +
+
+ +{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/site/social/templates/social/post_detail.html b/site/social/templates/social/post_detail.html new file mode 100644 index 0000000..d3040eb --- /dev/null +++ b/site/social/templates/social/post_detail.html @@ -0,0 +1,58 @@ +{% extends "portal_base.html" %} +{% block title %}Post · Portal{% endblock %} +{% block topbar_title %}Social post{% endblock %} +{% block portal_content %} +
+ {{ post.get_status_display }} + ← All posts +
+
+
+

Caption

+
+ {% if post.media %} +
+ {% for item in post.media %} + {% if item.type == "video" %} + + {% else %} + {{ item.filename|default:'Image' }} + {% endif %} + {% endfor %} +
+ {% endif %} +
{{ post.body }}
+ {% if post.scheduled_for %} +

Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}

+ {% endif %} + {% if post.ollama_prompt %} +

AI prompt: {{ post.ollama_prompt }}

+ {% endif %} +
+
+
+

Targets

+
+ + + + {% for target in post.targets.all %} + + + + + + {% empty %} + + {% endfor %} + +
AccountPlatformStatus
+ {{ target.account.display_name }} + {% if target.account.external_id %} +
{{ target.account.external_id }} + {% endif %} +
{{ target.get_platform_display }}{{ target.get_status_display }}
No targets attached.
+
+
+
+{% endblock %} diff --git a/site/social/templates/social/post_list.html b/site/social/templates/social/post_list.html new file mode 100644 index 0000000..9bbedb0 --- /dev/null +++ b/site/social/templates/social/post_list.html @@ -0,0 +1,30 @@ +{% extends "portal_base.html" %} +{% block title %}Social posts · Portal{% endblock %} +{% block topbar_title %}Scheduled & drafts{% endblock %} +{% block portal_content %} +
+

Posts saved from the composer.

+ Compose +
+
+
+ + + + + + {% for post in posts %} + + + + + + + {% empty %} + + {% endfor %} + +
PreviewStatusScheduled
{{ post.body|truncatechars:80 }}{{ post.get_status_display }}{% if post.scheduled_for %}{{ post.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}Open
No posts yet. Compose one.
+
+
+{% endblock %} diff --git a/site/social/tests.py b/site/social/tests.py new file mode 100644 index 0000000..95aed9e --- /dev/null +++ b/site/social/tests.py @@ -0,0 +1,207 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from social.models import ( + Platform, + SocialAccount, + SocialPost, + is_generic_account_label, +) + + +class AccountLabelTests(TestCase): + def test_generic_placeholders(self): + self.assertTrue(is_generic_account_label("")) + self.assertTrue(is_generic_account_label("LinkedIn member")) + self.assertTrue(is_generic_account_label("Instagram account")) + self.assertTrue(is_generic_account_label("Page 123456")) + self.assertTrue(is_generic_account_label("urn:li:person:abc")) + self.assertFalse(is_generic_account_label("Monica Dhillon")) + self.assertFalse(is_generic_account_label("@mkdrealtor")) + + def test_resolve_keeps_custom_name(self): + existing = SocialAccount( + platform=Platform.LINKEDIN, + label="Monica (personal)", + external_id="urn:li:person:abc", + ) + self.assertEqual( + SocialAccount.resolve_label(existing, "LinkedIn member"), + "Monica (personal)", + ) + + def test_resolve_replaces_generic(self): + existing = SocialAccount( + platform=Platform.LINKEDIN, + label="LinkedIn member", + external_id="urn:li:person:abc", + ) + self.assertEqual( + SocialAccount.resolve_label(existing, "Monica Dhillon"), + "Monica Dhillon", + ) + + +class SocialComposerTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="composer", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="composer", password="test-pass-123") + self.account = SocialAccount.objects.create( + platform=Platform.LINKEDIN, + label="LinkedIn member", + external_id="urn:li:person:abc123", + is_active=True, + owner=self.user, + ) + + def test_composer_shows_send_now_and_full_name(self): + response = self.client.get(reverse("social:composer")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Send now") + self.assertContains(response, "Schedule") + self.assertContains(response, "LinkedIn member") + self.assertContains(response, "urn:li:person:abc123") + self.assertNotContains(response, "Save / publish") + + def test_send_now_queues_without_schedule(self): + mock_task = patch("social.views.publish_social_post").start() + self.addCleanup(patch.stopall) + response = self.client.post( + reverse("social:composer"), + { + "body": "Open house Saturday", + "account_ids": [str(self.account.pk)], + "action": "send_now", + }, + ) + post = SocialPost.objects.get() + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, reverse("social:post_detail", kwargs={"pk": post.pk}) + ) + self.assertEqual(post.status, SocialPost.Status.QUEUED) + self.assertIsNotNone(post.scheduled_for) + self.assertEqual(post.targets.count(), 1) + mock_task.enqueue.assert_called_once_with(post_id=str(post.pk)) + + def test_schedule_requires_datetime(self): + response = self.client.post( + reverse("social:composer"), + { + "body": "Open house Saturday", + "account_ids": [str(self.account.pk)], + "action": "schedule", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Pick a schedule date/time, or choose Send now.") + self.assertFalse(SocialPost.objects.exists()) + + def test_schedule_with_datetime(self): + response = self.client.post( + reverse("social:composer"), + { + "body": "Open house Saturday", + "account_ids": [str(self.account.pk)], + "action": "schedule", + "scheduled_for": "2026-08-10T09:30", + }, + ) + post = SocialPost.objects.get() + self.assertEqual(response.status_code, 302) + self.assertEqual(post.status, SocialPost.Status.SCHEDULED) + self.assertIsNotNone(post.scheduled_for) + + def test_save_draft(self): + response = self.client.post( + reverse("social:composer"), + { + "body": "Draft caption", + "action": "save", + }, + ) + post = SocialPost.objects.get() + self.assertEqual(response.status_code, 302) + self.assertEqual(post.status, SocialPost.Status.DRAFT) + + +class SocialAccountRenameTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="renamer", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="renamer", password="test-pass-123") + self.account = SocialAccount.objects.create( + platform=Platform.FACEBOOK, + label="Page 999", + external_id="999", + is_active=True, + owner=self.user, + ) + + def test_accounts_page_shows_rename_field(self): + response = self.client.get(reverse("social:account_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'name="label"') + self.assertContains(response, "Page 999") + self.assertContains(response, "Rename") + + def test_rename_updates_label(self): + response = self.client.post( + reverse("social:account_list"), + { + "action": "rename", + "account_id": str(self.account.pk), + "label": "Monica Dhillon Realty", + }, + ) + self.assertEqual(response.status_code, 302) + self.account.refresh_from_db() + self.assertEqual(self.account.label, "Monica Dhillon Realty") + + def test_rename_rejects_blank(self): + response = self.client.post( + reverse("social:account_list"), + { + "action": "rename", + "account_id": str(self.account.pk), + "label": " ", + }, + ) + self.assertEqual(response.status_code, 302) + self.account.refresh_from_db() + self.assertEqual(self.account.label, "Page 999") + + +from datetime import timedelta +from io import StringIO +from unittest.mock import patch as _patch + +from django.core.management import call_command +from django.utils import timezone + + +class DispatchDueSocialTests(TestCase): + def test_enqueues_past_scheduled_social_post(self): + post = SocialPost.objects.create( + body="Open house", + status=SocialPost.Status.SCHEDULED, + scheduled_for=timezone.now() - timedelta(minutes=5), + ) + mock_task = _patch("social.tasks.publish_social_post").start() + self.addCleanup(_patch.stopall) + out = StringIO() + call_command("dispatch_due", stdout=out) + post.refresh_from_db() + self.assertEqual(post.status, SocialPost.Status.QUEUED) + mock_task.enqueue.assert_called_once_with(post_id=str(post.pk)) + self.assertIn("Enqueued 1 due item(s).", out.getvalue()) diff --git a/site/social/urls.py b/site/social/urls.py new file mode 100644 index 0000000..be8a2ae --- /dev/null +++ b/site/social/urls.py @@ -0,0 +1,43 @@ +from django.urls import path + +from social import views + +app_name = "social" + +urlpatterns = [ + path("", views.post_list, name="post_list"), + path("accounts/", views.account_list, name="account_list"), + path( + "accounts/linkedin/start/", + views.linkedin_oauth_start, + name="linkedin_oauth_start", + ), + path( + "accounts/linkedin/callback/", + views.linkedin_oauth_callback, + name="linkedin_oauth_callback", + ), + path( + "accounts/facebook/start/", + views.facebook_oauth_start, + name="facebook_oauth_start", + ), + path( + "accounts/instagram/start/", + views.instagram_oauth_start, + name="instagram_oauth_start", + ), + path( + "accounts/meta/callback/", + views.meta_oauth_callback, + name="meta_oauth_callback", + ), + path( + "accounts/meta/callback/complete/", + views.meta_oauth_complete, + name="meta_oauth_complete", + ), + path("compose/", views.composer, name="composer"), + path("media/upload/", views.media_upload, name="media_upload"), + path("/", views.post_detail, name="post_detail"), +] diff --git a/site/social/views.py b/site/social/views.py new file mode 100644 index 0000000..680de53 --- /dev/null +++ b/site/social/views.py @@ -0,0 +1,716 @@ +import json +import secrets + +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.http import JsonResponse +from django.urls import reverse +from django.utils import timezone +from django.utils.safestring import mark_safe +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from core.scheduling import parse_scheduled_for +from core.models import StoredFile +from social.crypto import SocialCryptoError, encrypt_tokens +from social.media import ( + ALLOWED_IMAGE_TYPES, + ALLOWED_VIDEO_TYPES, + MAX_IMAGE_BYTES, + MAX_VIDEO_BYTES, + media_item_from_stored, + parse_media_json, +) +from social.models import ( + Platform, + SocialAccount, + SocialAppCredentials, + SocialPost, + SocialPostTarget, +) +from social.oauth_linkedin import ( + LinkedInOAuthError, + authorization_url as linkedin_authorization_url, + credentials_configured as linkedin_credentials_configured, + exchange_code as linkedin_exchange_code, + fetch_member_profile as linkedin_fetch_member_profile, + get_app_credentials as get_linkedin_app_credentials, + redirect_uri as linkedin_redirect_uri, + token_blob_from_oauth as linkedin_token_blob_from_oauth, +) +from social.oauth_meta import ( + MetaOAuthError, + authorization_url as meta_authorization_url, + credentials_configured as meta_credentials_configured, + facebook_token_blob, + fetch_pages, + get_app_credentials as get_meta_app_credentials, + instagram_token_blob, + redirect_uri as meta_redirect_uri, +) +from social.tasks import publish_social_post + +_META_DOCS = ( + "https://developers.facebook.com/documentation/instagram-platform/" + "instagram-api-with-facebook-login/business-login-for-instagram" +) + +CONNECT_INSTRUCTIONS = { + Platform.FACEBOOK: { + "title": "Connect Facebook Page", + "oauth": True, + "oauth_platform": "facebook", + "client_id_label": "Meta App ID", + "client_secret_label": "Meta App Secret", + "connect_label": "Connect with Facebook", + "save_action": "save_meta_app", + "callback_label": ( + "Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings" + ), + "steps": [ + mark_safe( + 'In Meta for Developers, use a Business-type app and add ' + "Facebook Login for Business plus Instagram " + "(API setup with Facebook login)." + ), + "Under Facebook Login for Business → Settings → Valid OAuth Redirect URIs, " + "paste the callback URL shown below (exact match required).", + "Copy Meta App ID and App Secret into the fields below, then Save app credentials.", + "Click Connect with Facebook — grant Page permissions; we store the Page " + "access token for posting.", + ], + "docs_url": _META_DOCS, + "fields": [], + }, + Platform.INSTAGRAM: { + "title": "Connect Instagram", + "oauth": True, + "oauth_platform": "instagram", + "client_id_label": "Meta App ID", + "client_secret_label": "Meta App Secret", + "connect_label": "Connect with Instagram", + "save_action": "save_meta_app", + "callback_label": ( + "Valid OAuth Redirect URI — paste into Meta → Facebook Login for Business → Settings" + ), + "steps": [ + mark_safe( + 'Follow Facebook Login for Business ' + "for Instagram — Professional IG account linked to a Facebook Page." + ), + "Same Meta App ID / Secret as Facebook (shared). Save credentials once.", + "Add the redirect URI below to Valid OAuth Redirect URIs, then Connect with Instagram.", + "We request Page + Instagram publish scopes and save the linked Instagram " + "Business account using the Page access token.", + ], + "docs_url": _META_DOCS, + "fields": [], + }, + Platform.LINKEDIN: { + "title": "Connect LinkedIn", + "oauth": True, + "oauth_platform": "linkedin", + "client_id_label": "Client ID", + "client_secret_label": "Primary Client Secret", + "connect_label": "Connect with LinkedIn", + "save_action": "save_linkedin_app", + "callback_label": "Authorized redirect URL — paste into LinkedIn Auth tab", + "steps": [ + mark_safe( + 'Create a LinkedIn app in the ' + 'LinkedIn Developer Portal.' + ), + "Products tab — request “Sign In with LinkedIn using OpenID Connect” " + "and “Share on LinkedIn”.", + "Auth tab — under Authorized redirect URLs, paste the callback URL " + "shown in the box below (exact match required).", + "Auth tab — copy Client ID and Primary Client Secret into the fields below, " + "then Save app credentials.", + "Click Connect with LinkedIn to authorize. Access tokens last ~2 months; " + "use Re-authorize when publishing fails with auth errors.", + ], + "docs_url": "https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow", + "fields": [], + }, +} + + +@login_required +def post_list(request): + posts = SocialPost.objects.all()[:100] + return render(request, "social/post_list.html", {"posts": posts}) + + +@login_required +def post_detail(request, pk): + post = get_object_or_404(SocialPost.objects.prefetch_related("targets"), pk=pk) + return render(request, "social/post_detail.html", {"post": post}) + + +@login_required +@require_http_methods(["GET", "POST"]) +def account_list(request): + accounts = SocialAccount.objects.all() + connect_platform = (request.GET.get("connect") or "").strip() + if connect_platform not in Platform.values: + connect_platform = "" + + if request.method == "POST": + action = (request.POST.get("action") or "connect").strip() + if action == "rename": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + label = (request.POST.get("label") or "").strip()[:120] + if not label: + messages.error(request, "Enter a display name.") + return redirect("social:account_list") + account.label = label + account.save(update_fields=["label", "updated_at"]) + messages.success(request, f"Renamed account to {account.label}.") + return redirect("social:account_list") + if action == "disconnect": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + account.is_active = False + account.encrypted_tokens = "" + account.save(update_fields=["is_active", "encrypted_tokens", "updated_at"]) + messages.success(request, f"Disconnected {account.label}.") + return redirect("social:account_list") + if action == "reactivate": + pk = request.POST.get("account_id") + account = get_object_or_404(SocialAccount, pk=pk) + account.is_active = True + account.save(update_fields=["is_active", "updated_at"]) + messages.success(request, f"Reactivated {account.label}.") + return redirect("social:account_list") + if action in {"save_linkedin_app", "save_meta_app", "save_instagram_app"}: + if action == "save_linkedin_app": + platform = Platform.LINKEDIN + connect_platform_name = "linkedin" + label = "LinkedIn" + else: + # Meta App ID/Secret shared by Facebook + Instagram (Facebook Login for Business). + platform = Platform.FACEBOOK + connect_return = (request.POST.get("connect_return") or "").strip() + if connect_return not in {Platform.FACEBOOK, Platform.INSTAGRAM}: + connect_return = Platform.FACEBOOK + connect_platform_name = connect_return + label = "Meta" + connect_q = f"?connect={connect_platform_name}" + client_id = (request.POST.get("client_id") or "").strip() + client_secret = (request.POST.get("client_secret") or "").strip() + if not client_id: + messages.error(request, "App ID / Client ID is required.") + return redirect(f"{request.path}{connect_q}") + app, _created = SocialAppCredentials.objects.get_or_create(platform=platform) + app.client_id = client_id + if client_secret: + try: + app.encrypted_client_secret = encrypt_tokens(client_secret) + except SocialCryptoError as exc: + messages.error(request, str(exc)) + return redirect(f"{request.path}{connect_q}") + elif not app.encrypted_client_secret: + messages.error( + request, + "App Secret is required the first time you save credentials.", + ) + return redirect(f"{request.path}{connect_q}") + app.save() + messages.success( + request, + f"{label} app credentials saved. You can Connect now.", + ) + return redirect(f"{request.path}{connect_q}") + + platform = (request.POST.get("platform") or "").strip() + if platform not in Platform.values: + messages.error(request, "Choose a platform.") + return redirect("social:account_list") + # Manual token paste remains as fallback for non-OAuth platforms only. + if platform in {Platform.FACEBOOK, Platform.INSTAGRAM, Platform.LINKEDIN}: + messages.error( + request, + "Use Connect with OAuth for this platform (Save app credentials first).", + ) + return redirect(f"{request.path}?connect={platform}") + label = (request.POST.get("label") or "").strip() or platform.title() + external_id = (request.POST.get("external_id") or "").strip() + access_token = (request.POST.get("access_token") or "").strip() + if not access_token or not external_id: + messages.error(request, "Access token and external ID are required.") + return redirect(f"{request.path}?connect={platform}") + + token_blob = {"access_token": access_token} + try: + encrypted = encrypt_tokens(json.dumps(token_blob)) + except SocialCryptoError as exc: + messages.error(request, str(exc)) + return redirect(f"{request.path}?connect={platform}") + + account, created = SocialAccount.objects.update_or_create( + platform=platform, + external_id=external_id, + defaults={ + "label": label, + "encrypted_tokens": encrypted, + "is_active": True, + "owner": request.user, + }, + ) + verb = "Connected" if created else "Updated" + messages.success(request, f"{verb} {account.get_platform_display()} · {account.label}.") + return redirect("social:account_list") + + linkedin_app = get_linkedin_app_credentials() + meta_app = get_meta_app_credentials() + linkedin_callback = "" + meta_callback = "" + try: + linkedin_callback = linkedin_redirect_uri() + except LinkedInOAuthError: + linkedin_callback = "(set PUBLIC_SITE_URL, then restart)" + try: + meta_callback = meta_redirect_uri() + except MetaOAuthError: + meta_callback = "(set PUBLIC_SITE_URL, then restart)" + + meta_ready = meta_credentials_configured() + meta_client_id = meta_app.client_id if meta_app else "" + meta_secret_saved = bool(meta_app and meta_app.encrypted_client_secret) + oauth_ctx = { + "linkedin": { + "ready": linkedin_credentials_configured(), + "callback_url": linkedin_callback, + "client_id": (linkedin_app.client_id if linkedin_app else ""), + "secret_saved": bool( + linkedin_app and linkedin_app.encrypted_client_secret + ), + "start_url_name": "social:linkedin_oauth_start", + }, + "facebook": { + "ready": meta_ready, + "callback_url": meta_callback, + "client_id": meta_client_id, + "secret_saved": meta_secret_saved, + "start_url_name": "social:facebook_oauth_start", + }, + "instagram": { + "ready": meta_ready, + "callback_url": meta_callback, + "client_id": meta_client_id, + "secret_saved": meta_secret_saved, + "start_url_name": "social:instagram_oauth_start", + }, + } + oauth_state = oauth_ctx.get(connect_platform) or {} + + return render( + request, + "social/account_list.html", + { + "accounts": accounts, + "connect_platform": connect_platform, + "connect_meta": CONNECT_INSTRUCTIONS.get(connect_platform), + "platforms": Platform, + "oauth_ready": oauth_state.get("ready", False), + "oauth_callback_url": oauth_state.get("callback_url", ""), + "oauth_client_id": oauth_state.get("client_id", ""), + "oauth_secret_saved": oauth_state.get("secret_saved", False), + "oauth_start_url_name": oauth_state.get("start_url_name", ""), + }, + ) + + +@login_required +@require_GET +def linkedin_oauth_start(request): + """Redirect browser to LinkedIn consent screen.""" + if not linkedin_credentials_configured(): + messages.error( + request, + "Save LinkedIn Client ID and Client Secret on the Connect LinkedIn form first.", + ) + return redirect(f"{reverse('social:account_list')}?connect=linkedin") + state = secrets.token_urlsafe(24) + request.session["linkedin_oauth_state"] = state + try: + return redirect(linkedin_authorization_url(state=state)) + except LinkedInOAuthError as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}?connect=linkedin") + + +@login_required +@require_GET +def linkedin_oauth_callback(request): + """Handle LinkedIn redirect: exchange code, store encrypted SocialAccount.""" + error = (request.GET.get("error") or "").strip() + if error: + desc = (request.GET.get("error_description") or error).strip() + messages.error(request, f"LinkedIn authorization denied: {desc}") + return redirect("social:account_list") + + state = (request.GET.get("state") or "").strip() + expected = request.session.pop("linkedin_oauth_state", None) + if not state or not expected or state != expected: + messages.error(request, "LinkedIn OAuth state mismatch — try Connect again.") + return redirect("social:account_list") + + code = (request.GET.get("code") or "").strip() + if not code: + messages.error(request, "LinkedIn did not return an authorization code.") + return redirect("social:account_list") + + try: + token_payload = linkedin_exchange_code(code) + profile = linkedin_fetch_member_profile(token_payload["access_token"]) + author_urn = profile["author_urn"] + blob = linkedin_token_blob_from_oauth(token_payload, author_urn=author_urn) + existing = SocialAccount.objects.filter( + platform=Platform.LINKEDIN, external_id=author_urn + ).first() + account, created = SocialAccount.objects.update_or_create( + platform=Platform.LINKEDIN, + external_id=author_urn, + defaults={ + "label": SocialAccount.resolve_label(existing, profile["label"]), + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + except (LinkedInOAuthError, SocialCryptoError) as exc: + messages.error(request, str(exc)) + return redirect("social:account_list") + except Exception: + messages.error( + request, + "Unexpected error saving LinkedIn account. Check app logs.", + ) + raise + + verb = "Connected" if created else "Re-authorized" + messages.success( + request, + f"{verb} LinkedIn · {account.label}. You can post from the composer.", + ) + return redirect("social:account_list") + + +@login_required +@require_GET +def facebook_oauth_start(request): + """Start Facebook Login for Business; save Facebook Page account on complete.""" + return _meta_oauth_start(request, intent=Platform.FACEBOOK) + + +@login_required +@require_GET +def instagram_oauth_start(request): + """Start Facebook Login for Business; save linked Instagram Business account.""" + return _meta_oauth_start(request, intent=Platform.INSTAGRAM) + + +def _meta_oauth_start(request, *, intent: str): + connect_q = f"?connect={intent}" + if not meta_credentials_configured(): + messages.error( + request, + "Save Meta App ID and App Secret on the Connect form first.", + ) + return redirect(f"{reverse('social:account_list')}{connect_q}") + state = secrets.token_urlsafe(24) + request.session["meta_oauth_state"] = state + request.session["meta_oauth_intent"] = intent + try: + return redirect(meta_authorization_url(state=state)) + except MetaOAuthError as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}{connect_q}") + + +@login_required +@require_GET +def meta_oauth_callback(request): + """ + Landing page after Facebook redirect. + + Tokens arrive in the URL fragment (#) per Meta docs (response_type=token), + so JS reads them and POSTs to meta_oauth_complete. + """ + return render(request, "social/meta_oauth_callback.html") + + +@login_required +@require_POST +def meta_oauth_complete(request): + """Persist Facebook Page and/or Instagram accounts from Facebook Login tokens.""" + intent = request.session.pop("meta_oauth_intent", Platform.FACEBOOK) + connect_q = f"?connect={intent}" + + error = (request.POST.get("error") or "").strip() + if error: + desc = (request.POST.get("error_description") or error).strip() + messages.error(request, f"Facebook authorization denied: {desc}") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + state = (request.POST.get("state") or "").strip() + expected = request.session.pop("meta_oauth_state", None) + if not state or not expected or state != expected: + messages.error(request, "Meta OAuth state mismatch — try Connect again.") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + user_token = ( + (request.POST.get("long_lived_token") or "").strip() + or (request.POST.get("access_token") or "").strip() + ) + if not user_token: + messages.error(request, "Facebook did not return an access token.") + return redirect(f"{reverse('social:account_list')}{connect_q}") + + try: + pages = fetch_pages(user_token) + connected = [] + + if intent == Platform.INSTAGRAM: + ig_pages = [p for p in pages if (p.get("instagram_business_account") or {}).get("id")] + if not ig_pages: + raise MetaOAuthError( + "No Instagram Professional account linked to your Facebook Pages. " + "Link IG in Meta Business Suite, then Connect again." + ) + for page in ig_pages: + blob = instagram_token_blob(page, user_token=user_token) + existing = SocialAccount.objects.filter( + platform=Platform.INSTAGRAM, external_id=blob["ig_user_id"] + ).first() + account, created = SocialAccount.objects.update_or_create( + platform=Platform.INSTAGRAM, + external_id=blob["ig_user_id"], + defaults={ + "label": SocialAccount.resolve_label(existing, blob["label"]), + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + connected.append(("Connected" if created else "Re-authorized", account)) + else: + for page in pages: + blob = facebook_token_blob(page, user_token=user_token) + incoming_label = (page.get("name") or "").strip() or f"Page {page['id']}" + existing = SocialAccount.objects.filter( + platform=Platform.FACEBOOK, external_id=str(page["id"]) + ).first() + account, created = SocialAccount.objects.update_or_create( + platform=Platform.FACEBOOK, + external_id=str(page["id"]), + defaults={ + "label": SocialAccount.resolve_label(existing, incoming_label), + "encrypted_tokens": encrypt_tokens(json.dumps(blob)), + "is_active": True, + "owner": request.user, + }, + ) + connected.append(("Connected" if created else "Re-authorized", account)) + except (MetaOAuthError, SocialCryptoError) as exc: + messages.error(request, str(exc)) + return redirect(f"{reverse('social:account_list')}{connect_q}") + except Exception: + messages.error( + request, + "Unexpected error saving Meta account. Check app logs.", + ) + raise + + for verb, account in connected: + messages.success( + request, + f"{verb} {account.get_platform_display()} · {account.label}.", + ) + return redirect("social:account_list") + + +@login_required +@require_http_methods(["GET", "POST"]) +def composer(request): + """Compose, schedule, or publish to connected social accounts.""" + accounts = list(SocialAccount.objects.filter(is_active=True)) + form = { + "body": "", + "prompt": "", + "publish_mode": "now", + "scheduled_for": "", + "account_ids": [], + "media_json": "[]", + } + error = "" + draft = "" + + if request.method == "POST": + form["body"] = (request.POST.get("body") or "").strip() + form["prompt"] = (request.POST.get("prompt") or "").strip() + form["publish_mode"] = (request.POST.get("publish_mode") or "now").strip() + form["scheduled_for"] = request.POST.get("scheduled_for") or "" + form["account_ids"] = request.POST.getlist("account_ids") + form["media_json"] = (request.POST.get("media_json") or "[]").strip() or "[]" + action = (request.POST.get("action") or "save").strip() + if action == "send_now": + form["publish_mode"] = "now" + elif action == "schedule": + form["publish_mode"] = "schedule" + elif action == "save": + form["publish_mode"] = "draft" + + if action in {"save", "publish", "send_now", "schedule"}: + media_items: list = [] + try: + media_items = parse_media_json(form["media_json"]) + except ValueError as exc: + error = str(exc) + + if not error and not form["body"] and not media_items: + error = "Add a caption and/or attach an image or video." + elif not error and form["publish_mode"] != "draft" and not form["account_ids"]: + error = "Select at least one connected account." + else: + scheduled_for = None + status = SocialPost.Status.DRAFT + if form["publish_mode"] == "draft": + status = SocialPost.Status.DRAFT + elif form["publish_mode"] == "schedule": + try: + scheduled_for = parse_scheduled_for(form["scheduled_for"]) + except ValueError as exc: + error = str(exc) + else: + if not scheduled_for: + error = "Pick a schedule date/time, or choose Send now." + else: + status = SocialPost.Status.SCHEDULED + elif form["publish_mode"] == "now": + status = SocialPost.Status.QUEUED + scheduled_for = timezone.now() + else: + error = "Unknown publish mode." + + if not error: + selected = SocialAccount.objects.filter( + pk__in=form["account_ids"], is_active=True + ) + if form["publish_mode"] != "draft" and not selected.exists(): + error = "No valid accounts selected." + elif ( + form["publish_mode"] != "draft" + and selected.filter(platform=Platform.INSTAGRAM).exists() + and not media_items + ): + error = ( + "Instagram requires an image or video. " + "Attach media before publishing to Instagram." + ) + else: + post = SocialPost.objects.create( + body=form["body"], + media=media_items, + ollama_prompt=form["prompt"], + scheduled_for=scheduled_for, + status=status, + created_by=request.user, + ) + for account in selected: + SocialPostTarget.objects.create( + post=post, + account=account, + platform=account.platform, + ) + if status == SocialPost.Status.QUEUED: + publish_social_post.enqueue(post_id=str(post.pk)) + messages.success( + request, + "Post sent — queued for publishing to selected accounts.", + ) + elif status == SocialPost.Status.SCHEDULED: + messages.success( + request, + f"Post scheduled for {scheduled_for:%b %d, %I:%M %p}.", + ) + else: + messages.success(request, "Draft saved.") + return redirect("social:post_detail", pk=post.pk) + + return render( + request, + "social/composer.html", + { + "accounts": accounts, + "form": form, + "error": error, + "draft": draft or form["body"], + "media_upload_url": reverse("social:media_upload"), + }, + ) + + +@login_required +@require_POST +def media_upload(request): + """Upload image/video for social compose; store bytes in StoredFile.""" + upload = ( + request.FILES.get("media") + or request.FILES.get("file") + or request.FILES.get("image") + or request.FILES.get("video") + ) + if not upload: + return JsonResponse({"error": "No file uploaded."}, status=400) + + content_type = (getattr(upload, "content_type", None) or "").lower() + if content_type in ALLOWED_IMAGE_TYPES: + kind = StoredFile.Kind.SOCIAL_IMAGE + max_bytes = MAX_IMAGE_BYTES + label = "Image" + elif content_type in ALLOWED_VIDEO_TYPES: + kind = StoredFile.Kind.SOCIAL_VIDEO + max_bytes = MAX_VIDEO_BYTES + label = "Video" + else: + return JsonResponse( + { + "error": "Use JPEG/PNG/GIF/WebP images or MP4/MOV/WebM video.", + }, + status=400, + ) + + size = int(getattr(upload, "size", 0) or 0) + if size and size > max_bytes: + return JsonResponse( + {"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."}, + status=400, + ) + + data = upload.read() + if len(data) > max_bytes: + return JsonResponse( + {"error": f"{label} must be {max_bytes // (1024 * 1024)} MB or smaller."}, + status=400, + ) + + original = (getattr(upload, "name", None) or "media")[:255] + stored = StoredFile.objects.create( + kind=kind, + filename=original, + content_type=content_type, + size=len(data), + data=data, + uploaded_by=request.user if request.user.is_authenticated else None, + ) + item = media_item_from_stored(stored) + return JsonResponse(item) + + diff --git a/site/social_ai/__init__.py b/site/social_ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social_ai/apps.py b/site/social_ai/apps.py new file mode 100644 index 0000000..290dd44 --- /dev/null +++ b/site/social_ai/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class SocialAiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "social_ai" + verbose_name = "AI social generator" + + def ready(self): + from social_ai import hooks + + hooks.register() diff --git a/site/social_ai/hooks.py b/site/social_ai/hooks.py new file mode 100644 index 0000000..65184e2 --- /dev/null +++ b/site/social_ai/hooks.py @@ -0,0 +1,5 @@ +from core.registry import register_feature + + +def register() -> None: + register_feature("social_ai") diff --git a/site/social_ai/migrations/__init__.py b/site/social_ai/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/site/social_ai/ollama.py b/site/social_ai/ollama.py new file mode 100644 index 0000000..b600109 --- /dev/null +++ b/site/social_ai/ollama.py @@ -0,0 +1,63 @@ +"""Ollama client for drafting social posts.""" + +from __future__ import annotations + +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class OllamaError(RuntimeError): + pass + + +def generate_social_post( + prompt: str, + *, + platform: str = "", + tone: str = "professional, warm, on-brand", +) -> str: + """ + Call the LAN Ollama endpoint (default http://10.0.0.128:11434) to draft copy. + + Uses the standard Ollama /api/generate HTTP API. + """ + base = (settings.OLLAMA_BASE_URL or "").rstrip("/") + if not base: + raise OllamaError("OLLAMA_BASE_URL is not configured") + + system = ( + "You write short social media posts for a local business. " + f"Tone: {tone}. " + "Return only the post text, no preamble." + ) + if platform: + system += f" Optimize for {platform}." + + full_prompt = f"{system}\n\nUser request:\n{prompt}" + + url = f"{base}/api/generate" + payload = { + "model": settings.OLLAMA_MODEL, + "prompt": full_prompt, + "stream": False, + } + try: + response = requests.post( + url, + json=payload, + timeout=settings.OLLAMA_TIMEOUT_SECONDS, + ) + response.raise_for_status() + except requests.RequestException as exc: + logger.exception("Ollama request failed") + raise OllamaError(f"Ollama unreachable at {url}: {exc}") from exc + + data = response.json() + text = (data.get("response") or "").strip() + if not text: + raise OllamaError("Ollama returned empty response") + return text diff --git a/site/social_ai/urls.py b/site/social_ai/urls.py new file mode 100644 index 0000000..f30faa1 --- /dev/null +++ b/site/social_ai/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from social_ai import views + +app_name = "social_ai" + +urlpatterns = [ + path("", views.api_generate, name="generate"), +] diff --git a/site/social_ai/views.py b/site/social_ai/views.py new file mode 100644 index 0000000..1539191 --- /dev/null +++ b/site/social_ai/views.py @@ -0,0 +1,27 @@ +import json + +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_POST + +from social_ai.ollama import OllamaError, generate_social_post + + +@login_required +@require_POST +def api_generate(request): + """JSON endpoint to draft posts via Ollama. POST JSON: prompt, platform.""" + try: + payload = json.loads(request.body.decode() or "{}") + except json.JSONDecodeError: + payload = request.POST.dict() + prompt = (payload.get("prompt") or "").strip() + if not prompt: + return JsonResponse({"error": "prompt required"}, status=400) + try: + text = generate_social_post( + prompt, platform=(payload.get("platform") or "").strip() + ) + except OllamaError as exc: + return JsonResponse({"error": str(exc)}, status=502) + return JsonResponse({"text": text}) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ce17be5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,635 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "dj-queue" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/f5/930f49e03813469aec0c8035149bf4ca32004b418069ba02114bfa11347d/dj_queue-0.13.0.tar.gz", hash = "sha256:c2d9bb97ed0da564e6e85995aef0810cdb55cb9b86dc179aef882e20f15847ca", size = 124704, upload-time = "2026-06-09T15:39:49.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/86/a6e9f461a53c6230c91279402c9f56fb3e53e0215e227ab073476c77dd3a/dj_queue-0.13.0-py3-none-any.whl", hash = "sha256:df00b48109ee52990368ffa3ca89cc0dee404175ee974e313dc7afe59011428a", size = 150989, upload-time = "2026-06-09T15:39:48.322Z" }, +] + +[package.optional-dependencies] +postgres = [ + { name = "psycopg" }, +] + +[[package]] +name = "django" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/42/6cb20996733984c1f6661daeda3877990836c76c633c6c8879d39f7120eb/django-6.1.tar.gz", hash = "sha256:86a2aacd59b817e4d6ac2ebfe22356c58f66f7b24e503f71b7c2fead677ee48b", size = 11223034, upload-time = "2026-08-05T19:21:53.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/9c/ce847620134cfab903e75690c498af73b46abbede2912ea89bd76d5c1e76/django-6.1-py3-none-any.whl", hash = "sha256:6c132cd980c9392b06807d4ca52d72530d631dc65a85d9dacede00a780cefbbe", size = 8417399, upload-time = "2026-08-05T19:21:47.285Z" }, +] + +[[package]] +name = "django-phonenumber-field" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/82/479b070133cd493cad0f59bc04d6dd3e2e88b2c1f48cd6f54fd897225199/django_phonenumber_field-8.5.0.tar.gz", hash = "sha256:19c606fda7bb25630c4913f7ac83c1824186ea17e2e4c6dc9aa9373e8f5843c5", size = 46311, upload-time = "2026-07-23T12:24:16.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/14/4e33f4055afbf517ac3223371334f6f3226beecc92b781131a6eb6eeda1c/django_phonenumber_field-8.5.0-py3-none-any.whl", hash = "sha256:a6dd71250f7fd0724aad995df01bdb4ac457a65622f120d0d623792fe8283798", size = 69514, upload-time = "2026-07-23T12:24:14.955Z" }, +] + +[[package]] +name = "django-recaptcha" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/a2/480cd2df8031c30afe0d67b0a5e6299ced20f2e65135a3c160c4031d7052/django_recaptcha-4.1.0.tar.gz", hash = "sha256:73097b958733f65b729d4e4630c51fc2bf147aca6f026017d18898dfc25cb4c5", size = 28676, upload-time = "2025-03-28T13:52:26.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/e7/0765f98e4953e26573512284995648c439b0eb252877b655683d28c90be2/django_recaptcha-4.1.0-py3-none-any.whl", hash = "sha256:463aa65967e973de466b28ce8e8b6abb2faffb1ce0c7faa1bf0e5b041e0497cb", size = 39473, upload-time = "2025-03-28T13:52:24.92Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "phonenumbers" +version = "9.0.36" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/21/3f1561d3527b27121d6872aeecb6df2a8de8e11af9c36f5a0bd3483d2655/phonenumbers-9.0.36.tar.gz", hash = "sha256:60ca2a6870d2532d6e960105790e85e9d69270ede81746d10cf57f5e437b93ec", size = 2307271, upload-time = "2026-08-01T06:13:44.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/5d/74fbcb16b29927bf378f4ec4d47c7b48488b93e1f1af64b26544daf1d55f/phonenumbers-9.0.36-py2.py3-none-any.whl", hash = "sha256:c16a5b95178345ec2df1954578a4ac09e937443b2ee992dd7b15a8e8c81f4acf", size = 2595528, upload-time = "2026-08-01T06:13:41.591Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "stripe" +version = "15.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/57/56f7b33b31bba203b6e9cb502a24659206c1dbf64cf8aa00c3268410e831/stripe-15.5.1.tar.gz", hash = "sha256:ee87b696419f4d65a0637c3158393de8a2201de81b589632418517713e4ec8ed", size = 1544797, upload-time = "2026-08-18T23:52:28.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/48/8f018d19c6ecaee6881e8cc83484ba6f3b0f42fbf250808a5a1d171a0e77/stripe-15.5.1-py3-none-any.whl", hash = "sha256:5d76a311ae2ba636f3b42fa0766242cc7c9a844c5db73fe0b1702f81b5cdccd5", size = 2201139, upload-time = "2026-08-18T23:52:26.62Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, +] + +[[package]] +name = "web-django-template" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "cryptography" }, + { name = "dj-queue", extra = ["postgres"] }, + { name = "django" }, + { name = "django-phonenumber-field" }, + { name = "django-recaptcha" }, + { name = "gunicorn" }, + { name = "phonenumbers" }, + { name = "psycopg", extra = ["binary"] }, + { name = "requests" }, + { name = "stripe" }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "dj-queue", extras = ["postgres"], specifier = ">=0.13.0" }, + { name = "django", specifier = ">=6.0,<7" }, + { name = "django-phonenumber-field", specifier = ">=8.1.0" }, + { name = "django-recaptcha", specifier = ">=4.1.0" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "phonenumbers", specifier = ">=9.0.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, + { name = "requests", specifier = ">=2.32.0" }, + { name = "stripe", specifier = ">=11.0.0" }, + { name = "whitenoise", specifier = ">=6.9.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pre-commit", specifier = ">=4.1.0" }] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +]