added pricing, utm tracking, and leads
This commit is contained in:
@@ -134,6 +134,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"public.middleware.UTMTrackingMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "company_site.urls"
|
ROOT_URLCONF = "company_site.urls"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Test at minimum:
|
|||||||
|
|
||||||
When `WEBMCP_ENABLED=True`:
|
When `WEBMCP_ENABLED=True`:
|
||||||
|
|
||||||
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
|
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`, `estimate_web_design_cost`) load on all public pages
|
||||||
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
|
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
|
||||||
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,30 @@ Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` o
|
|||||||
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
||||||
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
||||||
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
||||||
|
| `estimate_web_design_cost` | All public pages | Yes | Estimate web design build + monthly package pricing |
|
||||||
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
||||||
|
|
||||||
|
### `estimate_web_design_cost`
|
||||||
|
|
||||||
|
**Input schema:**
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `features` | string[] | No — feature ids to add beyond the required base |
|
||||||
|
|
||||||
|
**Valid feature ids:** `public_site`, `client_portal`, `email_sms`, `direct_mail`, `blog`, `payments`, `social`, `ai_social`
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- `public_site` and `client_portal` are always included
|
||||||
|
- Selecting `payments` auto-selects `email_sms`
|
||||||
|
- Selecting `ai_social` auto-selects `social`
|
||||||
|
- Returns one-time build total, monthly total, selected features, included-with-every-site notes, and the full catalog
|
||||||
|
- Included-with-every-site covers brand-tailored design, client ownership of site/data, three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, and Grafana metrics/alerts
|
||||||
|
|
||||||
|
**Example:** `{ "features": ["email_sms", "payments"] }` → base + Email/SMS + Payments
|
||||||
|
|
||||||
|
Pricing catalog and included benefits are sourced from `public/web_design_pricing.py` (same data as `/web_design` and `/llms.txt`).
|
||||||
|
|
||||||
### `submit_contact_inquiry`
|
### `submit_contact_inquiry`
|
||||||
|
|
||||||
**Input schema:**
|
**Input schema:**
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contact, EmailMessage
|
|
||||||
from .views import preview_email
|
|
||||||
from django.shortcuts import render, get_object_or_404
|
|
||||||
from django.urls import path
|
|
||||||
from django.template.loader import get_template
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.template.loader import get_template
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
# Register your models here.
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Contact, site=admin.site)
|
@admin.register(Contact, site=admin.site)
|
||||||
class ContactAdmin(admin.ModelAdmin):
|
class ContactAdmin(admin.ModelAdmin):
|
||||||
list_display = ("email", "name", "contacted")
|
list_display = (
|
||||||
list_filter = ("email", "name", "contacted")
|
"email",
|
||||||
search_fields = ("email", "name")
|
"name",
|
||||||
|
"contacted",
|
||||||
|
"utm_source",
|
||||||
|
"utm_campaign",
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
list_filter = ("contacted", "utm_source", "utm_medium", "utm_campaign")
|
||||||
|
search_fields = ("email", "name", "utm_source", "utm_campaign")
|
||||||
|
readonly_fields = (
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
"created",
|
||||||
|
"last_modified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description="Send seelcted emails")
|
@admin.action(description="Send seelcted emails")
|
||||||
@@ -25,8 +39,8 @@ def send_emails(modeladmin, request, queryset):
|
|||||||
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
||||||
d = {"title": email.subject, "content": email.body}
|
d = {"title": email.subject, "content": email.body}
|
||||||
|
|
||||||
html_content = get_template(f"emails/marketing_email.html").render(d)
|
html_content = get_template("emails/marketing_email.html").render(d)
|
||||||
text_content = get_template(f"emails/marketing_email.txt").render(d)
|
text_content = get_template("emails/marketing_email.txt").render(d)
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(
|
msg = EmailMultiAlternatives(
|
||||||
email.subject, text_content, from_email, [email.recipient]
|
email.subject, text_content, from_email, [email.recipient]
|
||||||
@@ -65,3 +79,40 @@ class EmailMessageAdmin(admin.ModelAdmin):
|
|||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = {"title": email_instance.subject, "content": email_instance.body}
|
context = {"title": email_instance.subject, "content": email_instance.body}
|
||||||
return TemplateResponse(request, "public/preview_email.html", context)
|
return TemplateResponse(request, "public/preview_email.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PageVisit)
|
||||||
|
class PageVisitAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"created",
|
||||||
|
"path",
|
||||||
|
"traffic_type",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"is_landing",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"traffic_type",
|
||||||
|
"is_landing",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
)
|
||||||
|
search_fields = ("path", "utm_source", "utm_campaign", "user_agent", "referrer")
|
||||||
|
readonly_fields = (
|
||||||
|
"created",
|
||||||
|
"path",
|
||||||
|
"query_string",
|
||||||
|
"referrer",
|
||||||
|
"user_agent",
|
||||||
|
"traffic_type",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
"session_key",
|
||||||
|
"is_landing",
|
||||||
|
)
|
||||||
|
date_hierarchy = "created"
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ from django.conf import settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
||||||
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
features_for_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def tianji_tracking(request):
|
def tianji_tracking(request):
|
||||||
@@ -41,6 +46,15 @@ def webmcp_context(request):
|
|||||||
}
|
}
|
||||||
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
}
|
}
|
||||||
|
web_design_pricing = {
|
||||||
|
"features": features_for_json(),
|
||||||
|
"included_with_every_site": [
|
||||||
|
{"title": item["title"], "description": item["description"]}
|
||||||
|
for item in WEB_DESIGN_INCLUDED
|
||||||
|
],
|
||||||
|
"disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"page_url": request.build_absolute_uri(reverse("web_design")),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
|
||||||
@@ -49,6 +63,7 @@ def webmcp_context(request):
|
|||||||
'webmcp_recaptcha_required': not settings.DEBUG,
|
'webmcp_recaptcha_required': not settings.DEBUG,
|
||||||
'webmcp_services_json': json.dumps(services),
|
'webmcp_services_json': json.dumps(services),
|
||||||
'webmcp_pages_json': json.dumps(page_lookup),
|
'webmcp_pages_json': json.dumps(page_lookup),
|
||||||
|
'webmcp_web_design_pricing_json': json.dumps(web_design_pricing),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Capture page visits, UTM params, and traffic classification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.db import IntegrityError, OperationalError
|
||||||
|
from django.utils.encoding import force_str
|
||||||
|
|
||||||
|
from .models import PageVisit
|
||||||
|
from .traffic import classify_user_agent
|
||||||
|
|
||||||
|
UTM_SESSION_KEY = "utm_attribution"
|
||||||
|
UTM_PARAMS = (
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content",
|
||||||
|
)
|
||||||
|
|
||||||
|
SKIP_PREFIXES = (
|
||||||
|
"/static/",
|
||||||
|
"/media/",
|
||||||
|
"/admin/",
|
||||||
|
"/favicon",
|
||||||
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
|
"/llms.txt",
|
||||||
|
"/__debug__",
|
||||||
|
)
|
||||||
|
|
||||||
|
SKIP_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"utm_dashboard",
|
||||||
|
"leads_list",
|
||||||
|
"lead_detail",
|
||||||
|
"lead_toggle_contacted",
|
||||||
|
"robots_txt",
|
||||||
|
"sitemap_xml",
|
||||||
|
"llms_txt",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: str, max_len: int) -> str:
|
||||||
|
value = force_str(value or "")
|
||||||
|
if len(value) <= max_len:
|
||||||
|
return value
|
||||||
|
return value[: max_len - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_utm_from_get(get) -> dict[str, str]:
|
||||||
|
found = {}
|
||||||
|
for key in UTM_PARAMS:
|
||||||
|
raw = get.get(key)
|
||||||
|
if raw:
|
||||||
|
found[key] = _truncate(raw.strip(), 255)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_utm(session) -> dict[str, str]:
|
||||||
|
stored = session.get(UTM_SESSION_KEY) or {}
|
||||||
|
return {k: stored[k] for k in UTM_PARAMS if stored.get(k)}
|
||||||
|
|
||||||
|
|
||||||
|
def store_session_utm(session, utm: dict[str, str]) -> None:
|
||||||
|
if not utm:
|
||||||
|
return
|
||||||
|
existing = dict(session.get(UTM_SESSION_KEY) or {})
|
||||||
|
existing.update(utm)
|
||||||
|
session[UTM_SESSION_KEY] = existing
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
|
||||||
|
def should_track_request(request) -> bool:
|
||||||
|
if request.method != "GET":
|
||||||
|
return False
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return False
|
||||||
|
path = request.path or "/"
|
||||||
|
if any(path.startswith(prefix) for prefix in SKIP_PREFIXES):
|
||||||
|
return False
|
||||||
|
match = getattr(request, "resolver_match", None)
|
||||||
|
if match and match.url_name in SKIP_NAMES:
|
||||||
|
return False
|
||||||
|
accept = request.headers.get("Accept", "")
|
||||||
|
if accept and "text/html" not in accept and "*/*" not in accept:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def record_page_visit(request) -> PageVisit | None:
|
||||||
|
if not should_track_request(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
landing_utm = extract_utm_from_get(request.GET)
|
||||||
|
if landing_utm:
|
||||||
|
store_session_utm(request.session, landing_utm)
|
||||||
|
|
||||||
|
attribution = landing_utm or get_session_utm(request.session)
|
||||||
|
user_agent = _truncate(request.META.get("HTTP_USER_AGENT", ""), 512)
|
||||||
|
referrer = _truncate(request.META.get("HTTP_REFERER", ""), 1024)
|
||||||
|
session_key = ""
|
||||||
|
if hasattr(request, "session"):
|
||||||
|
# Ensure session exists so return visits can keep first-touch UTM.
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.save()
|
||||||
|
session_key = request.session.session_key or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return PageVisit.objects.create(
|
||||||
|
path=_truncate(request.path or "/", 512),
|
||||||
|
query_string=_truncate(request.META.get("QUERY_STRING", ""), 1024),
|
||||||
|
referrer=referrer,
|
||||||
|
user_agent=user_agent,
|
||||||
|
traffic_type=classify_user_agent(user_agent),
|
||||||
|
utm_source=attribution.get("utm_source", ""),
|
||||||
|
utm_medium=attribution.get("utm_medium", ""),
|
||||||
|
utm_campaign=attribution.get("utm_campaign", ""),
|
||||||
|
utm_term=attribution.get("utm_term", ""),
|
||||||
|
utm_content=attribution.get("utm_content", ""),
|
||||||
|
session_key=session_key,
|
||||||
|
is_landing=bool(landing_utm),
|
||||||
|
)
|
||||||
|
except (OperationalError, IntegrityError):
|
||||||
|
# Avoid breaking page loads if DB is unavailable or migration pending.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UTMTrackingMiddleware:
|
||||||
|
"""Record HTML GET page views after the view runs successfully."""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
if 200 <= response.status_code < 300:
|
||||||
|
content_type = response.get("Content-Type", "")
|
||||||
|
if not content_type or "text/html" in content_type:
|
||||||
|
record_page_visit(request)
|
||||||
|
return response
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-07-25 01:15
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('public', '0005_emailmessage'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_campaign',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_content',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_medium',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_source',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contact',
|
||||||
|
name='utm_term',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PageVisit',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
|
||||||
|
('path', models.CharField(db_index=True, max_length=512)),
|
||||||
|
('query_string', models.CharField(blank=True, default='', max_length=1024)),
|
||||||
|
('referrer', models.URLField(blank=True, default='', max_length=1024)),
|
||||||
|
('user_agent', models.CharField(blank=True, default='', max_length=512)),
|
||||||
|
('traffic_type', models.CharField(choices=[('human', 'Human traffic'), ('ai_bot', 'AI bot / AI search'), ('search_indexer', 'Search indexing'), ('social_bot', 'Social / preview bot'), ('monitoring', 'Monitoring / uptime'), ('other_bot', 'Other bot'), ('unknown', 'Unknown')], db_index=True, default='unknown', max_length=32)),
|
||||||
|
('utm_source', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_medium', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_campaign', models.CharField(blank=True, db_index=True, default='', max_length=255)),
|
||||||
|
('utm_term', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('utm_content', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('session_key', models.CharField(blank=True, db_index=True, default='', max_length=64)),
|
||||||
|
('is_landing', models.BooleanField(default=False, help_text='True when this request carried UTM params (campaign landing).')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created'],
|
||||||
|
'indexes': [models.Index(fields=['-created', 'traffic_type'], name='public_page_created_8c59a5_idx'), models.Index(fields=['utm_source', 'utm_campaign'], name='public_page_utm_sou_96ed78_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0 on 2026-07-25 01:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('public', '0006_pagevisit_and_contact_utm'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='pagevisit',
|
||||||
|
name='referrer',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=1024),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .traffic import TrafficType, traffic_type_label
|
||||||
|
|
||||||
|
|
||||||
class TimeInfoBase(models.Model):
|
class TimeInfoBase(models.Model):
|
||||||
|
|
||||||
created = models.DateTimeField(default=timezone.now)
|
created = models.DateTimeField(default=timezone.now)
|
||||||
@@ -17,13 +20,19 @@ class TimeInfoBase(models.Model):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
class Contact(TimeInfoBase):
|
class Contact(TimeInfoBase):
|
||||||
email = models.EmailField(max_length=128)
|
email = models.EmailField(max_length=128)
|
||||||
name = models.CharField(max_length=128)
|
name = models.CharField(max_length=128)
|
||||||
blurb = models.CharField(max_length=254, blank=True)
|
blurb = models.CharField(max_length=254, blank=True)
|
||||||
subject = models.CharField(max_length=128)
|
subject = models.CharField(max_length=128)
|
||||||
contacted = models.BooleanField(default=False)
|
contacted = models.BooleanField(default=False)
|
||||||
|
utm_source = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_medium = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_campaign = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_term = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_content = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
|
||||||
class EmailMessage(TimeInfoBase):
|
class EmailMessage(TimeInfoBase):
|
||||||
subject = models.CharField(max_length=255)
|
subject = models.CharField(max_length=255)
|
||||||
@@ -33,3 +42,52 @@ class EmailMessage(TimeInfoBase):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.recipient + " | " + self.subject
|
return self.recipient + " | " + self.subject
|
||||||
|
|
||||||
|
|
||||||
|
class PageVisit(models.Model):
|
||||||
|
"""First-party page view with UTM attribution and traffic classification."""
|
||||||
|
|
||||||
|
class TrafficTypeChoices(models.TextChoices):
|
||||||
|
HUMAN = TrafficType.HUMAN, traffic_type_label(TrafficType.HUMAN)
|
||||||
|
AI_BOT = TrafficType.AI_BOT, traffic_type_label(TrafficType.AI_BOT)
|
||||||
|
SEARCH_INDEXER = TrafficType.SEARCH_INDEXER, traffic_type_label(
|
||||||
|
TrafficType.SEARCH_INDEXER
|
||||||
|
)
|
||||||
|
SOCIAL_BOT = TrafficType.SOCIAL_BOT, traffic_type_label(TrafficType.SOCIAL_BOT)
|
||||||
|
MONITORING = TrafficType.MONITORING, traffic_type_label(TrafficType.MONITORING)
|
||||||
|
OTHER_BOT = TrafficType.OTHER_BOT, traffic_type_label(TrafficType.OTHER_BOT)
|
||||||
|
UNKNOWN = TrafficType.UNKNOWN, traffic_type_label(TrafficType.UNKNOWN)
|
||||||
|
|
||||||
|
created = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
path = models.CharField(max_length=512, db_index=True)
|
||||||
|
query_string = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
referrer = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
user_agent = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
traffic_type = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=TrafficTypeChoices.choices,
|
||||||
|
default=TrafficTypeChoices.UNKNOWN,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
utm_source = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
|
utm_medium = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
|
utm_campaign = models.CharField(
|
||||||
|
max_length=255, blank=True, default="", db_index=True
|
||||||
|
)
|
||||||
|
utm_term = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
utm_content = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
session_key = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
||||||
|
is_landing = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True when this request carried UTM params (campaign landing).",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["-created", "traffic_type"]),
|
||||||
|
models.Index(fields=["utm_source", "utm_campaign"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.path} ({self.traffic_type}) @ {self.created:%Y-%m-%d %H:%M}"
|
||||||
@@ -75,7 +75,10 @@ PUBLIC_PAGE_ENTRIES = (
|
|||||||
"Web Design and Hosting",
|
"Web Design and Hosting",
|
||||||
"monthly",
|
"monthly",
|
||||||
"0.8",
|
"0.8",
|
||||||
"Web design, development, and managed hosting for business sites and apps.",
|
"Custom brand-tailored sites with interactive package pricing. You own the site and data. "
|
||||||
|
"Public site + client portal (UTM/leads) always included; add-ons for Email/SMS, direct mail, "
|
||||||
|
"blog, Stripe payments, social, and AI social. Every build includes three-instance hosting, "
|
||||||
|
"SEO/accessibility/LLM readiness, and Grafana metrics and alerts.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"contact",
|
"contact",
|
||||||
@@ -127,7 +130,10 @@ def robots_txt(request):
|
|||||||
sitemap_url = _absolute_url(request, "sitemap_xml")
|
sitemap_url = _absolute_url(request, "sitemap_xml")
|
||||||
content = render_to_string(
|
content = render_to_string(
|
||||||
"public/robots.txt",
|
"public/robots.txt",
|
||||||
{"sitemap_url": sitemap_url},
|
{
|
||||||
|
"sitemap_url": sitemap_url,
|
||||||
|
"llms_url": _absolute_url(request, "llms_txt"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
@@ -146,18 +152,27 @@ def sitemap_xml(request):
|
|||||||
|
|
||||||
|
|
||||||
def llms_txt(request):
|
def llms_txt(request):
|
||||||
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
features_for_json,
|
||||||
|
)
|
||||||
|
|
||||||
pages = [
|
pages = [
|
||||||
{
|
{
|
||||||
"title": title,
|
"title": title,
|
||||||
"url": _absolute_url(request, url_name),
|
"url": _absolute_url(request, url_name),
|
||||||
|
"summary": summary,
|
||||||
}
|
}
|
||||||
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
|
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
|
||||||
]
|
]
|
||||||
content = render_to_string(
|
content = render_to_string(
|
||||||
"public/llms.txt",
|
"public/llms.txt",
|
||||||
{
|
{
|
||||||
"site_url": request.build_absolute_uri("/"),
|
"site_url": request.build_absolute_uri("/"),
|
||||||
"contact_url": _absolute_url(request, "contact"),
|
"contact_url": _absolute_url(request, "contact"),
|
||||||
|
"web_design_url": _absolute_url(request, "web_design"),
|
||||||
|
"web_design_features": features_for_json(),
|
||||||
|
"web_design_included": WEB_DESIGN_INCLUDED,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -878,3 +878,214 @@ input:focus, select:focus, textarea:focus {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Web design interactive pricing estimator */
|
||||||
|
.pricing-intro {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 2.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimator {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(260px, 0.9fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-features-panel,
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-panel-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.85rem 1rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature.is-required .pricing-feature-label {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check {
|
||||||
|
appearance: none;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform 0.12s ease-in-out;
|
||||||
|
box-shadow: inset 1em 1em var(--bg-color);
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:checked {
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:disabled:checked::before {
|
||||||
|
box-shadow: inset 1em 1em rgba(10, 10, 10, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-check:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature.is-selected:not(.is-required) {
|
||||||
|
background: rgba(0, 243, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-name {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-desc {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: #ffb347;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-monthly {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-row strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-count {
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-disclaimer {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-cta {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pricing-estimator {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-estimate-panel {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-label {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-costs {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-feature-monthly {
|
||||||
|
display: inline;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
|
||||||
var services = [];
|
var services = [];
|
||||||
var pages = {};
|
var pages = {};
|
||||||
|
var webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
services = JSON.parse(configEl.dataset.services || '[]');
|
services = JSON.parse(configEl.dataset.services || '[]');
|
||||||
@@ -29,6 +30,15 @@
|
|||||||
pages = {};
|
pages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
webDesignPricing = JSON.parse(configEl.dataset.webDesignPricing || '{}');
|
||||||
|
if (!webDesignPricing.features) {
|
||||||
|
webDesignPricing.features = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function textResult(payload) {
|
function textResult(payload) {
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
@@ -82,6 +92,85 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function estimateWebDesignCost(selectedIds) {
|
||||||
|
var catalog = {};
|
||||||
|
var features = webDesignPricing.features || [];
|
||||||
|
features.forEach(function (feature) {
|
||||||
|
catalog[feature.id] = feature;
|
||||||
|
});
|
||||||
|
|
||||||
|
var selected = {};
|
||||||
|
(selectedIds || []).forEach(function (id) {
|
||||||
|
if (catalog[id]) {
|
||||||
|
selected[id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
features.forEach(function (feature) {
|
||||||
|
if (feature.required) {
|
||||||
|
selected[feature.id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
Object.keys(selected).forEach(function (featureId) {
|
||||||
|
var feature = catalog[featureId];
|
||||||
|
if (!feature || !feature.requires) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feature.requires.forEach(function (dep) {
|
||||||
|
if (!selected[dep] && catalog[dep]) {
|
||||||
|
selected[dep] = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolved = features.filter(function (feature) {
|
||||||
|
return selected[feature.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
var unknown = (selectedIds || []).filter(function (id) {
|
||||||
|
return !catalog[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected: resolved.map(function (feature) {
|
||||||
|
return {
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
build: feature.build,
|
||||||
|
monthly: feature.monthly,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
selected_count: resolved.length,
|
||||||
|
one_time_build: resolved.reduce(function (sum, feature) {
|
||||||
|
return sum + feature.build;
|
||||||
|
}, 0),
|
||||||
|
monthly: resolved.reduce(function (sum, feature) {
|
||||||
|
return sum + feature.monthly;
|
||||||
|
}, 0),
|
||||||
|
included_with_every_site: webDesignPricing.included_with_every_site || [],
|
||||||
|
disclaimer: webDesignPricing.disclaimer || '',
|
||||||
|
page_url: webDesignPricing.page_url || resolvePageUrl('web_design'),
|
||||||
|
available_features: features.map(function (feature) {
|
||||||
|
return {
|
||||||
|
id: feature.id,
|
||||||
|
name: feature.name,
|
||||||
|
description: feature.description,
|
||||||
|
build: feature.build,
|
||||||
|
monthly: feature.monthly,
|
||||||
|
required: !!feature.required,
|
||||||
|
requires: feature.requires || [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
unknown_feature_ids: unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function getRecaptchaToken() {
|
async function getRecaptchaToken() {
|
||||||
if (!recaptchaRequired) {
|
if (!recaptchaRequired) {
|
||||||
return null;
|
return null;
|
||||||
@@ -265,6 +354,35 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelContext.registerTool({
|
||||||
|
name: 'estimate_web_design_cost',
|
||||||
|
description:
|
||||||
|
'Estimate one-time build and monthly cost for an AI ML Operations web design package. ' +
|
||||||
|
'Sites are brand-tailored; clients own the site and data. Public site and client portal are ' +
|
||||||
|
'always included, along with three-instance hosting, UTM/leads, SEO/accessibility/LLM readiness, ' +
|
||||||
|
'and Grafana metrics/alerts. Pass optional feature ids to add Email/SMS, direct mail, blog, ' +
|
||||||
|
'Stripe payments, social consolidation, or AI social generator. Dependencies ' +
|
||||||
|
'(payments→email_sms, ai_social→social) are auto-selected. Call with an empty features array ' +
|
||||||
|
'to list catalog pricing and included-with-every-site benefits.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
features: {
|
||||||
|
type: 'array',
|
||||||
|
description:
|
||||||
|
'Optional feature ids to include beyond the required base. Valid ids: ' +
|
||||||
|
'public_site, client_portal, email_sms, direct_mail, blog, payments, social, ai_social.',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: { readOnlyHint: true },
|
||||||
|
execute: function (input) {
|
||||||
|
var selected = (input && input.features) || [];
|
||||||
|
return textResult(estimateWebDesignCost(selected));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (pageName === 'contact') {
|
if (pageName === 'contact') {
|
||||||
modelContext.registerTool({
|
modelContext.registerTool({
|
||||||
name: 'submit_contact_inquiry',
|
name: 'submit_contact_inquiry',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
|
||||||
data-services='{{ webmcp_services_json|escapejs }}'
|
data-services='{{ webmcp_services_json|escapejs }}'
|
||||||
data-pages='{{ webmcp_pages_json|escapejs }}'
|
data-pages='{{ webmcp_pages_json|escapejs }}'
|
||||||
|
data-web-design-pricing='{{ webmcp_web_design_pricing_json|escapejs }}'
|
||||||
hidden></div>
|
hidden></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
@@ -109,7 +110,12 @@
|
|||||||
<li><a href="{% url 'profile' %}">Profile</a></li>
|
<li><a href="{% url 'profile' %}">Profile</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_financial_admin %}
|
{% if is_financial_admin %}
|
||||||
|
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
|
||||||
|
<li><a href="{% url 'leads_list' %}">Leads</a></li>
|
||||||
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
|
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
|
||||||
|
{% elif user.is_staff %}
|
||||||
|
<li><a href="{% url 'utm_dashboard' %}">UTM Analytics</a></li>
|
||||||
|
<li><a href="{% url 'leads_list' %}">Leads</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Lead: {{ lead.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container" style="max-width: 48rem;">
|
||||||
|
<p style="margin-bottom: 1rem;">
|
||||||
|
<a href="{% url 'leads_list' %}" style="color: var(--text-muted);">← All leads</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: start; margin-bottom: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h1 class="section-title" style="margin-bottom: 0.35rem;">{{ lead.name }}</h1>
|
||||||
|
<p style="color: var(--text-muted); margin: 0;">
|
||||||
|
{{ lead.created|date:"F j, Y g:i A" }}
|
||||||
|
·
|
||||||
|
{% if lead.contacted %}
|
||||||
|
<span>Contacted</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--primary-color);">New</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action="{% url 'lead_toggle_contacted' lead.pk %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{% url 'lead_detail' lead.pk %}">
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
{% if lead.contacted %}Mark as new{% else %}Mark as contacted{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<ul style="list-style: none; margin-bottom: 1.5rem; padding: 0;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li style="color: var(--primary-color); margin-bottom: 0.35rem;">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">Contact</span>
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem;">
|
||||||
|
<strong>Email:</strong> <a href="mailto:{{ lead.email }}">{{ lead.email }}</a><br>
|
||||||
|
<strong>Subject:</strong> {{ lead.subject }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">Message</span>
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem; white-space: pre-wrap;">{% if lead.blurb %}{{ lead.blurb }}{% else %}<span style="color: var(--text-muted);">(No message body)</span>{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem; cursor: default;">
|
||||||
|
<span class="card-title" style="font-size: 1.1rem;">UTM attribution</span>
|
||||||
|
{% if lead.utm_source or lead.utm_medium or lead.utm_campaign or lead.utm_term or lead.utm_content %}
|
||||||
|
<div class="table-responsive" style="margin-top: 0.75rem;">
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th style="width: 8rem;">Source</th><td>{{ lead.utm_source|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Medium</th><td>{{ lead.utm_medium|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Campaign</th><td>{{ lead.utm_campaign|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Term</th><td>{{ lead.utm_term|default:"—" }}</td></tr>
|
||||||
|
<tr><th>Content</th><td>{{ lead.utm_content|default:"—" }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="card-text" style="margin-top: 0.75rem; color: var(--text-muted);">No UTM params on this lead’s session.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Last modified {{ lead.last_modified|date:"Y-m-d H:i" }} · ID {{ lead.pk }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Contact Leads{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: baseline; margin-bottom: 0.5rem;">
|
||||||
|
<h1 class="section-title" style="margin-bottom: 0;">Contact Leads</h1>
|
||||||
|
<a href="{% url 'utm_dashboard' %}" style="color: var(--text-muted); font-size: 0.9rem;">UTM Analytics →</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem; max-width: 42rem;">
|
||||||
|
Inquiries from the contact form, with message details and any UTM attribution. Staff only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="get" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; margin-bottom: 2rem;">
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Period
|
||||||
|
<select name="days" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="7" {% if days == 7 %}selected{% endif %}>Last 7 days</option>
|
||||||
|
<option value="30" {% if days == 30 %}selected{% endif %}>Last 30 days</option>
|
||||||
|
<option value="90" {% if days == 90 %}selected{% endif %}>Last 90 days</option>
|
||||||
|
<option value="365" {% if days == 365 %}selected{% endif %}>Last year</option>
|
||||||
|
<option value="0" {% if days == 0 %}selected{% endif %}>All time</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Status
|
||||||
|
<select name="status" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="" {% if not status %}selected{% endif %}>All</option>
|
||||||
|
<option value="new" {% if status == "new" %}selected{% endif %}>New</option>
|
||||||
|
<option value="contacted" {% if status == "contacted" %}selected{% endif %}>Contacted</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem; flex: 1; min-width: 12rem;">
|
||||||
|
Search
|
||||||
|
<input type="search" name="q" value="{{ q }}" placeholder="Name, email, subject…"
|
||||||
|
style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.9rem; padding-bottom: 0.55rem;">
|
||||||
|
<input type="checkbox" name="utm" value="1" {% if utm_only %}checked{% endif %}>
|
||||||
|
UTM only
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-grid" style="margin-bottom: 2.5rem;">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ total }}</span>
|
||||||
|
<p class="card-text">Total in period</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ new_count }}</span>
|
||||||
|
<p class="card-text">New (not contacted)</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ contacted_count }}</span>
|
||||||
|
<p class="card-text">Contacted</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ with_utm }}</span>
|
||||||
|
<p class="card-text">With UTM attribution</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9rem;">
|
||||||
|
Showing {{ lead_count }} lead{% if lead_count != 1 %}s{% endif %}{% if lead_count > 200 %} (first 200){% endif %}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if leads %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for lead in leads %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ lead.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>{{ lead.name }}</td>
|
||||||
|
<td><a href="mailto:{{ lead.email }}">{{ lead.email }}</a></td>
|
||||||
|
<td>{{ lead.subject|truncatechars:40 }}</td>
|
||||||
|
<td>{% if lead.contacted %}Contacted{% else %}<span style="color: var(--primary-color);">New</span>{% endif %}</td>
|
||||||
|
<td>{{ lead.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ lead.utm_campaign|default:"—" }}</td>
|
||||||
|
<td><a href="{% url 'lead_detail' lead.pk %}">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No leads match these filters.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,12 +2,28 @@
|
|||||||
|
|
||||||
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
|
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
|
||||||
|
|
||||||
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web hosting.
|
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web design/hosting.
|
||||||
|
|
||||||
## Key pages
|
## Key pages
|
||||||
|
|
||||||
{% for page in pages %}- [{{ page.title }}]({{ page.url }})
|
{% for page in pages %}- [{{ page.title }}]({{ page.url }}){% if page.summary %} — {{ page.summary }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
## Web design & hosting
|
||||||
|
|
||||||
|
Interactive package estimator: {{ web_design_url }}
|
||||||
|
|
||||||
|
Custom brand-tailored sites. Clients own the site and data and can leave anytime with everything. Use the estimator to toggle catalog add-ons and see one-time build + monthly totals (draft pricing; not a formal quote; Stripe/SMS/postage usage billed separately).
|
||||||
|
|
||||||
|
### Included with every site
|
||||||
|
|
||||||
|
{% for item in web_design_included %}- **{{ item.title }}**: {{ item.description }}
|
||||||
|
{% endfor %}
|
||||||
|
### Catalog features
|
||||||
|
|
||||||
|
{% for feature in web_design_features %}- **{{ feature.name }}** (`{{ feature.id }}`): ${{ feature.build }} build, ${{ feature.monthly }}/mo{% if feature.required %} — always included{% endif %}{% if feature.requires %} — requires {{ feature.requires|join:", " }}{% endif %} — {{ feature.description }}
|
||||||
|
{% endfor %}
|
||||||
|
Agents can call the WebMCP tool `estimate_web_design_cost` with optional feature ids to compute build + monthly totals and return the included-with-every-site list.
|
||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
- [Contact form]({{ contact_url }})
|
- [Contact form]({{ contact_url }})
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
data-tianji-event="service_click" data-tianji-event-service="Web Design">
|
data-tianji-event="service_click" data-tianji-event-service="Web Design">
|
||||||
<span class="card-badge">Primary</span>
|
<span class="card-badge">Primary</span>
|
||||||
<span class="card-title">Web Design and Hosting</span>
|
<span class="card-title">Web Design and Hosting</span>
|
||||||
<p class="card-text">Modern websites with reliable hosting for your online presence.</p>
|
<p class="card-text">Brand-tailored sites you own, with multi-instance hosting, UTM/leads, Grafana alerts, and an interactive package estimator.</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'ml_model' %}" class="card"
|
<a href="{% url 'ml_model' %}" class="card"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ User-agent: PerplexityBot
|
|||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: {{ sitemap_url }}
|
Sitemap: {{ sitemap_url }}
|
||||||
|
# llms.txt: {{ llms_url }}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}UTM & Traffic Analytics{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="section-title">UTM & Traffic Analytics</h1>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1.5rem; max-width: 42rem;">
|
||||||
|
First-party page views with campaign tags and traffic classification
|
||||||
|
(human, AI bots, search indexers, and more). Staff only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="get" class="utm-filters" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: end; margin-bottom: 2rem;">
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Period
|
||||||
|
<select name="days" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="7" {% if days == 7 %}selected{% endif %}>Last 7 days</option>
|
||||||
|
<option value="30" {% if days == 30 %}selected{% endif %}>Last 30 days</option>
|
||||||
|
<option value="90" {% if days == 90 %}selected{% endif %}>Last 90 days</option>
|
||||||
|
<option value="365" {% if days == 365 %}selected{% endif %}>Last year</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; flex-direction: column; gap: 0.35rem; color: var(--text-muted); font-size: 0.85rem;">
|
||||||
|
Traffic type
|
||||||
|
<select name="traffic" style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); padding: 0.5rem 0.75rem; border-radius: 4px;">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for value, label in traffic_choices %}
|
||||||
|
<option value="{{ value }}" {% if traffic_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" style="border: 1px solid var(--primary-color); padding: 0.5rem 1.25rem; border-radius: 4px; color: var(--primary-color); background: transparent; cursor: pointer;">
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ total_visits }}</span>
|
||||||
|
<p class="card-text">Total page views</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ human_count }}</span>
|
||||||
|
<p class="card-text">Human traffic</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ ai_count }}</span>
|
||||||
|
<p class="card-text">AI bot / AI search</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ indexer_count }}</span>
|
||||||
|
<p class="card-text">Search indexing</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ utm_landings }}</span>
|
||||||
|
<p class="card-text">UTM landings</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-title">{{ attributed_visits }}</span>
|
||||||
|
<p class="card-text">Views with UTM attribution</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Traffic mix</h2>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
{% if type_counts %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in type_counts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td>{{ row.total }}</td>
|
||||||
|
<td>{% widthratio row.total total_visits 100 %}%</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No visits in this period yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 2rem; margin-bottom: 3rem;">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top sources</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_sources %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_source</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_sources %}
|
||||||
|
<tr><td>{{ row.utm_source }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM sources yet. Share links like <code>?utm_source=linkedin&utm_medium=social&utm_campaign=spring</code>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top mediums</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_mediums %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_medium</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_mediums %}
|
||||||
|
<tr><td>{{ row.utm_medium }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM mediums yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title" style="font-size: 1.35rem; margin-bottom: 1rem;">Top campaigns</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if top_campaigns %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>utm_campaign</th><th>Views</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_campaigns %}
|
||||||
|
<tr><td>{{ row.utm_campaign }}</td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No UTM campaigns yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Top pages{% if traffic_filter %} (filtered){% endif %}</h2>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
{% if top_pages %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Path</th><th>Views</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in top_pages %}
|
||||||
|
<tr><td><code>{{ row.path }}</code></td><td>{{ row.total }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No page data for this filter.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if contacts_with_utm %}
|
||||||
|
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; align-items: baseline; margin-bottom: 1rem;">
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 0;">Contact leads with UTM</h2>
|
||||||
|
<a href="{% url 'leads_list' %}?utm=1" style="color: var(--text-muted); font-size: 0.9rem;">All leads →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive" style="margin-bottom: 3rem;">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Medium</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in contacts_with_utm %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td>{{ c.email }}</td>
|
||||||
|
<td>{{ c.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ c.utm_medium|default:"—" }}</td>
|
||||||
|
<td>{{ c.utm_campaign|default:"—" }}</td>
|
||||||
|
<td><a href="{% url 'lead_detail' c.pk %}">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 class="section-title" style="font-size: 1.75rem; margin-bottom: 1rem;">Recent visits</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% if recent_visits %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Medium</th>
|
||||||
|
<th>Campaign</th>
|
||||||
|
<th>Landing</th>
|
||||||
|
<th>Referrer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for v in recent_visits %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ v.created|date:"Y-m-d H:i" }}</td>
|
||||||
|
<td><code>{{ v.path }}</code></td>
|
||||||
|
<td>{{ v.get_traffic_type_display }}</td>
|
||||||
|
<td>{{ v.utm_source|default:"—" }}</td>
|
||||||
|
<td>{{ v.utm_medium|default:"—" }}</td>
|
||||||
|
<td>{{ v.utm_campaign|default:"—" }}</td>
|
||||||
|
<td>{% if v.is_landing %}Yes{% else %}—{% endif %}</td>
|
||||||
|
<td style="max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{ v.referrer }}">{{ v.referrer|default:"—"|truncatechars:40 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted);">No visits recorded yet. Browse the public site (optionally with UTM query params) and refresh this page.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,54 +2,51 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
{% block title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
{% block meta_description %}Professional web design and reliable hosting solutions by AI ML Operations, LLC. We create
|
{% block meta_description %}Custom brand-tailored websites with interactive package pricing. You own the site and data. Public site + client portal with UTM/leads included; add Email/SMS, blog, Stripe, social, and more. Three-instance hosting, SEO, accessibility, LLM integration, and Grafana alerts on every build.{% endblock %}
|
||||||
visually stunning, functional websites tailored to your business.{% endblock %}
|
{% block og_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
|
{% block og_description %}Build a custom site package with live pricing. You own the site and data. Base site + portal included; three-instance hosting, UTM/leads, SEO, accessibility, LLM readiness, and Grafana metrics on every project.{% endblock %}
|
||||||
|
{% block twitter_title %}Web Design & Hosting - AI ML Operations, LLC{% endblock %}
|
||||||
|
{% block twitter_description %}Custom brand-tailored sites with interactive package pricing. You own the site and data — leave anytime and take it with you.{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-title">Web Design & Hosting</h1>
|
<h1 class="hero-title">Web Design & Hosting</h1>
|
||||||
<p class="hero-subtitle">Crafting Beautiful, Functional Websites with Reliable Hosting</p>
|
<p class="hero-subtitle">Crafted sites with reliable multi-instance hosting and built-in growth tools</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About Web Design & Hosting Section -->
|
<!-- About -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">About Our Web Design & Hosting Service</h2>
|
<h2 class="section-title">About Our Web Design & Hosting Service</h2>
|
||||||
<p style="text-align: center; max-width: 800px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
|
<p style="text-align: center; max-width: 800px; margin: 0 auto; color: var(--text-muted); font-size: 1.1rem;">
|
||||||
At AI ML Operations, we specialize in creating visually stunning, highly functional websites tailored to your
|
At AI ML Operations, we design and host custom websites tailored to your brand and how your
|
||||||
business needs. From design to deployment, we handle every aspect of your online presence. Our reliable hosting
|
customers use the site. You own the site and data — leave anytime and take everything with you.
|
||||||
solutions ensure your website is always fast, secure, and accessible. Whether you need a simple portfolio site or
|
Every project ships with three-instance hosting, UTM tracking and lead capture, optimized SEO,
|
||||||
a complex e-commerce platform, we’ve got you covered.
|
accessibility, performance, LLM integration, and Grafana metrics and alerts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features Section -->
|
<!-- Included with every site -->
|
||||||
<div class="section" style="background: var(--surface-color);">
|
<div class="section" style="background: var(--surface-color);">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">What We Offer</h2>
|
<h2 class="section-title">Included With Every Site</h2>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
|
{% for item in pricing_included %}
|
||||||
<div class="card" style="text-align: center;">
|
<div class="card" style="text-align: center;">
|
||||||
<h5 class="card-title">Custom Web Design</h5>
|
<h5 class="card-title">{{ item.title }}</h5>
|
||||||
<p class="card-text">Tailored designs that reflect your brand and engage your audience.</p>
|
<p class="card-text">{{ item.description }}</p>
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Reliable Hosting</h5>
|
|
||||||
<p class="card-text">Fast, secure, and scalable hosting solutions for your website.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="text-align: center;">
|
|
||||||
<h5 class="card-title">Ongoing Support</h5>
|
|
||||||
<p class="card-text">Continuous maintenance and support to keep your site running smoothly.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Services Section -->
|
<!-- Services -->
|
||||||
<div class="section">
|
<!-- <div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Our Services</h2>
|
<h2 class="section-title">Our Services</h2>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
@@ -60,134 +57,246 @@ visually stunning, functional websites tailored to your business.{% endblock %}
|
|||||||
<p class="card-text">Unique, responsive designs tailored to your brand and audience.</p>
|
<p class="card-text">Unique, responsive designs tailored to your brand and audience.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-2.jpg' %}" alt="E-Commerce Solutions"
|
<img src="{% static 'public/img/web_design/card-2.jpg' %}" alt="Client Portal"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">E-Commerce Solutions</span>
|
<span class="card-title">Client Portal + UTM</span>
|
||||||
<p class="card-text">Build and optimize online stores for seamless shopping experiences.</p>
|
<p class="card-text">Login, dashboard, leads, and UTM analytics — included with every site.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-3.jpg' %}" alt="Website Hosting"
|
<img src="{% static 'public/img/web_design/card-3.jpg' %}" alt="Website Hosting"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">Website Hosting</span>
|
<span class="card-title">Multi-Instance Hosting</span>
|
||||||
<p class="card-text">Secure, high-performance hosting with 99.9% uptime guarantee.</p>
|
<p class="card-text">At least three instances for reliability, failover, and scale.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-4.jpg' %}" alt="SEO Optimization"
|
<img src="{% static 'public/img/web_design/card-4.jpg' %}" alt="SEO and Accessibility"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">SEO Optimization</span>
|
<span class="card-title">SEO, Accessibility & Performance</span>
|
||||||
<p class="card-text">Improve your website's visibility and ranking on search engines.</p>
|
<p class="card-text">Search-ready, accessible, and fast — plus LLM-friendly site structure.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Maintenance & Support"
|
<img src="{% static 'public/img/web_design/card-5.jpg' %}" alt="Growth Add-ons"
|
||||||
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
style="width: 100%; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
<span class="card-title">Maintenance & Support</span>
|
<span class="card-title">Growth Add-ons</span>
|
||||||
<p class="card-text">Regular updates, backups, and troubleshooting to keep your site running smoothly.</p>
|
<p class="card-text">Email/SMS, direct mail, blog, Stripe payments, social, and AI social drafts.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<!-- Pricing Section -->
|
<!-- Interactive pricing -->
|
||||||
<div class="section" style="background: var(--surface-color);">
|
<div class="section" style="background: var(--surface-color);">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Web Hosting Plans</h2>
|
<h2 class="section-title">Build Your Package</h2>
|
||||||
|
<p class="pricing-intro">
|
||||||
|
Public site and client portal (with UTM) are always included. Toggle add-ons to see one-time
|
||||||
|
build and monthly totals. Stripe / SMS / postage usage billed separately.
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Pricing toggle -->
|
<div class="pricing-estimator" id="pricingEstimator"
|
||||||
<div class="pricing-toggle-wrap">
|
data-disclaimer="{{ pricing_disclaimer|escape }}">
|
||||||
<div class="pricing-toggle" role="group" aria-label="Billing period">
|
<div class="pricing-features-panel">
|
||||||
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span>
|
<h3 class="pricing-panel-title">Features</h3>
|
||||||
<label class="pricing-switch" for="pricingToggle">
|
<ul class="pricing-feature-list" role="list">
|
||||||
<input type="checkbox" id="pricingToggle" aria-label="Toggle between monthly and yearly billing">
|
{% for feature in pricing_features %}
|
||||||
<span class="pricing-switch-slider"></span>
|
<li class="pricing-feature{% if feature.required %} is-required{% endif %}"
|
||||||
|
data-feature-id="{{ feature.id }}">
|
||||||
|
<label class="pricing-feature-label">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="pricing-feature-check"
|
||||||
|
value="{{ feature.id }}"
|
||||||
|
data-build="{{ feature.build }}"
|
||||||
|
data-monthly="{{ feature.monthly }}"
|
||||||
|
data-requires="{{ feature.requires|join:',' }}"
|
||||||
|
{% if feature.required %}checked disabled{% endif %}>
|
||||||
|
<span class="pricing-feature-body">
|
||||||
|
<span class="pricing-feature-name">{{ feature.name }}</span>
|
||||||
|
<span class="pricing-feature-desc">{{ feature.description }}</span>
|
||||||
|
{% if feature.requires_note %}
|
||||||
|
<span class="pricing-feature-note" hidden data-note-for="{{ feature.id }}">{{ feature.requires_note }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="pricing-feature-costs">
|
||||||
|
<strong>${{ feature.build }}</strong> build
|
||||||
|
<span class="pricing-feature-monthly"><strong>${{ feature.monthly }}</strong> /mo</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
|
</li>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pricing cards -->
|
<aside class="pricing-estimate-panel" aria-live="polite">
|
||||||
<div class="card-grid" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
|
<h3 class="pricing-panel-title">Estimate</h3>
|
||||||
<!-- Monthly Card -->
|
<div class="pricing-estimate-row">
|
||||||
<div class="card" style="text-align: center;">
|
<span>One-time build</span>
|
||||||
<span class="card-title" style="font-size: 1.5rem;">Standard Plan</span>
|
<strong id="estimateBuild">${{ pricing_base_estimate.one_time_build }}</strong>
|
||||||
<h4 style="font-size: 3rem; color: var(--primary-color); margin: 1rem 0;">$<span class="price">10</span></h4>
|
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Web Hosting</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Weekly Backups</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ SSL Certificate</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ CAPTCHA Protection</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
|
|
||||||
</ul>
|
|
||||||
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" class="btn">Get Started</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pricing-estimate-row">
|
||||||
<!-- Yearly Card -->
|
<span>Monthly</span>
|
||||||
<div class="card" style="text-align: center; border-color: var(--secondary-color);">
|
<strong id="estimateMonthly">${{ pricing_base_estimate.monthly }}</strong>
|
||||||
<span class="card-title" style="font-size: 1.5rem; color: var(--secondary-color);">Premium Plan</span>
|
|
||||||
<h4 style="font-size: 3rem; color: var(--secondary-color); margin: 1rem 0;">$<span class="price">15</span></h4>
|
|
||||||
<p class="card-text" style="margin-bottom: 2rem;"><span class="billing-period">per month</span></p>
|
|
||||||
<ul style="text-align: left; margin-bottom: 2rem; list-style: none;">
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ All Standard Features</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Monthly Analytics Reports</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Site Optimization Reports</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ HTML Marketing Emails</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
|
|
||||||
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
|
|
||||||
</ul>
|
|
||||||
<a href="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="pricing-estimate-count" id="estimateCount">
|
||||||
|
Selected: {{ pricing_base_estimate.selected_count }} features
|
||||||
|
</p>
|
||||||
|
<p class="pricing-estimate-disclaimer">{{ pricing_disclaimer }}</p>
|
||||||
|
<a href="{% url 'contact' %}?subject=Web%20Design%20Package%20Estimate"
|
||||||
|
class="btn pricing-estimate-cta"
|
||||||
|
id="estimateContactCta"
|
||||||
|
data-tianji-event="web_design_estimate_submit"
|
||||||
|
data-tianji-event-page="web_design"
|
||||||
|
data-tianji-event-action="request_quote"
|
||||||
|
data-tianji-event-build="{{ pricing_base_estimate.one_time_build }}"
|
||||||
|
data-tianji-event-monthly="{{ pricing_base_estimate.monthly }}"
|
||||||
|
data-tianji-event-selected-count="{{ pricing_base_estimate.selected_count }}"
|
||||||
|
data-tianji-event-features="{% for item in pricing_base_estimate.selected %}{{ item.id }}{% if not forloop.last %},{% endif %}{% endfor %}">Request this package</a>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const pricingToggle = document.getElementById('pricingToggle');
|
(function () {
|
||||||
const monthlyLabel = document.getElementById('monthlyLabel');
|
var root = document.getElementById('pricingEstimator');
|
||||||
const yearlyLabel = document.getElementById('yearlyLabel');
|
if (!root) return;
|
||||||
|
|
||||||
function updatePricing(isYearly) {
|
var checks = Array.prototype.slice.call(root.querySelectorAll('.pricing-feature-check'));
|
||||||
const prices = document.querySelectorAll('.price');
|
var buildEl = document.getElementById('estimateBuild');
|
||||||
const periods = document.querySelectorAll('.billing-period');
|
var monthlyEl = document.getElementById('estimateMonthly');
|
||||||
|
var countEl = document.getElementById('estimateCount');
|
||||||
|
var ctaEl = document.getElementById('estimateContactCta');
|
||||||
|
var byId = {};
|
||||||
|
|
||||||
monthlyLabel.classList.toggle('active', !isYearly);
|
checks.forEach(function (input) {
|
||||||
yearlyLabel.classList.toggle('active', isYearly);
|
byId[input.value] = input;
|
||||||
|
});
|
||||||
|
|
||||||
if (isYearly) {
|
function formatMoney(n) {
|
||||||
prices[0].textContent = '100';
|
return '$' + n.toLocaleString('en-US');
|
||||||
periods[0].textContent = 'per year';
|
}
|
||||||
prices[1].textContent = '150';
|
|
||||||
periods[1].textContent = 'per year';
|
function trackTianji(name, data) {
|
||||||
} else {
|
if (typeof window.aimlTrackWhenReady === 'function') {
|
||||||
prices[0].textContent = '10';
|
window.aimlTrackWhenReady(name, data);
|
||||||
periods[0].textContent = 'per month';
|
|
||||||
prices[1].textContent = '15';
|
|
||||||
periods[1].textContent = 'per month';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pricingToggle.addEventListener('change', function () {
|
function getSelectionState() {
|
||||||
updatePricing(this.checked);
|
var build = 0;
|
||||||
|
var monthly = 0;
|
||||||
|
var selected = [];
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
if (!input.checked) return;
|
||||||
|
build += Number(input.getAttribute('data-build')) || 0;
|
||||||
|
monthly += Number(input.getAttribute('data-monthly')) || 0;
|
||||||
|
selected.push(input.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
monthlyLabel.addEventListener('click', function () {
|
return {
|
||||||
pricingToggle.checked = false;
|
build: build,
|
||||||
updatePricing(false);
|
monthly: monthly,
|
||||||
|
selected: selected,
|
||||||
|
selectedCount: selected.length,
|
||||||
|
features: selected.join(','),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredBy(id) {
|
||||||
|
return checks.filter(function (input) {
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
return req.indexOf(id) !== -1 && input.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceDependencies(changed) {
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
if (!input.checked || input.disabled) return;
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
req.forEach(function (depId) {
|
||||||
|
var dep = byId[depId];
|
||||||
|
if (dep && !dep.checked && !dep.disabled) {
|
||||||
|
dep.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
yearlyLabel.addEventListener('click', function () {
|
if (changed && !changed.checked) {
|
||||||
pricingToggle.checked = true;
|
requiredBy(changed.value).forEach(function (dep) {
|
||||||
updatePricing(true);
|
if (!dep.disabled) dep.checked = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
var note = root.querySelector('[data-note-for="' + input.value + '"]');
|
||||||
|
if (!note) return;
|
||||||
|
var req = (input.getAttribute('data-requires') || '').split(',').filter(Boolean);
|
||||||
|
note.hidden = !(input.checked && req.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEstimate() {
|
||||||
|
enforceDependencies();
|
||||||
|
|
||||||
|
var state = getSelectionState();
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
var row = input.closest('.pricing-feature');
|
||||||
|
if (row) row.classList.toggle('is-selected', input.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
buildEl.textContent = formatMoney(state.build);
|
||||||
|
monthlyEl.textContent = formatMoney(state.monthly);
|
||||||
|
countEl.textContent = 'Selected: ' + state.selectedCount + ' feature' + (state.selectedCount === 1 ? '' : 's');
|
||||||
|
|
||||||
|
if (ctaEl) {
|
||||||
|
var subject = 'Web Design Package Estimate — ' + state.features;
|
||||||
|
ctaEl.href = '{% url "contact" %}?subject=' + encodeURIComponent(subject);
|
||||||
|
ctaEl.setAttribute('data-tianji-event-build', String(state.build));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-monthly', String(state.monthly));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-selected-count', String(state.selectedCount));
|
||||||
|
ctaEl.setAttribute('data-tianji-event-features', state.features);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.forEach(function (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var before = getSelectionState();
|
||||||
|
enforceDependencies(input);
|
||||||
|
var state = updateEstimate();
|
||||||
|
|
||||||
|
trackTianji('web_design_estimate_toggle', {
|
||||||
|
page: 'web_design',
|
||||||
|
feature: input.value,
|
||||||
|
enabled: input.checked ? 'true' : 'false',
|
||||||
|
build: String(state.build),
|
||||||
|
monthly: String(state.monthly),
|
||||||
|
selected_count: String(state.selectedCount),
|
||||||
|
features: state.features,
|
||||||
|
previous_features: before.features,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateEstimate();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Call to Action Section -->
|
<!-- Call to Action -->
|
||||||
<div class="section" style="text-align: center;">
|
<div class="section" style="text-align: center;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="section-title">Ready to Build Your Online Presence?</h2>
|
<h2 class="section-title">Ready to Build Your Online Presence?</h2>
|
||||||
<p class="hero-subtitle" style="margin-bottom: 2rem;">Contact us today to get started on your website project.</p>
|
<p class="hero-subtitle" style="margin-bottom: 2rem;">
|
||||||
<a href="{% url 'contact' %}" class="btn"
|
Tell us which features you need — we will turn this estimate into a tailored proposal.
|
||||||
data-tianji-event="service_cta" data-tianji-event-page="web_design" data-tianji-event-action="get_started">Get Started</a>
|
</p>
|
||||||
|
<a href="{% url 'contact' %}?subject=Web%20Design%20Inquiry" class="btn"
|
||||||
|
data-tianji-event="web_design_contact_cta"
|
||||||
|
data-tianji-event-page="web_design"
|
||||||
|
data-tianji-event-action="get_started">
|
||||||
|
Contact us
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,8 +6,9 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from company_site.settings.base import build_csrf_trusted_origins
|
from company_site.settings.base import build_csrf_trusted_origins
|
||||||
|
|
||||||
from .models import Contact, EmailMessage
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
from .seo import SERVICE_URL_NAMES, get_service_entries
|
from .seo import SERVICE_URL_NAMES, get_service_entries
|
||||||
|
from .traffic import TrafficType, classify_user_agent
|
||||||
|
|
||||||
|
|
||||||
class CsrfTrustedOriginsTests(TestCase):
|
class CsrfTrustedOriginsTests(TestCase):
|
||||||
@@ -223,6 +224,7 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
self.assertContains(response, "User-agent: *")
|
self.assertContains(response, "User-agent: *")
|
||||||
self.assertContains(response, "Sitemap:")
|
self.assertContains(response, "Sitemap:")
|
||||||
|
self.assertContains(response, "llms.txt")
|
||||||
|
|
||||||
def test_sitemap_xml_lists_public_pages(self):
|
def test_sitemap_xml_lists_public_pages(self):
|
||||||
response = self.client.get(reverse("sitemap_xml"))
|
response = self.client.get(reverse("sitemap_xml"))
|
||||||
@@ -241,6 +243,14 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertContains(response, "# AI ML Operations, LLC")
|
self.assertContains(response, "# AI ML Operations, LLC")
|
||||||
self.assertContains(response, "## Key pages")
|
self.assertContains(response, "## Key pages")
|
||||||
self.assertContains(response, reverse("contact"))
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, "## Web design & hosting")
|
||||||
|
self.assertContains(response, "estimate_web_design_cost")
|
||||||
|
self.assertContains(response, "email_sms")
|
||||||
|
self.assertContains(response, "three instances")
|
||||||
|
self.assertContains(response, "Tailored to your brand")
|
||||||
|
self.assertContains(response, "You own the site")
|
||||||
|
self.assertContains(response, "Grafana metrics")
|
||||||
|
self.assertContains(response, "brand-tailored")
|
||||||
|
|
||||||
def test_homepage_uses_semantic_nav_controls(self):
|
def test_homepage_uses_semantic_nav_controls(self):
|
||||||
response = self.client.get(reverse("public_index"))
|
response = self.client.get(reverse("public_index"))
|
||||||
@@ -313,6 +323,7 @@ class WebMcpTests(TestCase):
|
|||||||
"get_page_content",
|
"get_page_content",
|
||||||
"navigate_to_service",
|
"navigate_to_service",
|
||||||
"open_contact_with_subject",
|
"open_contact_with_subject",
|
||||||
|
"estimate_web_design_cost",
|
||||||
"submit_contact_inquiry",
|
"submit_contact_inquiry",
|
||||||
):
|
):
|
||||||
self.assertIn("name: '" + tool_name + "'", script)
|
self.assertIn("name: '" + tool_name + "'", script)
|
||||||
@@ -320,3 +331,180 @@ class WebMcpTests(TestCase):
|
|||||||
self.assertIn("readOnlyHint: true", script)
|
self.assertIn("readOnlyHint: true", script)
|
||||||
self.assertIn("readOnlyHint: false", script)
|
self.assertIn("readOnlyHint: false", script)
|
||||||
self.assertIn("navigator.modelContext || document.modelContext", script)
|
self.assertIn("navigator.modelContext || document.modelContext", script)
|
||||||
|
self.assertIn("webDesignPricing", script)
|
||||||
|
|
||||||
|
def test_webmcp_config_includes_web_design_pricing(self):
|
||||||
|
response = self.client.get(reverse("public_index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "data-web-design-pricing=")
|
||||||
|
self.assertContains(response, "email_sms")
|
||||||
|
self.assertContains(response, "client_portal")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDesignPricingTests(TestCase):
|
||||||
|
def test_web_design_page_renders_estimator(self):
|
||||||
|
response = self.client.get(reverse("web_design"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Build Your Package")
|
||||||
|
self.assertContains(response, "pricingEstimator")
|
||||||
|
self.assertContains(response, "Public site")
|
||||||
|
self.assertContains(response, "Client portal + UTM")
|
||||||
|
self.assertContains(response, "three instances")
|
||||||
|
self.assertContains(response, "UTM tracking")
|
||||||
|
self.assertContains(response, "Grafana metrics & alerts")
|
||||||
|
self.assertContains(response, "You own the site")
|
||||||
|
self.assertContains(response, "Tailored to your brand")
|
||||||
|
self.assertContains(response, reverse("contact"))
|
||||||
|
self.assertContains(response, 'data-tianji-event="web_design_estimate_submit"')
|
||||||
|
self.assertContains(response, 'data-tianji-event="web_design_contact_cta"')
|
||||||
|
self.assertContains(response, "web_design_estimate_toggle")
|
||||||
|
self.assertContains(response, "aimlTrackWhenReady")
|
||||||
|
self.assertContains(response, "data-tianji-event-build=")
|
||||||
|
self.assertContains(response, "data-tianji-event-features=")
|
||||||
|
|
||||||
|
def test_base_estimate_includes_required_features_only(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost()
|
||||||
|
self.assertEqual(estimate["one_time_build"], 600)
|
||||||
|
self.assertEqual(estimate["monthly"], 40)
|
||||||
|
self.assertEqual(estimate["selected_count"], 2)
|
||||||
|
|
||||||
|
def test_payments_auto_selects_email_sms(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost(["payments"])
|
||||||
|
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||||
|
self.assertIn("email_sms", selected_ids)
|
||||||
|
self.assertIn("payments", selected_ids)
|
||||||
|
self.assertEqual(estimate["one_time_build"], 1600)
|
||||||
|
self.assertEqual(estimate["monthly"], 70)
|
||||||
|
|
||||||
|
def test_ai_social_auto_selects_social(self):
|
||||||
|
from .web_design_pricing import estimate_web_design_cost
|
||||||
|
|
||||||
|
estimate = estimate_web_design_cost(["ai_social"])
|
||||||
|
selected_ids = {item["id"] for item in estimate["selected"]}
|
||||||
|
self.assertIn("social", selected_ids)
|
||||||
|
self.assertIn("ai_social", selected_ids)
|
||||||
|
self.assertEqual(estimate["one_time_build"], 1400)
|
||||||
|
self.assertEqual(estimate["monthly"], 80)
|
||||||
|
|
||||||
|
|
||||||
|
class TrafficClassificationTests(TestCase):
|
||||||
|
def test_classifies_common_agents(self):
|
||||||
|
cases = [
|
||||||
|
("Mozilla/5.0 (Macintosh) Chrome/120.0.0.0 Safari/537.36", TrafficType.HUMAN),
|
||||||
|
("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", TrafficType.SEARCH_INDEXER),
|
||||||
|
("Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.0)", TrafficType.AI_BOT),
|
||||||
|
("ClaudeBot/1.0", TrafficType.AI_BOT),
|
||||||
|
("facebookexternalhit/1.1", TrafficType.SOCIAL_BOT),
|
||||||
|
("UptimeRobot/2.0", TrafficType.MONITORING),
|
||||||
|
("python-requests/2.31.0", TrafficType.OTHER_BOT),
|
||||||
|
("", TrafficType.UNKNOWN),
|
||||||
|
]
|
||||||
|
for ua, expected in cases:
|
||||||
|
with self.subTest(ua=ua):
|
||||||
|
self.assertEqual(classify_user_agent(ua), expected)
|
||||||
|
|
||||||
|
|
||||||
|
class UTMTrackingTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(
|
||||||
|
HTTP_USER_AGENT="Mozilla/5.0 (Macintosh) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
self.staff = User.objects.create_user(
|
||||||
|
username="staff_utm", password="pass", is_staff=True
|
||||||
|
)
|
||||||
|
self.regular = User.objects.create_user(username="plain_utm", password="pass")
|
||||||
|
|
||||||
|
def test_page_visit_records_utm_and_human_type(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/?utm_source=linkedin&utm_medium=social&utm_campaign=spring"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
visit = PageVisit.objects.latest("created")
|
||||||
|
self.assertEqual(visit.path, "/")
|
||||||
|
self.assertEqual(visit.utm_source, "linkedin")
|
||||||
|
self.assertEqual(visit.utm_medium, "social")
|
||||||
|
self.assertEqual(visit.utm_campaign, "spring")
|
||||||
|
self.assertTrue(visit.is_landing)
|
||||||
|
self.assertEqual(visit.traffic_type, TrafficType.HUMAN)
|
||||||
|
|
||||||
|
def test_utm_persists_on_next_page_via_session(self):
|
||||||
|
self.client.get("/?utm_source=newsletter&utm_medium=email&utm_campaign=march")
|
||||||
|
self.client.get("/contact")
|
||||||
|
visit = PageVisit.objects.filter(path="/contact").latest("created")
|
||||||
|
self.assertEqual(visit.utm_source, "newsletter")
|
||||||
|
self.assertEqual(visit.utm_campaign, "march")
|
||||||
|
self.assertFalse(visit.is_landing)
|
||||||
|
|
||||||
|
def test_dashboard_requires_staff(self):
|
||||||
|
url = reverse("utm_dashboard")
|
||||||
|
anon = self.client.get(url)
|
||||||
|
self.assertEqual(anon.status_code, 302)
|
||||||
|
self.assertIn("login", anon.url)
|
||||||
|
|
||||||
|
self.client.login(username="plain_utm", password="pass")
|
||||||
|
denied = self.client.get(url)
|
||||||
|
self.assertEqual(denied.status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="staff_utm", password="pass")
|
||||||
|
ok = self.client.get(url)
|
||||||
|
self.assertEqual(ok.status_code, 200)
|
||||||
|
self.assertContains(ok, "UTM & Traffic Analytics")
|
||||||
|
|
||||||
|
def test_search_bot_classified(self):
|
||||||
|
bot_client = Client(
|
||||||
|
HTTP_USER_AGENT="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
|
||||||
|
)
|
||||||
|
bot_client.get("/")
|
||||||
|
visit = PageVisit.objects.latest("created")
|
||||||
|
self.assertEqual(visit.traffic_type, TrafficType.SEARCH_INDEXER)
|
||||||
|
|
||||||
|
|
||||||
|
class LeadsDashboardTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.staff = User.objects.create_user(
|
||||||
|
username="staff_leads", password="pass", is_staff=True
|
||||||
|
)
|
||||||
|
self.regular = User.objects.create_user(username="plain_leads", password="pass")
|
||||||
|
self.lead = Contact.objects.create(
|
||||||
|
name="Ada Lovelace",
|
||||||
|
email="ada@example.com",
|
||||||
|
subject="AI help",
|
||||||
|
blurb="Need forward-deployed support.",
|
||||||
|
utm_source="linkedin",
|
||||||
|
utm_medium="social",
|
||||||
|
utm_campaign="spring",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_leads_list_requires_staff(self):
|
||||||
|
url = reverse("leads_list")
|
||||||
|
self.assertEqual(self.client.get(url).status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="plain_leads", password="pass")
|
||||||
|
self.assertEqual(self.client.get(url).status_code, 302)
|
||||||
|
|
||||||
|
self.client.login(username="staff_leads", password="pass")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Ada Lovelace")
|
||||||
|
self.assertContains(response, "ada@example.com")
|
||||||
|
|
||||||
|
def test_lead_detail_and_toggle(self):
|
||||||
|
self.client.login(username="staff_leads", password="pass")
|
||||||
|
detail = reverse("lead_detail", kwargs={"pk": self.lead.pk})
|
||||||
|
response = self.client.get(detail)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Need forward-deployed support.")
|
||||||
|
self.assertContains(response, "linkedin")
|
||||||
|
|
||||||
|
toggle = reverse("lead_toggle_contacted", kwargs={"pk": self.lead.pk})
|
||||||
|
response = self.client.post(toggle, {"next": detail})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.lead.refresh_from_db()
|
||||||
|
self.assertTrue(self.lead.contacted)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Classify request traffic from User-Agent and related hints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class TrafficType(StrEnum):
|
||||||
|
HUMAN = "human"
|
||||||
|
AI_BOT = "ai_bot"
|
||||||
|
SEARCH_INDEXER = "search_indexer"
|
||||||
|
SOCIAL_BOT = "social_bot"
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
OTHER_BOT = "other_bot"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
TRAFFIC_TYPE_LABELS = {
|
||||||
|
TrafficType.HUMAN: "Human traffic",
|
||||||
|
TrafficType.AI_BOT: "AI bot / AI search",
|
||||||
|
TrafficType.SEARCH_INDEXER: "Search indexing",
|
||||||
|
TrafficType.SOCIAL_BOT: "Social / preview bot",
|
||||||
|
TrafficType.MONITORING: "Monitoring / uptime",
|
||||||
|
TrafficType.OTHER_BOT: "Other bot",
|
||||||
|
TrafficType.UNKNOWN: "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order matters: first match wins.
|
||||||
|
_AI_BOT_PATTERNS = (
|
||||||
|
r"GPTBot",
|
||||||
|
r"ChatGPT-User",
|
||||||
|
r"OAI-SearchBot",
|
||||||
|
r"ClaudeBot",
|
||||||
|
r"anthropic-ai",
|
||||||
|
r"Claude-Web",
|
||||||
|
r"Google-Extended",
|
||||||
|
r"GoogleOther",
|
||||||
|
r"Google-CloudVertexBot",
|
||||||
|
r"Bytespider",
|
||||||
|
r"CCBot",
|
||||||
|
r"Diffbot",
|
||||||
|
r"FacebookBot", # Meta AI crawler (distinct from facebookexternalhit)
|
||||||
|
r"meta-externalagent",
|
||||||
|
r"Meta-ExternalAgent",
|
||||||
|
r"PerplexityBot",
|
||||||
|
r"Perplexity-User",
|
||||||
|
r"YouBot",
|
||||||
|
r"Amazonbot",
|
||||||
|
r"Applebot-Extended",
|
||||||
|
r"cohere-ai",
|
||||||
|
r"Cohere-ai",
|
||||||
|
r"AI2Bot",
|
||||||
|
r"omgili",
|
||||||
|
r"ImagesiftBot",
|
||||||
|
r"Timpibot",
|
||||||
|
r"Webzio-Extended",
|
||||||
|
r"DuckAssistBot",
|
||||||
|
r"iAskBot",
|
||||||
|
r"MistralAI-User",
|
||||||
|
r"xAI-Bot",
|
||||||
|
r"GrokBot",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SEARCH_INDEXER_PATTERNS = (
|
||||||
|
r"Googlebot",
|
||||||
|
r"Googlebot-Image",
|
||||||
|
r"Googlebot-News",
|
||||||
|
r"Googlebot-Video",
|
||||||
|
r"Storebot-Google",
|
||||||
|
r"AdsBot-Google",
|
||||||
|
r"Mediapartners-Google",
|
||||||
|
r"Bingbot",
|
||||||
|
r"bingbot",
|
||||||
|
r"BingPreview",
|
||||||
|
r"adidxbot",
|
||||||
|
r"DuckDuckBot",
|
||||||
|
r"Slurp", # Yahoo
|
||||||
|
r"YandexBot",
|
||||||
|
r"YandexImages",
|
||||||
|
r"Baiduspider",
|
||||||
|
r"Sogou",
|
||||||
|
r"Applebot",
|
||||||
|
r"SeznamBot",
|
||||||
|
r"Qwantify",
|
||||||
|
r"ecosia",
|
||||||
|
r"BraveBot",
|
||||||
|
r"PetalBot",
|
||||||
|
)
|
||||||
|
|
||||||
|
_SOCIAL_BOT_PATTERNS = (
|
||||||
|
r"facebookexternalhit",
|
||||||
|
r"Facebot",
|
||||||
|
r"Twitterbot",
|
||||||
|
r"LinkedInBot",
|
||||||
|
r"Slackbot",
|
||||||
|
r"Discordbot",
|
||||||
|
r"WhatsApp",
|
||||||
|
r"TelegramBot",
|
||||||
|
r"Pinterest",
|
||||||
|
r"vkShare",
|
||||||
|
r"SkypeUriPreview",
|
||||||
|
r"redditbot",
|
||||||
|
r"Embedly",
|
||||||
|
r"Iframely",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MONITORING_PATTERNS = (
|
||||||
|
r"UptimeRobot",
|
||||||
|
r"Pingdom",
|
||||||
|
r"StatusCake",
|
||||||
|
r"Site24x7",
|
||||||
|
r"Better Uptime",
|
||||||
|
r"BetterStack",
|
||||||
|
r"Healthchecks",
|
||||||
|
r"NewRelic",
|
||||||
|
r"Datadog",
|
||||||
|
r"Synthetics",
|
||||||
|
r"GhostInspector",
|
||||||
|
r"HeadlessChrome", # often synthetic monitors
|
||||||
|
)
|
||||||
|
|
||||||
|
_GENERIC_BOT_PATTERNS = (
|
||||||
|
r"bot",
|
||||||
|
r"crawler",
|
||||||
|
r"spider",
|
||||||
|
r"scraper",
|
||||||
|
r"curl/",
|
||||||
|
r"wget/",
|
||||||
|
r"python-requests",
|
||||||
|
r"Go-http-client",
|
||||||
|
r"httpx",
|
||||||
|
r"aiohttp",
|
||||||
|
r"Java/",
|
||||||
|
r"libwww",
|
||||||
|
r"scrapy",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Browser-like tokens used to avoid over-classifying humans as bots when UA contains "bot" in odd places.
|
||||||
|
_BROWSER_HINTS = (
|
||||||
|
r"Mozilla/",
|
||||||
|
r"Chrome/",
|
||||||
|
r"Safari/",
|
||||||
|
r"Firefox/",
|
||||||
|
r"Edg/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compile(patterns: tuple[str, ...]) -> re.Pattern[str]:
|
||||||
|
return re.compile("|".join(f"(?:{p})" for p in patterns), re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
_AI_RE = _compile(_AI_BOT_PATTERNS)
|
||||||
|
_SEARCH_RE = _compile(_SEARCH_INDEXER_PATTERNS)
|
||||||
|
_SOCIAL_RE = _compile(_SOCIAL_BOT_PATTERNS)
|
||||||
|
_MONITOR_RE = _compile(_MONITORING_PATTERNS)
|
||||||
|
_GENERIC_BOT_RE = _compile(_GENERIC_BOT_PATTERNS)
|
||||||
|
_BROWSER_RE = _compile(_BROWSER_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_user_agent(user_agent: str | None) -> TrafficType:
|
||||||
|
ua = (user_agent or "").strip()
|
||||||
|
if not ua:
|
||||||
|
return TrafficType.UNKNOWN
|
||||||
|
|
||||||
|
if _AI_RE.search(ua):
|
||||||
|
return TrafficType.AI_BOT
|
||||||
|
if _SEARCH_RE.search(ua):
|
||||||
|
return TrafficType.SEARCH_INDEXER
|
||||||
|
if _SOCIAL_RE.search(ua):
|
||||||
|
return TrafficType.SOCIAL_BOT
|
||||||
|
if _MONITOR_RE.search(ua):
|
||||||
|
return TrafficType.MONITORING
|
||||||
|
if _GENERIC_BOT_RE.search(ua):
|
||||||
|
# Some real browsers mention "bot" rarely; prefer human if clearly a browser UA
|
||||||
|
# without other bot signals already matched above.
|
||||||
|
if _BROWSER_RE.search(ua) and not re.search(
|
||||||
|
r"(?:bot|crawler|spider|scraper)", ua, re.IGNORECASE
|
||||||
|
):
|
||||||
|
return TrafficType.HUMAN
|
||||||
|
return TrafficType.OTHER_BOT
|
||||||
|
return TrafficType.HUMAN
|
||||||
|
|
||||||
|
|
||||||
|
def traffic_type_label(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return TRAFFIC_TYPE_LABELS[TrafficType(value)]
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
@@ -18,6 +18,14 @@ urlpatterns = [
|
|||||||
path("ml_model", views.ml_model, name="ml_model"),
|
path("ml_model", views.ml_model, name="ml_model"),
|
||||||
path("contact", views.contact, name="contact"),
|
path("contact", views.contact, name="contact"),
|
||||||
path("terms", views.terms_of_service, name="terms_of_service"),
|
path("terms", views.terms_of_service, name="terms_of_service"),
|
||||||
|
path("utm", views.utm_dashboard, name="utm_dashboard"),
|
||||||
|
path("leads", views.leads_list, name="leads_list"),
|
||||||
|
path("leads/<int:pk>/", views.lead_detail, name="lead_detail"),
|
||||||
|
path(
|
||||||
|
"leads/<int:pk>/toggle-contacted/",
|
||||||
|
views.lead_toggle_contacted,
|
||||||
|
name="lead_toggle_contacted",
|
||||||
|
),
|
||||||
path("change_password", views.change_password, name="change_password"),
|
path("change_password", views.change_password, name="change_password"),
|
||||||
path("preview_email/<int:pk>/", views.preview_email, name="preview_email")
|
path("preview_email/<int:pk>/", views.preview_email, name="preview_email")
|
||||||
]
|
]
|
||||||
|
|||||||
+224
-10
@@ -1,16 +1,23 @@
|
|||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from .models import Contact, EmailMessage
|
|
||||||
from django.template.loader import get_template
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from .forms import FormWithCaptcha
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import PasswordChangeForm
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
from django.template.loader import get_template
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from .forms import FormWithCaptcha
|
||||||
|
from .middleware import get_session_utm
|
||||||
|
from .models import Contact, EmailMessage, PageVisit
|
||||||
|
from .traffic import TRAFFIC_TYPE_LABELS, TrafficType
|
||||||
|
|
||||||
def send_contact_email(email, subject, message):
|
def send_contact_email(email, subject, message):
|
||||||
subject = "New Contact Request for AI ML Operations, LLC"
|
subject = "New Contact Request for AI ML Operations, LLC"
|
||||||
@@ -38,7 +45,23 @@ def computers(request):
|
|||||||
return render(request, "public/computers.html", {})
|
return render(request, "public/computers.html", {})
|
||||||
|
|
||||||
def web_design(request):
|
def web_design(request):
|
||||||
return render(request, "public/web_design.html", {})
|
from .web_design_pricing import (
|
||||||
|
WEB_DESIGN_INCLUDED,
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
features_for_json,
|
||||||
|
estimate_web_design_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/web_design.html",
|
||||||
|
{
|
||||||
|
"pricing_features": features_for_json(),
|
||||||
|
"pricing_included": WEB_DESIGN_INCLUDED,
|
||||||
|
"pricing_disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"pricing_base_estimate": estimate_web_design_cost(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def ai_sensor(request):
|
def ai_sensor(request):
|
||||||
return render(request, "public/ai_sensor.html", {})
|
return render(request, "public/ai_sensor.html", {})
|
||||||
@@ -85,7 +108,18 @@ def contact(request):
|
|||||||
|
|
||||||
|
|
||||||
# then we are good
|
# then we are good
|
||||||
c = Contact(name=name, email=email, blurb=message, subject=subject)
|
utm = get_session_utm(request.session)
|
||||||
|
c = Contact(
|
||||||
|
name=name,
|
||||||
|
email=email,
|
||||||
|
blurb=message,
|
||||||
|
subject=subject,
|
||||||
|
utm_source=utm.get("utm_source", ""),
|
||||||
|
utm_medium=utm.get("utm_medium", ""),
|
||||||
|
utm_campaign=utm.get("utm_campaign", ""),
|
||||||
|
utm_term=utm.get("utm_term", ""),
|
||||||
|
utm_content=utm.get("utm_content", ""),
|
||||||
|
)
|
||||||
c.save()
|
c.save()
|
||||||
# send the email.
|
# send the email.
|
||||||
try:
|
try:
|
||||||
@@ -128,3 +162,183 @@ def change_password(request):
|
|||||||
'form': form
|
'form': form
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def utm_dashboard(request):
|
||||||
|
try:
|
||||||
|
days = int(request.GET.get("days", "30"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 30
|
||||||
|
if days not in (7, 30, 90, 365):
|
||||||
|
days = 30
|
||||||
|
|
||||||
|
traffic_filter = request.GET.get("traffic", "")
|
||||||
|
since = timezone.now() - timedelta(days=days)
|
||||||
|
visits = PageVisit.objects.filter(created__gte=since)
|
||||||
|
|
||||||
|
if traffic_filter in {t.value for t in TrafficType}:
|
||||||
|
visits = visits.filter(traffic_type=traffic_filter)
|
||||||
|
|
||||||
|
type_counts_qs = (
|
||||||
|
PageVisit.objects.filter(created__gte=since)
|
||||||
|
.values("traffic_type")
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")
|
||||||
|
)
|
||||||
|
type_counts = [
|
||||||
|
{
|
||||||
|
"key": row["traffic_type"],
|
||||||
|
"label": TRAFFIC_TYPE_LABELS.get(
|
||||||
|
TrafficType(row["traffic_type"]), row["traffic_type"]
|
||||||
|
)
|
||||||
|
if row["traffic_type"] in {t.value for t in TrafficType}
|
||||||
|
else row["traffic_type"],
|
||||||
|
"total": row["total"],
|
||||||
|
}
|
||||||
|
for row in type_counts_qs
|
||||||
|
]
|
||||||
|
|
||||||
|
total_visits = PageVisit.objects.filter(created__gte=since).count()
|
||||||
|
human_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.HUMAN
|
||||||
|
).count()
|
||||||
|
ai_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.AI_BOT
|
||||||
|
).count()
|
||||||
|
indexer_count = PageVisit.objects.filter(
|
||||||
|
created__gte=since, traffic_type=TrafficType.SEARCH_INDEXER
|
||||||
|
).count()
|
||||||
|
utm_landings = PageVisit.objects.filter(
|
||||||
|
created__gte=since, is_landing=True
|
||||||
|
).count()
|
||||||
|
attributed_visits = PageVisit.objects.filter(created__gte=since).exclude(
|
||||||
|
utm_source=""
|
||||||
|
).count()
|
||||||
|
|
||||||
|
def top_utm(field: str, limit: int = 10):
|
||||||
|
return list(
|
||||||
|
PageVisit.objects.filter(created__gte=since)
|
||||||
|
.exclude(**{field: ""})
|
||||||
|
.values(field)
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
top_pages = list(
|
||||||
|
visits.values("path")
|
||||||
|
.annotate(total=Count("id"))
|
||||||
|
.order_by("-total")[:15]
|
||||||
|
)
|
||||||
|
|
||||||
|
recent = list(visits[:100])
|
||||||
|
|
||||||
|
contacts_with_utm = (
|
||||||
|
Contact.objects.filter(created__gte=since)
|
||||||
|
.exclude(utm_source="")
|
||||||
|
.order_by("-created")[:25]
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/utm_dashboard.html",
|
||||||
|
{
|
||||||
|
"days": days,
|
||||||
|
"traffic_filter": traffic_filter,
|
||||||
|
"traffic_choices": [
|
||||||
|
(t.value, TRAFFIC_TYPE_LABELS[t]) for t in TrafficType
|
||||||
|
],
|
||||||
|
"total_visits": total_visits,
|
||||||
|
"human_count": human_count,
|
||||||
|
"ai_count": ai_count,
|
||||||
|
"indexer_count": indexer_count,
|
||||||
|
"utm_landings": utm_landings,
|
||||||
|
"attributed_visits": attributed_visits,
|
||||||
|
"type_counts": type_counts,
|
||||||
|
"top_sources": top_utm("utm_source"),
|
||||||
|
"top_mediums": top_utm("utm_medium"),
|
||||||
|
"top_campaigns": top_utm("utm_campaign"),
|
||||||
|
"top_pages": top_pages,
|
||||||
|
"recent_visits": recent,
|
||||||
|
"contacts_with_utm": contacts_with_utm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def leads_list(request):
|
||||||
|
try:
|
||||||
|
days = int(request.GET.get("days", "90"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 90
|
||||||
|
if days not in (7, 30, 90, 365, 0):
|
||||||
|
days = 90
|
||||||
|
|
||||||
|
status = request.GET.get("status", "")
|
||||||
|
q = (request.GET.get("q") or "").strip()
|
||||||
|
utm_only = request.GET.get("utm") == "1"
|
||||||
|
|
||||||
|
leads = Contact.objects.all().order_by("-created")
|
||||||
|
if days:
|
||||||
|
leads = leads.filter(created__gte=timezone.now() - timedelta(days=days))
|
||||||
|
if status == "new":
|
||||||
|
leads = leads.filter(contacted=False)
|
||||||
|
elif status == "contacted":
|
||||||
|
leads = leads.filter(contacted=True)
|
||||||
|
if utm_only:
|
||||||
|
leads = leads.exclude(utm_source="")
|
||||||
|
if q:
|
||||||
|
leads = leads.filter(
|
||||||
|
Q(name__icontains=q)
|
||||||
|
| Q(email__icontains=q)
|
||||||
|
| Q(subject__icontains=q)
|
||||||
|
| Q(blurb__icontains=q)
|
||||||
|
| Q(utm_source__icontains=q)
|
||||||
|
| Q(utm_campaign__icontains=q)
|
||||||
|
)
|
||||||
|
|
||||||
|
base = Contact.objects.all()
|
||||||
|
if days:
|
||||||
|
base = base.filter(created__gte=timezone.now() - timedelta(days=days))
|
||||||
|
|
||||||
|
total = base.count()
|
||||||
|
new_count = base.filter(contacted=False).count()
|
||||||
|
contacted_count = base.filter(contacted=True).count()
|
||||||
|
with_utm = base.exclude(utm_source="").count()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"public/leads_list.html",
|
||||||
|
{
|
||||||
|
"leads": leads[:200],
|
||||||
|
"lead_count": leads.count(),
|
||||||
|
"days": days,
|
||||||
|
"status": status,
|
||||||
|
"q": q,
|
||||||
|
"utm_only": utm_only,
|
||||||
|
"total": total,
|
||||||
|
"new_count": new_count,
|
||||||
|
"contacted_count": contacted_count,
|
||||||
|
"with_utm": with_utm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def lead_detail(request, pk):
|
||||||
|
lead = get_object_or_404(Contact, pk=pk)
|
||||||
|
return render(request, "public/lead_detail.html", {"lead": lead})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_POST
|
||||||
|
def lead_toggle_contacted(request, pk):
|
||||||
|
lead = get_object_or_404(Contact, pk=pk)
|
||||||
|
lead.contacted = not lead.contacted
|
||||||
|
lead.save(update_fields=["contacted", "last_modified"])
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Marked {lead.name} as {'contacted' if lead.contacted else 'new'}.",
|
||||||
|
)
|
||||||
|
next_url = request.POST.get("next") or reverse("lead_detail", kwargs={"pk": lead.pk})
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Catalog pricing for the public web design cost estimator.
|
||||||
|
|
||||||
|
Draft catalog pricing — not a formal quote. Third-party usage
|
||||||
|
(Stripe fees, SMS, postage) is billed separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Feature ids used by the interactive UI and WebMCP estimate_web_design_cost tool.
|
||||||
|
WEB_DESIGN_FEATURES = (
|
||||||
|
{
|
||||||
|
"id": "public_site",
|
||||||
|
"name": "Public site",
|
||||||
|
"description": "Landing, about, contact (+ your service pages).",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": True,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "client_portal",
|
||||||
|
"name": "Client portal + UTM",
|
||||||
|
"description": "Login, dashboard, leads, UTM analytics — every client.",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 30,
|
||||||
|
"required": True,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email_sms",
|
||||||
|
"name": "Email & SMS",
|
||||||
|
"description": "Campaigns, mailing list, engagement reports.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "direct_mail",
|
||||||
|
"name": "Direct mail",
|
||||||
|
"description": "Postcard designer + print/send. Postage separate.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 5,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "blog",
|
||||||
|
"name": "Blog",
|
||||||
|
"description": "Public blog + portal post management.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 10,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "payments",
|
||||||
|
"name": "Payments (Stripe)",
|
||||||
|
"description": "Invoices + pay links. Requires Email & SMS.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": ("email_sms",),
|
||||||
|
"requires_note": "Requires Email & SMS — auto-selected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "social",
|
||||||
|
"name": "Social consolidation",
|
||||||
|
"description": "Accounts, composer, scheduling.",
|
||||||
|
"build": 500,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": (),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai_social",
|
||||||
|
"name": "AI social generator",
|
||||||
|
"description": "Ollama drafts. Requires Social consolidation.",
|
||||||
|
"build": 300,
|
||||||
|
"monthly": 20,
|
||||||
|
"required": False,
|
||||||
|
"requires": ("social",),
|
||||||
|
"requires_note": "Requires Social consolidation — auto-selected.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
WEB_DESIGN_INCLUDED = (
|
||||||
|
{
|
||||||
|
"title": "Tailored to your brand",
|
||||||
|
"description": "Custom design suited to your business — layout, visuals, and flows built around how your customers actually use the site.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You own the site & data",
|
||||||
|
"description": "The site and your data belong to you. Leave anytime and take everything with you — no lock-in.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Three-instance hosting",
|
||||||
|
"description": "Every site runs on at least three instances for higher reliability and scale.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "UTM tracking & lead capture",
|
||||||
|
"description": "UTM tracking and lead capture/analysis ship with every client portal.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "SEO, accessibility & LLM-ready",
|
||||||
|
"description": "Optimized SEO, accessibility, performance, and LLM integration on every build.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Grafana metrics & alerts",
|
||||||
|
"description": "Live dashboards with Grafana metrics and proactive alerts on uptime and performance.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
WEB_DESIGN_PRICING_DISCLAIMER = (
|
||||||
|
"Draft catalog pricing. Not a formal quote. "
|
||||||
|
"Third-party usage (Stripe fees, SMS, postage) not included."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_feature_by_id(feature_id):
|
||||||
|
for feature in WEB_DESIGN_FEATURES:
|
||||||
|
if feature["id"] == feature_id:
|
||||||
|
return feature
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_selected_features(selected_ids):
|
||||||
|
"""Expand required bases + dependency chains; return ordered unique ids."""
|
||||||
|
catalog = {f["id"]: f for f in WEB_DESIGN_FEATURES}
|
||||||
|
selected = set(selected_ids or [])
|
||||||
|
|
||||||
|
for feature in WEB_DESIGN_FEATURES:
|
||||||
|
if feature["required"]:
|
||||||
|
selected.add(feature["id"])
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for feature_id in list(selected):
|
||||||
|
feature = catalog.get(feature_id)
|
||||||
|
if not feature:
|
||||||
|
continue
|
||||||
|
for dep in feature.get("requires", ()):
|
||||||
|
if dep not in selected:
|
||||||
|
selected.add(dep)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return [f["id"] for f in WEB_DESIGN_FEATURES if f["id"] in selected]
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_web_design_cost(selected_ids=None):
|
||||||
|
"""Return build/monthly totals for a feature selection."""
|
||||||
|
resolved = resolve_selected_features(selected_ids)
|
||||||
|
catalog = {f["id"]: f for f in WEB_DESIGN_FEATURES}
|
||||||
|
features = [catalog[fid] for fid in resolved]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"selected": [
|
||||||
|
{
|
||||||
|
"id": f["id"],
|
||||||
|
"name": f["name"],
|
||||||
|
"build": f["build"],
|
||||||
|
"monthly": f["monthly"],
|
||||||
|
}
|
||||||
|
for f in features
|
||||||
|
],
|
||||||
|
"selected_count": len(features),
|
||||||
|
"one_time_build": sum(f["build"] for f in features),
|
||||||
|
"monthly": sum(f["monthly"] for f in features),
|
||||||
|
"disclaimer": WEB_DESIGN_PRICING_DISCLAIMER,
|
||||||
|
"included_with_every_site": [
|
||||||
|
{"title": item["title"], "description": item["description"]}
|
||||||
|
for item in WEB_DESIGN_INCLUDED
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def features_for_json():
|
||||||
|
"""JSON-serializable feature list for templates and WebMCP config."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f["id"],
|
||||||
|
"name": f["name"],
|
||||||
|
"description": f["description"],
|
||||||
|
"build": f["build"],
|
||||||
|
"monthly": f["monthly"],
|
||||||
|
"required": f["required"],
|
||||||
|
"requires": list(f.get("requires", ())),
|
||||||
|
"requires_note": f.get("requires_note", ""),
|
||||||
|
}
|
||||||
|
for f in WEB_DESIGN_FEATURES
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user