diff --git a/company_site/company_site/settings/base.py b/company_site/company_site/settings/base.py index cbd095a..829b37d 100644 --- a/company_site/company_site/settings/base.py +++ b/company_site/company_site/settings/base.py @@ -134,6 +134,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "public.middleware.UTMTrackingMiddleware", ] ROOT_URLCONF = "company_site.urls" diff --git a/company_site/docs/agentic-browsing.md b/company_site/docs/agentic-browsing.md index cde1dc1..c2230b0 100644 --- a/company_site/docs/agentic-browsing.md +++ b/company_site/docs/agentic-browsing.md @@ -31,7 +31,7 @@ Test at minimum: 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 - Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment diff --git a/company_site/docs/webmcp.md b/company_site/docs/webmcp.md index 76e297d..18fd7cc 100644 --- a/company_site/docs/webmcp.md +++ b/company_site/docs/webmcp.md @@ -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 | | `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 | +| `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 | +### `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` **Input schema:** diff --git a/company_site/public/admin.py b/company_site/public/admin.py index 2d44384..18ce860 100644 --- a/company_site/public/admin.py +++ b/company_site/public/admin.py @@ -1,20 +1,34 @@ 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.shortcuts import get_object_or_404 +from django.template.loader import get_template 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) class ContactAdmin(admin.ModelAdmin): - list_display = ("email", "name", "contacted") - list_filter = ("email", "name", "contacted") - search_fields = ("email", "name") + list_display = ( + "email", + "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") @@ -25,8 +39,8 @@ def send_emails(modeladmin, request, queryset): from_email = "AI ML Operations, LLC " d = {"title": email.subject, "content": email.body} - html_content = get_template(f"emails/marketing_email.html").render(d) - text_content = get_template(f"emails/marketing_email.txt").render(d) + html_content = get_template("emails/marketing_email.html").render(d) + text_content = get_template("emails/marketing_email.txt").render(d) msg = EmailMultiAlternatives( 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) context = {"title": email_instance.subject, "content": email_instance.body} 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" diff --git a/company_site/public/context_processors.py b/company_site/public/context_processors.py index 265b785..6ce827b 100644 --- a/company_site/public/context_processors.py +++ b/company_site/public/context_processors.py @@ -4,6 +4,11 @@ from django.conf import settings from django.urls import reverse 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): @@ -41,6 +46,15 @@ def webmcp_context(request): } 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 { 'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False), @@ -49,6 +63,7 @@ def webmcp_context(request): 'webmcp_recaptcha_required': not settings.DEBUG, 'webmcp_services_json': json.dumps(services), 'webmcp_pages_json': json.dumps(page_lookup), + 'webmcp_web_design_pricing_json': json.dumps(web_design_pricing), } diff --git a/company_site/public/middleware.py b/company_site/public/middleware.py new file mode 100644 index 0000000..54f03dc --- /dev/null +++ b/company_site/public/middleware.py @@ -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 diff --git a/company_site/public/migrations/0006_pagevisit_and_contact_utm.py b/company_site/public/migrations/0006_pagevisit_and_contact_utm.py new file mode 100644 index 0000000..b5dfbb4 --- /dev/null +++ b/company_site/public/migrations/0006_pagevisit_and_contact_utm.py @@ -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')], + }, + ), + ] diff --git a/company_site/public/migrations/0007_pagevisit_referrer_charfield.py b/company_site/public/migrations/0007_pagevisit_referrer_charfield.py new file mode 100644 index 0000000..7eeeaf0 --- /dev/null +++ b/company_site/public/migrations/0007_pagevisit_referrer_charfield.py @@ -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), + ), + ] diff --git a/company_site/public/models.py b/company_site/public/models.py index 4dcd726..90d2e67 100644 --- a/company_site/public/models.py +++ b/company_site/public/models.py @@ -1,6 +1,9 @@ from django.db import models from django.utils import timezone +from .traffic import TrafficType, traffic_type_label + + class TimeInfoBase(models.Model): created = models.DateTimeField(default=timezone.now) @@ -17,13 +20,19 @@ class TimeInfoBase(models.Model): super().save(*args, **kwargs) -# Create your models here. + class Contact(TimeInfoBase): email = models.EmailField(max_length=128) name = models.CharField(max_length=128) blurb = models.CharField(max_length=254, blank=True) subject = models.CharField(max_length=128) 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): subject = models.CharField(max_length=255) @@ -32,4 +41,53 @@ class EmailMessage(TimeInfoBase): sent = models.BooleanField(default=False) def __str__(self): - return self.recipient + " | " + self.subject \ No newline at end of file + 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}" \ No newline at end of file diff --git a/company_site/public/seo.py b/company_site/public/seo.py index 59f6614..aa4846a 100644 --- a/company_site/public/seo.py +++ b/company_site/public/seo.py @@ -75,7 +75,10 @@ PUBLIC_PAGE_ENTRIES = ( "Web Design and Hosting", "monthly", "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", @@ -127,7 +130,10 @@ def robots_txt(request): sitemap_url = _absolute_url(request, "sitemap_xml") content = render_to_string( "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") @@ -146,18 +152,27 @@ def sitemap_xml(request): def llms_txt(request): + from .web_design_pricing import ( + WEB_DESIGN_INCLUDED, + features_for_json, + ) + pages = [ { "title": title, "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( "public/llms.txt", { "site_url": request.build_absolute_uri("/"), "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, }, ) diff --git a/company_site/public/static/public/css/style.css b/company_site/public/static/public/css/style.css index 1ccf904..8c4dd79 100644 --- a/company_site/public/static/public/css/style.css +++ b/company_site/public/static/public/css/style.css @@ -877,4 +877,215 @@ input:focus, select:focus, textarea:focus { border-radius: 6px; text-transform: none; } -} \ No newline at end of file +} + +/* 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; + } +} diff --git a/company_site/public/static/public/js/webmcp-tools.js b/company_site/public/static/public/js/webmcp-tools.js index 02a6984..166f30a 100644 --- a/company_site/public/static/public/js/webmcp-tools.js +++ b/company_site/public/static/public/js/webmcp-tools.js @@ -16,6 +16,7 @@ var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true'; var services = []; var pages = {}; + var webDesignPricing = { features: [], included_with_every_site: [], disclaimer: '' }; try { services = JSON.parse(configEl.dataset.services || '[]'); @@ -29,6 +30,15 @@ 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) { return { content: [{ @@ -82,6 +92,85 @@ 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() { if (!recaptchaRequired) { 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') { modelContext.registerTool({ name: 'submit_contact_inquiry', diff --git a/company_site/public/templates/base.html b/company_site/public/templates/base.html index c478a32..8843789 100644 --- a/company_site/public/templates/base.html +++ b/company_site/public/templates/base.html @@ -52,6 +52,7 @@ data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}" data-services='{{ webmcp_services_json|escapejs }}' data-pages='{{ webmcp_pages_json|escapejs }}' + data-web-design-pricing='{{ webmcp_web_design_pricing_json|escapejs }}' hidden> {% endif %} @@ -109,7 +110,12 @@
  • Profile
  • {% endif %} {% if is_financial_admin %} +
  • UTM Analytics
  • +
  • Leads
  • Manage Users
  • + {% elif user.is_staff %} +
  • UTM Analytics
  • +
  • Leads
  • {% endif %}
  • Change Password
  • diff --git a/company_site/public/templates/public/lead_detail.html b/company_site/public/templates/public/lead_detail.html new file mode 100644 index 0000000..fb98680 --- /dev/null +++ b/company_site/public/templates/public/lead_detail.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Lead: {{ lead.name }}{% endblock %} + +{% block content %} +
    +
    +

    + ← All leads +

    + +
    +
    +

    {{ lead.name }}

    +

    + {{ lead.created|date:"F j, Y g:i A" }} + · + {% if lead.contacted %} + Contacted + {% else %} + New + {% endif %} +

    +
    +
    + {% csrf_token %} + + +
    +
    + + {% if messages %} +
      + {% for message in messages %} +
    • {{ message }}
    • + {% endfor %} +
    + {% endif %} + +
    + Contact +

    + Email: {{ lead.email }}
    + Subject: {{ lead.subject }} +

    +
    + +
    + Message +

    {% if lead.blurb %}{{ lead.blurb }}{% else %}(No message body){% endif %}

    +
    + +
    + UTM attribution + {% if lead.utm_source or lead.utm_medium or lead.utm_campaign or lead.utm_term or lead.utm_content %} +
    + + + + + + + + +
    Source{{ lead.utm_source|default:"—" }}
    Medium{{ lead.utm_medium|default:"—" }}
    Campaign{{ lead.utm_campaign|default:"—" }}
    Term{{ lead.utm_term|default:"—" }}
    Content{{ lead.utm_content|default:"—" }}
    +
    + {% else %} +

    No UTM params on this lead’s session.

    + {% endif %} +
    + +

    + Last modified {{ lead.last_modified|date:"Y-m-d H:i" }} · ID {{ lead.pk }} +

    +
    +
    +{% endblock %} diff --git a/company_site/public/templates/public/leads_list.html b/company_site/public/templates/public/leads_list.html new file mode 100644 index 0000000..e14f912 --- /dev/null +++ b/company_site/public/templates/public/leads_list.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Contact Leads{% endblock %} + +{% block content %} +
    +
    +
    +

    Contact Leads

    + UTM Analytics → +
    +

    + Inquiries from the contact form, with message details and any UTM attribution. Staff only. +

    + +
    + + + + + +
    + +
    +
    + {{ total }} +

    Total in period

    +
    +
    + {{ new_count }} +

    New (not contacted)

    +
    +
    + {{ contacted_count }} +

    Contacted

    +
    +
    + {{ with_utm }} +

    With UTM attribution

    +
    +
    + +

    + Showing {{ lead_count }} lead{% if lead_count != 1 %}s{% endif %}{% if lead_count > 200 %} (first 200){% endif %}. +

    + +
    + {% if leads %} + + + + + + + + + + + + + + + {% for lead in leads %} + + + + + + + + + + + {% endfor %} + +
    WhenNameEmailSubjectStatusSourceCampaign
    {{ lead.created|date:"Y-m-d H:i" }}{{ lead.name }}{{ lead.email }}{{ lead.subject|truncatechars:40 }}{% if lead.contacted %}Contacted{% else %}New{% endif %}{{ lead.utm_source|default:"—" }}{{ lead.utm_campaign|default:"—" }}Details
    + {% else %} +

    No leads match these filters.

    + {% endif %} +
    +
    +
    +{% endblock %} diff --git a/company_site/public/templates/public/llms.txt b/company_site/public/templates/public/llms.txt index 8566257..91b0576 100644 --- a/company_site/public/templates/public/llms.txt +++ b/company_site/public/templates/public/llms.txt @@ -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. -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 -{% for page in pages %}- [{{ page.title }}]({{ page.url }}) +{% for page in pages %}- [{{ page.title }}]({{ page.url }}){% if page.summary %} — {{ page.summary }}{% endif %} {% 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 form]({{ contact_url }}) diff --git a/company_site/public/templates/public/new_index.html b/company_site/public/templates/public/new_index.html index 62b5b3a..a3e6a45 100644 --- a/company_site/public/templates/public/new_index.html +++ b/company_site/public/templates/public/new_index.html @@ -186,7 +186,7 @@ data-tianji-event="service_click" data-tianji-event-service="Web Design"> Primary Web Design and Hosting -

    Modern websites with reliable hosting for your online presence.

    +

    Brand-tailored sites you own, with multi-instance hosting, UTM/leads, Grafana alerts, and an interactive package estimator.

    +
    +

    UTM & Traffic Analytics

    +

    + First-party page views with campaign tags and traffic classification + (human, AI bots, search indexers, and more). Staff only. +

    + +
    + + + +
    + +
    +
    + {{ total_visits }} +

    Total page views

    +
    +
    + {{ human_count }} +

    Human traffic

    +
    +
    + {{ ai_count }} +

    AI bot / AI search

    +
    +
    + {{ indexer_count }} +

    Search indexing

    +
    +
    + {{ utm_landings }} +

    UTM landings

    +
    +
    + {{ attributed_visits }} +

    Views with UTM attribution

    +
    +
    + +

    Traffic mix

    +
    + {% if type_counts %} + + + + + + + + + + {% for row in type_counts %} + + + + + + {% endfor %} + +
    TypeViewsShare
    {{ row.label }}{{ row.total }}{% widthratio row.total total_visits 100 %}%
    + {% else %} +

    No visits in this period yet.

    + {% endif %} +
    + +
    +
    +

    Top sources

    +
    + {% if top_sources %} + + + + {% for row in top_sources %} + + {% endfor %} + +
    utm_sourceViews
    {{ row.utm_source }}{{ row.total }}
    + {% else %} +

    No UTM sources yet. Share links like ?utm_source=linkedin&utm_medium=social&utm_campaign=spring.

    + {% endif %} +
    +
    +
    +

    Top mediums

    +
    + {% if top_mediums %} + + + + {% for row in top_mediums %} + + {% endfor %} + +
    utm_mediumViews
    {{ row.utm_medium }}{{ row.total }}
    + {% else %} +

    No UTM mediums yet.

    + {% endif %} +
    +
    +
    +

    Top campaigns

    +
    + {% if top_campaigns %} + + + + {% for row in top_campaigns %} + + {% endfor %} + +
    utm_campaignViews
    {{ row.utm_campaign }}{{ row.total }}
    + {% else %} +

    No UTM campaigns yet.

    + {% endif %} +
    +
    +
    + +

    Top pages{% if traffic_filter %} (filtered){% endif %}

    +
    + {% if top_pages %} + + + + + + {% for row in top_pages %} + + {% endfor %} + +
    PathViews
    {{ row.path }}{{ row.total }}
    + {% else %} +

    No page data for this filter.

    + {% endif %} +
    + + {% if contacts_with_utm %} +
    +

    Contact leads with UTM

    +
    All leads → +
    +
    + + + + + + + + + + + + + + {% for c in contacts_with_utm %} + + + + + + + + + + {% endfor %} + +
    WhenNameEmailSourceMediumCampaign
    {{ c.created|date:"Y-m-d H:i" }}{{ c.name }}{{ c.email }}{{ c.utm_source|default:"—" }}{{ c.utm_medium|default:"—" }}{{ c.utm_campaign|default:"—" }}Details
    +
    + {% endif %} + +

    Recent visits

    +
    + {% if recent_visits %} + + + + + + + + + + + + + + + {% for v in recent_visits %} + + + + + + + + + + + {% endfor %} + +
    WhenPathTypeSourceMediumCampaignLandingReferrer
    {{ v.created|date:"Y-m-d H:i" }}{{ v.path }}{{ v.get_traffic_type_display }}{{ v.utm_source|default:"—" }}{{ v.utm_medium|default:"—" }}{{ v.utm_campaign|default:"—" }}{% if v.is_landing %}Yes{% else %}—{% endif %}{{ v.referrer|default:"—"|truncatechars:40 }}
    + {% else %} +

    No visits recorded yet. Browse the public site (optionally with UTM query params) and refresh this page.

    + {% endif %} +
    +
    + +{% endblock %} diff --git a/company_site/public/templates/public/web_design.html b/company_site/public/templates/public/web_design.html index 558f512..053d00d 100644 --- a/company_site/public/templates/public/web_design.html +++ b/company_site/public/templates/public/web_design.html @@ -2,54 +2,51 @@ {% load static %} {% 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 -visually stunning, functional websites tailored to your business.{% endblock %} +{% 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 %} +{% 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 %}

    Web Design & Hosting

    -

    Crafting Beautiful, Functional Websites with Reliable Hosting

    +

    Crafted sites with reliable multi-instance hosting and built-in growth tools

    - +

    About Our Web Design & Hosting Service

    - At AI ML Operations, we specialize in creating visually stunning, highly functional websites tailored to your - business needs. From design to deployment, we handle every aspect of your online presence. Our reliable hosting - solutions ensure your website is always fast, secure, and accessible. Whether you need a simple portfolio site or - a complex e-commerce platform, we’ve got you covered. + At AI ML Operations, we design and host custom websites tailored to your brand and how your + customers use the site. You own the site and data — leave anytime and take everything with you. + Every project ships with three-instance hosting, UTM tracking and lead capture, optimized SEO, + accessibility, performance, LLM integration, and Grafana metrics and alerts.

    - +
    -

    What We Offer

    +

    Included With Every Site

    + {% for item in pricing_included %}
    -
    Custom Web Design
    -

    Tailored designs that reflect your brand and engage your audience.

    -
    -
    -
    Reliable Hosting
    -

    Fast, secure, and scalable hosting solutions for your website.

    -
    -
    -
    Ongoing Support
    -

    Continuous maintenance and support to keep your site running smoothly.

    +
    {{ item.title }}
    +

    {{ item.description }}

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

    Web Hosting Plans

    +

    Build Your Package

    +

    + 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. +

    - -
    -
    - Monthly - - Yearly -
    -
    - - -
    - -
    - Standard Plan -

    $10

    -

    per month

    -
      -
    • ✓ Web Hosting
    • -
    • ✓ Weekly Backups
    • -
    • ✓ SSL Certificate
    • -
    • ✓ CAPTCHA Protection
    • -
    • ✓ Email Notifications
    • -
    • ✓ Backend Admin Access
    • +
      +
      +

      Features

      +
        + {% for feature in pricing_features %} +
      • + +
      • + {% endfor %}
      - Get Started
      - -
      - Premium Plan -

      $15

      -

      per month

      -
        -
      • ✓ All Standard Features
      • -
      • ✓ Monthly Analytics Reports
      • -
      • ✓ Site Optimization Reports
      • -
      • ✓ HTML Marketing Emails
      • -
      • ✓ 2 Months Free (Yearly)
      • -
      • ✓ Priority Support
      • -
      - Save 20% -
      +
    - +

    Ready to Build Your Online Presence?

    -

    Contact us today to get started on your website project.

    - Get Started +

    + Tell us which features you need — we will turn this estimate into a tailored proposal. +

    + + Contact us +
    -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/company_site/public/tests.py b/company_site/public/tests.py index 014c93f..b501fd4 100644 --- a/company_site/public/tests.py +++ b/company_site/public/tests.py @@ -6,8 +6,9 @@ from django.urls import reverse 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 .traffic import TrafficType, classify_user_agent class CsrfTrustedOriginsTests(TestCase): @@ -223,6 +224,7 @@ class AgenticBrowsingSeoTests(TestCase): self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8") self.assertContains(response, "User-agent: *") self.assertContains(response, "Sitemap:") + self.assertContains(response, "llms.txt") def test_sitemap_xml_lists_public_pages(self): response = self.client.get(reverse("sitemap_xml")) @@ -241,6 +243,14 @@ class AgenticBrowsingSeoTests(TestCase): self.assertContains(response, "# AI ML Operations, LLC") self.assertContains(response, "## Key pages") 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): response = self.client.get(reverse("public_index")) @@ -313,6 +323,7 @@ class WebMcpTests(TestCase): "get_page_content", "navigate_to_service", "open_contact_with_subject", + "estimate_web_design_cost", "submit_contact_inquiry", ): self.assertIn("name: '" + tool_name + "'", script) @@ -320,3 +331,180 @@ class WebMcpTests(TestCase): self.assertIn("readOnlyHint: true", script) self.assertIn("readOnlyHint: false", 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) diff --git a/company_site/public/traffic.py b/company_site/public/traffic.py new file mode 100644 index 0000000..0bccf20 --- /dev/null +++ b/company_site/public/traffic.py @@ -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 diff --git a/company_site/public/urls.py b/company_site/public/urls.py index 1e54c84..124adee 100755 --- a/company_site/public/urls.py +++ b/company_site/public/urls.py @@ -18,6 +18,14 @@ urlpatterns = [ path("ml_model", views.ml_model, name="ml_model"), path("contact", views.contact, name="contact"), 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//", views.lead_detail, name="lead_detail"), + path( + "leads//toggle-contacted/", + views.lead_toggle_contacted, + name="lead_toggle_contacted", + ), path("change_password", views.change_password, name="change_password"), path("preview_email//", views.preview_email, name="preview_email") ] diff --git a/company_site/public/views.py b/company_site/public/views.py index 3775866..4e7cb47 100755 --- a/company_site/public/views.py +++ b/company_site/public/views.py @@ -1,16 +1,23 @@ -from django.shortcuts import render, get_object_or_404, redirect +from datetime import timedelta -from django.http import HttpResponse -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.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth import update_session_auth_hash 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): subject = "New Contact Request for AI ML Operations, LLC" @@ -38,7 +45,23 @@ def computers(request): return render(request, "public/computers.html", {}) 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): return render(request, "public/ai_sensor.html", {}) @@ -85,7 +108,18 @@ def contact(request): # 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() # send the email. try: @@ -127,4 +161,184 @@ def change_password(request): return render(request, 'public/change_password.html', { '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) + diff --git a/company_site/public/web_design_pricing.py b/company_site/public/web_design_pricing.py new file mode 100644 index 0000000..01cf46d --- /dev/null +++ b/company_site/public/web_design_pricing.py @@ -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 + ]