Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from django.contrib import admin # noqa: F401
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PublicConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "public"
|
||||
@@ -0,0 +1,66 @@
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _phone_tel(phone: str) -> str:
|
||||
digits = re.sub(r"[^\d+]", "", phone or "")
|
||||
return digits
|
||||
|
||||
|
||||
def site_branding(_request):
|
||||
phone = settings.CONTACT_PHONE or ""
|
||||
return {
|
||||
"SITE_NAME": settings.SITE_NAME,
|
||||
"SITE_TAGLINE": settings.SITE_TAGLINE,
|
||||
"CONTACT_PHONE": phone,
|
||||
"CONTACT_PHONE_TEL": _phone_tel(phone),
|
||||
"CONTACT_EMAIL": settings.CONTACT_EMAIL or "",
|
||||
"CONTACT_SERVICE_AREA": getattr(
|
||||
settings, "CONTACT_SERVICE_AREA", "Serving Greater Metro Area"
|
||||
),
|
||||
"CREDIT_NAME": getattr(settings, "CREDIT_NAME", "AI ML Operations, LLC"),
|
||||
"CREDIT_URL": getattr(
|
||||
settings, "CREDIT_URL", "https://aimloperations.com"
|
||||
),
|
||||
"SITE_UNDER_CONSTRUCTION": settings.SITE_UNDER_CONSTRUCTION,
|
||||
}
|
||||
|
||||
|
||||
def tianji_tracking(request):
|
||||
match = getattr(request, "resolver_match", None)
|
||||
url_name = getattr(match, "url_name", "") or ""
|
||||
namespace = getattr(match, "namespace", "") or ""
|
||||
full = f"{namespace}:{url_name}" if namespace else url_name
|
||||
|
||||
nav_section = ""
|
||||
if full == "dashboard:home":
|
||||
nav_section = "dashboard"
|
||||
elif namespace == "leads":
|
||||
nav_section = "leads"
|
||||
elif full == "analytics:report":
|
||||
nav_section = "analytics"
|
||||
elif namespace == "contacts":
|
||||
nav_section = "contacts"
|
||||
elif full == "messaging:postcard_designer":
|
||||
nav_section = "postcard"
|
||||
elif namespace == "messaging":
|
||||
nav_section = "campaigns"
|
||||
elif full == "social:account_list":
|
||||
nav_section = "social_accounts"
|
||||
elif namespace == "social":
|
||||
nav_section = "social"
|
||||
|
||||
return {
|
||||
"tianji_enabled": getattr(settings, "TIANJI_ENABLED", False)
|
||||
and bool(getattr(settings, "TIANJI_WEBSITE_ID", ""))
|
||||
and bool(getattr(settings, "TIANJI_TRACKER_URL", "")),
|
||||
"tianji_tracker_url": getattr(
|
||||
settings,
|
||||
"TIANJI_TRACKER_URL",
|
||||
"https://tianji.aimloperations.com/tracker.js",
|
||||
),
|
||||
"tianji_website_id": getattr(settings, "TIANJI_WEBSITE_ID", ""),
|
||||
"page_name": url_name,
|
||||
"nav_section": nav_section,
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
INTEREST_CHOICES = [
|
||||
("buying", "Buying a home"),
|
||||
("selling", "Selling a home"),
|
||||
("both", "Both / not sure yet"),
|
||||
("other", "Something else"),
|
||||
]
|
||||
|
||||
first_name = forms.CharField(
|
||||
max_length=100,
|
||||
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-first-name"}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
max_length=100,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-last-name"}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(attrs={"class": "form-input", "id": "contact-email"}),
|
||||
)
|
||||
phone = forms.CharField(
|
||||
max_length=32,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={"class": "form-input", "id": "contact-phone"}),
|
||||
)
|
||||
address_line1 = forms.CharField(
|
||||
max_length=200,
|
||||
required=False,
|
||||
label="Street address",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-input",
|
||||
"id": "contact-address-line1",
|
||||
"autocomplete": "off",
|
||||
"data-ac": "line1",
|
||||
}
|
||||
),
|
||||
)
|
||||
address_line2 = forms.CharField(
|
||||
max_length=200,
|
||||
required=False,
|
||||
label="Apt / suite",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-input",
|
||||
"id": "contact-address-line2",
|
||||
"autocomplete": "address-line2",
|
||||
"data-ac": "line2",
|
||||
}
|
||||
),
|
||||
)
|
||||
address_city = forms.CharField(
|
||||
max_length=100,
|
||||
required=False,
|
||||
label="City",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-input",
|
||||
"id": "contact-address-city",
|
||||
"autocomplete": "address-level2",
|
||||
"data-ac": "city",
|
||||
}
|
||||
),
|
||||
)
|
||||
address_state = forms.CharField(
|
||||
max_length=32,
|
||||
required=False,
|
||||
label="State",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-input",
|
||||
"id": "contact-address-state",
|
||||
"autocomplete": "address-level1",
|
||||
"data-ac": "state",
|
||||
}
|
||||
),
|
||||
)
|
||||
address_zip = forms.CharField(
|
||||
max_length=20,
|
||||
required=False,
|
||||
label="ZIP",
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"class": "form-input",
|
||||
"id": "contact-address-zip",
|
||||
"autocomplete": "postal-code",
|
||||
"data-ac": "zip",
|
||||
}
|
||||
),
|
||||
)
|
||||
interest = forms.ChoiceField(
|
||||
choices=INTEREST_CHOICES,
|
||||
widget=forms.Select(attrs={"class": "form-input", "id": "contact-interest"}),
|
||||
)
|
||||
message = forms.CharField(
|
||||
widget=forms.Textarea(attrs={"class": "form-input", "id": "contact-message"}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if settings.RECAPTCHA_PUBLIC_KEY and settings.RECAPTCHA_PRIVATE_KEY:
|
||||
from django_recaptcha.fields import ReCaptchaField
|
||||
from django_recaptcha.widgets import ReCaptchaV3
|
||||
|
||||
self.fields["captcha"] = ReCaptchaField(widget=ReCaptchaV3)
|
||||
|
||||
|
||||
class NotifyForm(forms.Form):
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(attrs={"class": "form-input"}),
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
class UnderConstructionMiddleware:
|
||||
"""
|
||||
When SITE_UNDER_CONSTRUCTION is true, redirect public traffic to the holding page.
|
||||
|
||||
Exempt: health, static/media, admin, auth login/logout, SMS webhook, and the
|
||||
under-construction page itself (so notify-me POSTs work).
|
||||
"""
|
||||
|
||||
EXEMPT_PREFIXES = (
|
||||
"/healthz",
|
||||
"/static/",
|
||||
"/media/",
|
||||
"/admin/",
|
||||
"/accounts/login",
|
||||
"/accounts/logout",
|
||||
"/under-construction",
|
||||
"/portal/messaging/webhooks/",
|
||||
"/unsubscribe/",
|
||||
"/api/address-suggest",
|
||||
)
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if settings.SITE_UNDER_CONSTRUCTION and not self._is_exempt(request):
|
||||
return redirect("public:under_construction")
|
||||
return self.get_response(request)
|
||||
|
||||
def _is_exempt(self, request) -> bool:
|
||||
path = request.path
|
||||
if any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES):
|
||||
return True
|
||||
user = getattr(request, "user", None)
|
||||
if (
|
||||
user is not None
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and getattr(user, "is_staff", False)
|
||||
and path.startswith("/portal/")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}404 · {{ SITE_NAME }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<section class="section section-single bg-gray-800 primary-overlay" style="background-image: url({% static 'images/bg-image-7.jpg' %});">
|
||||
<div class="section-single-inner">
|
||||
<div class="section-single-dummy"></div>
|
||||
<div class="section-single-main">
|
||||
<div class="container">
|
||||
<div class="row row-30">
|
||||
<div class="col-sm-6 text-center text-sm-left">
|
||||
<p class="text-extra-large">404</p>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="section-single-main-content">
|
||||
<h6 class="title-decorated title-decorated-lg">Page not found</h6>
|
||||
<p><span class="text-width-2">That page may have moved or the link is outdated. Let’s get you back on track.</span></p>
|
||||
<a class="button button-lg button-primary button-winona" href="{% url 'public:home' %}">Back to home</a>
|
||||
<a class="button button-lg button-default-outline button-winona" href="{% url 'public:contact' %}" style="margin-left:8px">Contact Monica</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-minimal section-single-footer">
|
||||
<div class="container">
|
||||
<div class="footer-minimal-inner">
|
||||
<a class="brand" href="{% url 'public:home' %}">
|
||||
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty · {{ SITE_NAME }}" width="151" height="44"/>
|
||||
</a>
|
||||
<div class="footer-aside-copy" style="text-align:left">
|
||||
<p class="rights">
|
||||
<span>© </span><span>{% now "Y" %}</span><span> </span><span>{{ SITE_NAME }} · MKDRealtor.com</span>
|
||||
</p>
|
||||
<p class="footer-made-by">
|
||||
Made by <a href="{{ CREDIT_URL }}" target="_blank" rel="noopener noreferrer">{{ CREDIT_NAME }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}About · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
|
||||
<div class="breadcrumbs-custom-inner">
|
||||
<div class="container breadcrumbs-custom-container">
|
||||
<div class="breadcrumbs-custom-main">
|
||||
<h6 class="breadcrumbs-custom-subtitle title-decorated">About</h6>
|
||||
<h2 class="text-uppercase breadcrumbs-custom-title">Meet Monica</h2>
|
||||
</div>
|
||||
<ul class="breadcrumbs-custom-path">
|
||||
<li><a href="{% url 'public:home' %}">Home</a></li>
|
||||
<li class="active">About</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<div class="row row-50 justify-content-center justify-content-lg-between flex-lg-row-reverse">
|
||||
<div class="col-md-10 col-lg-6 col-xl-5">
|
||||
<h3 class="text-uppercase">Local realtor,<br>personal service</h3>
|
||||
<p class="about-subtitle">I help families buy and sell homes with clear communication and marketing that actually reaches people — not just another postcard blast.</p>
|
||||
<p>This site replaces a costly third-party campaign platform. Visitors reach me through a protected contact form; every inquiry lands in my portal with source tracking so I know which campaigns work.</p>
|
||||
<a class="button button-lg button-primary button-winona" href="{% url 'public:contact' %}" data-tianji-event="about_cta" data-tianji-event-destination="contact">Work with me</a>
|
||||
</div>
|
||||
<div class="col-md-10 col-lg-6 col-xl-6">
|
||||
<img class="img-responsive" src="{% static 'images/careers-1-570x388.jpg' %}" alt="{{ SITE_NAME }}" width="570" height="388"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,163 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Contact · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="breadcrumbs-custom bg-image context-dark" style="background-image: url({% static 'images/breadcrumbs-image-1.jpg' %});">
|
||||
<div class="breadcrumbs-custom-inner">
|
||||
<div class="container breadcrumbs-custom-container">
|
||||
<div class="breadcrumbs-custom-main">
|
||||
<h6 class="breadcrumbs-custom-subtitle title-decorated">Contact</h6>
|
||||
<h2 class="text-uppercase breadcrumbs-custom-title">Get in touch</h2>
|
||||
</div>
|
||||
<ul class="breadcrumbs-custom-path">
|
||||
<li><a href="{% url 'public:home' %}">Home</a></li>
|
||||
<li class="active">Contact</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-sm">
|
||||
<div class="container">
|
||||
<div class="layout-bordered">
|
||||
{% if CONTACT_PHONE %}
|
||||
<div class="layout-bordered-item wow-outer">
|
||||
<div class="layout-bordered-item-inner wow slideInUp">
|
||||
<div class="icon icon-lg mdi mdi-phone text-primary"></div>
|
||||
<ul class="list-0"><li><a class="link-default" href="tel:{{ CONTACT_PHONE_TEL }}">{{ CONTACT_PHONE }}</a></li></ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if CONTACT_EMAIL %}
|
||||
<div class="layout-bordered-item wow-outer">
|
||||
<div class="layout-bordered-item-inner wow slideInUp">
|
||||
<div class="icon icon-lg mdi mdi-email text-primary"></div>
|
||||
<a class="link-default" href="mailto:{{ CONTACT_EMAIL }}">{{ CONTACT_EMAIL }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="layout-bordered-item wow-outer">
|
||||
<div class="layout-bordered-item-inner wow slideInUp">
|
||||
<div class="icon icon-lg mdi mdi-map-marker text-primary"></div>
|
||||
<span class="link-default">{{ CONTACT_SERVICE_AREA }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section bg-gray-100">
|
||||
<div class="container section-lg">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<h3 class="text-uppercase">Send a message</h3>
|
||||
<form class="rd-form" method="post" action="{% url 'public:contact' %}">
|
||||
{% csrf_token %}
|
||||
<div class="row row-10">
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.first_name.id_for_label }}">First name</label>
|
||||
{{ form.first_name }}
|
||||
{{ form.first_name.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.last_name.id_for_label }}">Last name</label>
|
||||
{{ form.last_name }}
|
||||
{{ form.last_name.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.email.id_for_label }}">Email</label>
|
||||
{{ form.email }}
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.phone.id_for_label }}">Phone</label>
|
||||
{{ form.phone }}
|
||||
{{ form.phone.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="form-label-outside" style="margin:8px 0 4px">Mailing address <span style="font-weight:400;color:#6b7280">(optional — for postcards)</span></p>
|
||||
</div>
|
||||
<div class="col-12" data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||
<div class="form-wrap address-ac-wrap">
|
||||
<label class="form-label-outside" for="{{ form.address_line1.id_for_label }}">Street address</label>
|
||||
{{ form.address_line1 }}
|
||||
{{ form.address_line1.errors }}
|
||||
</div>
|
||||
<div class="row row-10">
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.address_line2.id_for_label }}">Apt / suite</label>
|
||||
{{ form.address_line2 }}
|
||||
{{ form.address_line2.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.address_city.id_for_label }}">City</label>
|
||||
{{ form.address_city }}
|
||||
{{ form.address_city.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.address_state.id_for_label }}">State</label>
|
||||
{{ form.address_state }}
|
||||
{{ form.address_state.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.address_zip.id_for_label }}">ZIP</label>
|
||||
{{ form.address_zip }}
|
||||
{{ form.address_zip.errors }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.interest.id_for_label }}">I am interested in</label>
|
||||
{{ form.interest }}
|
||||
{{ form.interest.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-wrap">
|
||||
<label class="form-label-outside" for="{{ form.message.id_for_label }}">Message</label>
|
||||
{{ form.message }}
|
||||
{{ form.message.errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.captcha %}
|
||||
<div class="col-12">{{ form.captcha }}{{ form.captcha.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="font-size:13px;color:#6b7280;margin:12px 0 20px;">By submitting, you agree I may contact you about your inquiry by email and SMS (if you provide a phone number). You can change preferences or unsubscribe anytime from links in messages, or reply STOP to SMS.{% if form.captcha %} Protected by reCAPTCHA.{% endif %}</p>
|
||||
<button class="button button-primary button-winona" type="submit" data-tianji-event="contact_form_submit">Send message</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||
{% endblock %}
|
||||
{% block tracking_events %}
|
||||
{% if "sent" in request.GET %}
|
||||
<script>
|
||||
if (window.aimlTrackWhenReady) {
|
||||
window.aimlTrackWhenReady('contact_form_success');
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Home · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="swiper-container swiper-slider swiper-slider-minimal" data-loop="true" data-slide-effect="fade" data-autoplay="false" data-simulate-touch="true">
|
||||
<div class="swiper-wrapper">
|
||||
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-1-1920x968.jpg' %}">
|
||||
<div class="container">
|
||||
<div class="jumbotron-classic-content">
|
||||
<div class="wow-outer">
|
||||
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">{{ SITE_NAME }} · MKDRealtor.com</div>
|
||||
</div>
|
||||
<h1 class="text-uppercase text-white font-weight-bold wow-outer"><span class="wow slideInDown" data-wow-delay=".2s">Your Home,<br>Guided Well</span></h1>
|
||||
<p class="text-white wow-outer"><span class="wow slideInDown" data-wow-delay=".35s">EXIT Realty Redefined — buying or selling in the metro area with clear advice, strong marketing, and a team that answers the phone.</span></p>
|
||||
<div class="wow-outer button-outer">
|
||||
<a class="button button-md button-primary button-winona wow slideInDown" href="{% url 'public:contact' %}" data-wow-delay=".4s" data-tianji-event="hero_cta" data-tianji-event-destination="contact">Start a conversation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swiper-slide" data-slide-bg="{% static 'images/slider-minimal-slide-2-1920x968.jpg' %}">
|
||||
<div class="container">
|
||||
<div class="jumbotron-classic-content">
|
||||
<div class="wow-outer">
|
||||
<div class="title-docor-text font-weight-bold title-decorated text-uppercase wow slideInLeft text-white">Local Market Insight</div>
|
||||
</div>
|
||||
<h1 class="text-uppercase text-white font-weight-bold wow-outer"><span class="wow slideInDown" data-wow-delay=".2s">Sell With<br>Confidence</span></h1>
|
||||
<p class="text-white wow-outer"><span class="wow slideInDown" data-wow-delay=".35s">Pricing strategy, staging guidance, and multi-channel outreach so the right buyers see your listing.</span></p>
|
||||
<div class="wow-outer button-outer">
|
||||
<a class="button button-md button-primary button-winona wow slideInDown" href="{% url 'public:about' %}" data-wow-delay=".4s" data-tianji-event="hero_about">Meet Monica</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swiper-pagination-outer container">
|
||||
<div class="swiper-pagination swiper-pagination-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="section section-lg">
|
||||
<div class="container">
|
||||
<h3 class="text-uppercase text-center wow-outer"><span class="wow slideInDown">How I can help</span></h3>
|
||||
<div class="row row-50">
|
||||
<div class="col-md-4 wow-outer">
|
||||
<article class="box-chloe wow slideInUp">
|
||||
<div class="box-chloe__icon linearicons-home-icon3"></div>
|
||||
<div class="box-chloe__main">
|
||||
<h4 class="box-chloe__title">Buyers</h4>
|
||||
<p>Neighborhood tours, offer strategy, and negotiation that protects your timeline and budget.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4 wow-outer">
|
||||
<article class="box-chloe wow slideInUp" data-wow-delay=".05s">
|
||||
<div class="box-chloe__icon linearicons-apartment"></div>
|
||||
<div class="box-chloe__main">
|
||||
<h4 class="box-chloe__title">Sellers</h4>
|
||||
<p>Listing prep, professional marketing, and outreach across email, SMS, postcard, and social.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4 wow-outer">
|
||||
<article class="box-chloe wow slideInUp" data-wow-delay=".1s">
|
||||
<div class="box-chloe__icon linearicons-chart-growth"></div>
|
||||
<div class="box-chloe__main">
|
||||
<h4 class="box-chloe__title">Market guidance</h4>
|
||||
<p>Honest comps and timing advice — whether you move this season or plan ahead.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section section-lg bg-gray-100 text-center">
|
||||
<div class="container">
|
||||
<h3 class="text-uppercase wow-outer"><span class="wow slideInUp">Ready when you are</span></h3>
|
||||
<p class="wow-outer"><span class="text-width-1 wow slideInDown">Tell me what you are looking for. I will follow up personally — your message becomes a lead in my private portal.</span></p>
|
||||
<a class="button button-lg button-primary button-winona" href="{% url 'public:contact' %}" data-tianji-event="home_cta_contact">Contact Monica</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Under construction · {{ SITE_NAME }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="page">
|
||||
<section class="section section-single bg-gray-800 primary-overlay" style="background-image: url({% static 'images/bg-image-4.jpg' %});">
|
||||
<div class="section-single-inner">
|
||||
<div class="section-single-dummy"></div>
|
||||
<div class="section-single-main">
|
||||
<div class="container">
|
||||
<div class="row row-30 justify-content-center justify-content-sm-start">
|
||||
<div class="col-sm-10 col-md-9 col-lg-7 col-xl-6">
|
||||
<h6 class="title-decorated title-decorated-lg">We’re getting ready to launch</h6>
|
||||
<p>{{ SITE_NAME }} · MKDRealtor.com’s new site is almost here. Leave your email and I’ll let you know when it’s live — or reach out now if you need help buying or selling.</p>
|
||||
<div class="rd-mailform-wrap">
|
||||
<form class="rd-form form-inline" method="post" action="{% url 'public:under_construction' %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-wrap">
|
||||
<input class="form-input" id="{{ form.email.id_for_label }}" type="email" name="{{ form.email.name }}" required value="{{ form.email.value|default:'' }}">
|
||||
<label class="form-label" for="{{ form.email.id_for_label }}">Your e-mail</label>
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
<div class="form-button">
|
||||
<button class="button button-primary button-winona" type="submit" data-tianji-event="notify_me_submit">Notify me</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p style="margin-top:20px">
|
||||
<a class="button button-default-outline button-winona" href="{% url 'public:contact' %}" data-tianji-event="holding_contact">Contact Monica now</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-minimal section-single-footer">
|
||||
<div class="container">
|
||||
<div class="footer-minimal-inner">
|
||||
<a class="brand" href="{% url 'public:home' %}">
|
||||
<img src="{% static 'brand/exit_logo.png' %}" alt="EXIT Realty · {{ SITE_NAME }}" width="151" height="44"/>
|
||||
</a>
|
||||
<div class="footer-aside-copy" style="text-align:left">
|
||||
<p class="rights">
|
||||
<span>© </span><span>{% now "Y" %}</span><span> </span><span>{{ SITE_NAME }} · MKDRealtor.com</span>
|
||||
{% if CONTACT_PHONE %}<span> · </span><a href="tel:{{ CONTACT_PHONE_TEL }}">{{ CONTACT_PHONE }}</a>{% endif %}
|
||||
</p>
|
||||
<p class="footer-made-by">
|
||||
Made by <a href="{{ CREDIT_URL }}" target="_blank" rel="noopener noreferrer">{{ CREDIT_NAME }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Communication preferences · {{ SITE_NAME }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="section section-lg">
|
||||
<div class="container" style="max-width:560px;">
|
||||
<h3 class="text-uppercase">Communication preferences</h3>
|
||||
{% if not valid %}
|
||||
<p>This preferences link is invalid or has expired.</p>
|
||||
<p style="font-size:13px;color:#6b7280;">If you still get messages, reply STOP on SMS or contact Monica directly.</p>
|
||||
<p style="margin-top:24px">
|
||||
<a class="button button-primary button-winona" href="{% url 'public:contact' %}">Contact Monica</a>
|
||||
<a class="button button-gray-bordered button-winona" href="{% url 'public:home' %}">Back to home</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p>You are subscribed as <strong>{{ identity }}</strong>.</p>
|
||||
<form method="post" action="{% url 'public:unsubscribe' token=token %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-wrap">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" name="consent_email" value="1" {% if prefs.email %}checked{% endif %}>
|
||||
Email marketing
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-wrap">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" name="consent_sms" value="1" {% if prefs.sms %}checked{% endif %}>
|
||||
SMS updates
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-wrap">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}>
|
||||
Postcard mailings
|
||||
</label>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#6b7280;margin-top:16px;">
|
||||
SMS: reply <strong>STOP</strong> anytime. Changes take effect immediately.
|
||||
Postcard mailings also need a postal address on file.
|
||||
</p>
|
||||
<div style="margin-top:24px;display:flex;flex-wrap:wrap;gap:12px;">
|
||||
<button class="button button-primary button-winona" type="submit" name="action" value="save">
|
||||
Save preferences
|
||||
</button>
|
||||
<button class="button button-gray-bordered button-winona" type="submit" name="action" value="unsubscribe_all">
|
||||
Unsubscribe from all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p style="margin-top:32px">
|
||||
<a class="button button-gray-bordered button-winona" href="{% url 'public:home' %}">Back to home</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from public import views
|
||||
|
||||
app_name = "public"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("about/", views.about, name="about"),
|
||||
path("contact/", views.contact, name="contact"),
|
||||
path("under-construction/", views.under_construction, name="under_construction"),
|
||||
path("unsubscribe/<str:token>/", views.unsubscribe, name="unsubscribe"),
|
||||
path(
|
||||
"unsubscribe/<str:token>/one-click/",
|
||||
views.unsubscribe_one_click,
|
||||
name="unsubscribe_one_click",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from analytics.services import attribute_lead_from_request
|
||||
from contacts.models import Channel, ConsentRecord, Contact
|
||||
from leads.models import Lead
|
||||
from messaging.services import (
|
||||
channel_preferences,
|
||||
parse_unsubscribe_token,
|
||||
process_unsubscribe_token,
|
||||
set_channel_preferences,
|
||||
unsubscribe_all,
|
||||
)
|
||||
from public.forms import ContactForm, NotifyForm
|
||||
|
||||
|
||||
def home(request):
|
||||
return render(request, "public/home.html")
|
||||
|
||||
|
||||
def about(request):
|
||||
return render(request, "public/about.html")
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def contact(request):
|
||||
if request.method == "POST":
|
||||
form = ContactForm(request.POST)
|
||||
if form.is_valid():
|
||||
data = form.cleaned_data
|
||||
defaults = {
|
||||
"first_name": data["first_name"],
|
||||
"last_name": data.get("last_name") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"source": Contact.Source.CONTACT_FORM,
|
||||
}
|
||||
postal = Contact.make_postal_address(
|
||||
line1=data.get("address_line1") or "",
|
||||
line2=data.get("address_line2") or "",
|
||||
city=data.get("address_city") or "",
|
||||
state=data.get("address_state") or "",
|
||||
zip_code=data.get("address_zip") or "",
|
||||
)
|
||||
if Contact.postal_address_has_content(postal):
|
||||
defaults["postal_address"] = postal
|
||||
contact_obj, _ = Contact.objects.update_or_create(
|
||||
email=data["email"].lower(),
|
||||
defaults=defaults,
|
||||
)
|
||||
ConsentRecord.objects.update_or_create(
|
||||
contact=contact_obj,
|
||||
channel=Channel.EMAIL,
|
||||
defaults={"opted_in": True, "reason": "contact_form"},
|
||||
)
|
||||
if (data.get("phone") or "").strip():
|
||||
ConsentRecord.objects.update_or_create(
|
||||
contact=contact_obj,
|
||||
channel=Channel.SMS,
|
||||
defaults={"opted_in": True, "reason": "contact_form"},
|
||||
)
|
||||
interest = data.get("interest") or ""
|
||||
interest_label = dict(ContactForm.INTEREST_CHOICES).get(interest, interest)
|
||||
body = data.get("message") or ""
|
||||
if interest_label:
|
||||
body = f"Interest: {interest_label}\n\n{body}".strip()
|
||||
lead = Lead.objects.create(
|
||||
contact=contact_obj,
|
||||
message=body,
|
||||
status=Lead.Status.NEW,
|
||||
)
|
||||
attribute_lead_from_request(request, lead)
|
||||
messages.success(request, "Thanks — Monica will be in touch soon.")
|
||||
return redirect(f"{reverse('public:contact')}?sent=1")
|
||||
else:
|
||||
form = ContactForm()
|
||||
return render(request, "public/contact.html", {"form": form})
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def under_construction(request):
|
||||
"""Direct route (also used by middleware). Accepts notify-me emails."""
|
||||
if request.method == "POST":
|
||||
form = NotifyForm(request.POST)
|
||||
if form.is_valid():
|
||||
email = form.cleaned_data["email"].lower()
|
||||
Contact.objects.get_or_create(
|
||||
email=email,
|
||||
defaults={
|
||||
"first_name": "",
|
||||
"source": Contact.Source.NOTIFY_ME,
|
||||
},
|
||||
)
|
||||
messages.success(request, "You're on the list — we'll email when we launch.")
|
||||
return redirect("public:under_construction")
|
||||
else:
|
||||
form = NotifyForm()
|
||||
return render(request, "public/under_construction.html", {"form": form})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def unsubscribe_one_click(request, token: str):
|
||||
"""
|
||||
One-click opt-out for the channel encoded in the token.
|
||||
|
||||
CSRF-exempt so mail clients can POST List-Unsubscribe=One-Click (RFC 8058).
|
||||
"""
|
||||
ok = process_unsubscribe_token(token)
|
||||
if not ok:
|
||||
return render(request, "public/unsubscribe.html", {"valid": False})
|
||||
return redirect("public:unsubscribe", token=token)
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def unsubscribe(request, token: str):
|
||||
"""
|
||||
Signed-token preference center for email / SMS / postcard.
|
||||
|
||||
GET ?one_click=1 opts out the token channel then redirects here.
|
||||
POST saves checkboxes or unsubscribes from all channels.
|
||||
"""
|
||||
contact, token_channel = parse_unsubscribe_token(token)
|
||||
if not contact:
|
||||
return render(
|
||||
request,
|
||||
"public/unsubscribe.html",
|
||||
{"valid": False},
|
||||
)
|
||||
|
||||
if request.method == "GET" and request.GET.get("one_click") in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
process_unsubscribe_token(token)
|
||||
return redirect("public:unsubscribe", token=token)
|
||||
|
||||
if request.method == "POST":
|
||||
action = (request.POST.get("action") or "save").strip()
|
||||
if action == "unsubscribe_all":
|
||||
unsubscribe_all(contact, reason="preferences_unsubscribe_all")
|
||||
messages.success(
|
||||
request, "You are unsubscribed from all marketing channels."
|
||||
)
|
||||
else:
|
||||
prefs = {
|
||||
Channel.EMAIL: "consent_email" in request.POST,
|
||||
Channel.SMS: "consent_sms" in request.POST,
|
||||
Channel.POSTCARD: "consent_postcard" in request.POST,
|
||||
}
|
||||
set_channel_preferences(
|
||||
contact, prefs, reason="preferences_save"
|
||||
)
|
||||
messages.success(request, "Your communication preferences were saved.")
|
||||
return redirect("public:unsubscribe", token=token)
|
||||
|
||||
contact = Contact.objects.prefetch_related("consents").get(pk=contact.pk)
|
||||
prefs = channel_preferences(contact)
|
||||
identity = contact.email or contact.phone or contact.full_name or "your profile"
|
||||
return render(
|
||||
request,
|
||||
"public/unsubscribe.html",
|
||||
{
|
||||
"valid": True,
|
||||
"contact": contact,
|
||||
"identity": identity,
|
||||
"prefs": prefs,
|
||||
"token_channel": token_channel,
|
||||
"token": token,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def page_not_found(request, exception):
|
||||
return render(request, "public/404.html", status=404)
|
||||
Reference in New Issue
Block a user