feat: Implement contact form submission feature with SMTP settings
- Added ContactFormSubmission model to store contact form submissions. - Created ContactForm for handling form submissions. - Implemented admin interface for managing contact form submissions. - Developed views and JavaScript for handling contact form submission via AJAX. - Added SMTP settings model for email configuration. - Created notification email templates for contact form submissions. - Updated frontend to include contact form modal and associated styles. - Added tests for contact form submission and validation.
This commit is contained in:
parent
4679cc70ef
commit
7a632c5ebd
@ -1,3 +1,20 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from .models import ContactFormSubmission
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
@admin.register(ContactFormSubmission)
|
||||||
|
class ContactFormSubmissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("created_at", "category", "name", "email", "contact")
|
||||||
|
list_filter = ("category", "created_at")
|
||||||
|
search_fields = ("name", "email", "contact", "message", "source_page")
|
||||||
|
readonly_fields = (
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"contact",
|
||||||
|
"category",
|
||||||
|
"message",
|
||||||
|
"source_page",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from .models import ContactFormSubmission
|
||||||
|
|
||||||
|
|
||||||
class NewsletterSubscribeForm(forms.Form):
|
class NewsletterSubscribeForm(forms.Form):
|
||||||
@ -17,3 +18,12 @@ class NewsletterUnsubscribeForm(forms.Form):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
token = forms.CharField(max_length=1024, widget=forms.HiddenInput())
|
token = forms.CharField(max_length=1024, widget=forms.HiddenInput())
|
||||||
|
|
||||||
|
|
||||||
|
class ContactForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ContactFormSubmission
|
||||||
|
fields = ["name", "contact", "email", "category", "message", "source_page"]
|
||||||
|
widgets = {
|
||||||
|
"source_page": forms.HiddenInput(),
|
||||||
|
}
|
||||||
|
|||||||
117
innovedus_cms/base/migrations/0007_contactformsubmission.py
Normal file
117
innovedus_cms/base/migrations/0007_contactformsubmission.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("base", "0006_oneclickunsubscribeaudit_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MailSmtpSettings",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("smtp_relay_host", models.CharField(blank=True, max_length=255)),
|
||||||
|
("smtp_relay_port", models.PositiveIntegerField(default=587)),
|
||||||
|
("smtp_use_tls", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"smtp_use_ssl",
|
||||||
|
models.BooleanField(default=False, help_text="465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。"),
|
||||||
|
),
|
||||||
|
("smtp_timeout_seconds", models.PositiveIntegerField(default=15)),
|
||||||
|
("smtp_username", models.CharField(blank=True, max_length=255)),
|
||||||
|
("smtp_password", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "SMTP Settings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SystemNotificationMailSettings",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("contact_form_from_name", models.CharField(blank=True, max_length=255)),
|
||||||
|
("contact_form_from_email", models.EmailField(blank=True, max_length=254)),
|
||||||
|
("contact_form_reply_to_email", models.EmailField(blank=True, max_length=254)),
|
||||||
|
("contact_form_to_emails", models.TextField(blank=True, help_text="可填多個收件人,以逗號或換行分隔。")),
|
||||||
|
("contact_form_subject_prefix", models.CharField(blank=True, default="[Contact Us]", max_length=255)),
|
||||||
|
("contact_form_user_subject_template", models.CharField(blank=True, default="已收到您的聯絡表單", max_length=255)),
|
||||||
|
(
|
||||||
|
"contact_form_user_text_template",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="您好 {{name}}:\n\n我們已收到您的來信,以下為存檔資訊:\nEmail: {{email}}\n聯絡方式: {{contact}}\n問題類別: {{category}}\n\n留言內容:\n{{message}}\n",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"contact_form_user_html_template",
|
||||||
|
models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="<p>您好 {{name}}:</p><p>我們已收到您的來信,以下為存檔資訊:</p><ul><li>Email: {{email}}</li><li>聯絡方式: {{contact}}</li><li>問題類別: {{category}}</li></ul><p>留言內容:</p><p>{{message}}</p>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("default_charset", models.CharField(default="utf-8", max_length=50)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "System Notification Mail Settings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ContactFormSubmission",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("email", models.EmailField(blank=True, max_length=254)),
|
||||||
|
("contact", models.CharField(max_length=255)),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("collaboration", "合作邀約"),
|
||||||
|
("website_issue", "網站問題回報"),
|
||||||
|
("career", "求職專區"),
|
||||||
|
("other", "其他"),
|
||||||
|
],
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("message", models.TextField()),
|
||||||
|
("source_page", models.CharField(blank=True, max_length=512)),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_password",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_relay_host",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_relay_port",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_timeout_seconds",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_use_ssl",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_use_tls",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="newslettersystemsettings",
|
||||||
|
name="smtp_username",
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -29,6 +29,8 @@ from wagtail.fields import StreamField
|
|||||||
from wagtail import blocks
|
from wagtail import blocks
|
||||||
|
|
||||||
from .security import encrypt_text
|
from .security import encrypt_text
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class HeaderSettings(BaseGenericSetting):
|
class HeaderSettings(BaseGenericSetting):
|
||||||
logo_light = models.ForeignKey(
|
logo_light = models.ForeignKey(
|
||||||
@ -115,6 +117,53 @@ class SocialMediaSettings(BaseGenericSetting):
|
|||||||
panels = [FieldPanel("links")]
|
panels = [FieldPanel("links")]
|
||||||
|
|
||||||
|
|
||||||
|
@register_setting
|
||||||
|
class MailSmtpSettings(BaseGenericSetting):
|
||||||
|
smtp_relay_host = models.CharField(max_length=255, blank=True)
|
||||||
|
smtp_relay_port = models.PositiveIntegerField(default=587)
|
||||||
|
smtp_use_tls = models.BooleanField(default=True)
|
||||||
|
smtp_use_ssl = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。",
|
||||||
|
)
|
||||||
|
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
|
||||||
|
smtp_username = models.CharField(max_length=255, blank=True)
|
||||||
|
smtp_password = models.TextField(blank=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("smtp_relay_host"),
|
||||||
|
FieldPanel("smtp_relay_port"),
|
||||||
|
FieldPanel("smtp_use_tls"),
|
||||||
|
FieldPanel("smtp_use_ssl"),
|
||||||
|
FieldPanel("smtp_timeout_seconds"),
|
||||||
|
FieldPanel("smtp_username"),
|
||||||
|
FieldPanel(
|
||||||
|
"smtp_password",
|
||||||
|
widget=django_forms.PasswordInput(render_value=False),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
heading="SMTP Settings",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "SMTP Settings"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.smtp_use_tls and self.smtp_use_ssl:
|
||||||
|
raise ValueError("smtp_use_tls and smtp_use_ssl cannot both be True.")
|
||||||
|
|
||||||
|
if self.pk and not self.smtp_password:
|
||||||
|
previous = type(self).objects.filter(pk=self.pk).only("smtp_password").first()
|
||||||
|
self.smtp_password = previous.smtp_password if previous else ""
|
||||||
|
elif self.smtp_password:
|
||||||
|
self.smtp_password = encrypt_text(self.smtp_password)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@register_setting
|
@register_setting
|
||||||
class NewsletterSystemSettings(BaseGenericSetting):
|
class NewsletterSystemSettings(BaseGenericSetting):
|
||||||
member_center_base_url = models.URLField(blank=True)
|
member_center_base_url = models.URLField(blank=True)
|
||||||
@ -176,16 +225,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300)
|
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300)
|
||||||
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3)
|
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3)
|
||||||
|
|
||||||
smtp_relay_host = models.CharField(max_length=255, blank=True)
|
|
||||||
smtp_relay_port = models.PositiveIntegerField(default=587)
|
|
||||||
smtp_use_tls = models.BooleanField(default=True)
|
|
||||||
smtp_use_ssl = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="465 常用 SSL(Implicit TLS);587 常用 STARTTLS(TLS)。",
|
|
||||||
)
|
|
||||||
smtp_timeout_seconds = models.PositiveIntegerField(default=15)
|
|
||||||
smtp_username = models.CharField(max_length=255, blank=True)
|
|
||||||
smtp_password = models.TextField(blank=True)
|
|
||||||
sender_name = models.CharField(max_length=255, blank=True)
|
sender_name = models.CharField(max_length=255, blank=True)
|
||||||
sender_email = models.EmailField(blank=True)
|
sender_email = models.EmailField(blank=True)
|
||||||
reply_to_email = models.EmailField(blank=True)
|
reply_to_email = models.EmailField(blank=True)
|
||||||
@ -252,22 +291,12 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
),
|
),
|
||||||
MultiFieldPanel(
|
MultiFieldPanel(
|
||||||
[
|
[
|
||||||
FieldPanel("smtp_relay_host"),
|
|
||||||
FieldPanel("smtp_relay_port"),
|
|
||||||
FieldPanel("smtp_use_tls"),
|
|
||||||
FieldPanel("smtp_use_ssl"),
|
|
||||||
FieldPanel("smtp_timeout_seconds"),
|
|
||||||
FieldPanel("smtp_username"),
|
|
||||||
FieldPanel(
|
|
||||||
"smtp_password",
|
|
||||||
widget=django_forms.PasswordInput(render_value=False),
|
|
||||||
),
|
|
||||||
FieldPanel("sender_name"),
|
FieldPanel("sender_name"),
|
||||||
FieldPanel("sender_email"),
|
FieldPanel("sender_email"),
|
||||||
FieldPanel("reply_to_email"),
|
FieldPanel("reply_to_email"),
|
||||||
FieldPanel("default_charset"),
|
FieldPanel("default_charset"),
|
||||||
],
|
],
|
||||||
heading="SMTP / Mail",
|
heading="Newsletter Mail",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -275,15 +304,6 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
verbose_name = "Newsletter System Settings"
|
verbose_name = "Newsletter System Settings"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.smtp_use_tls and self.smtp_use_ssl:
|
|
||||||
raise ValueError("smtp_use_tls and smtp_use_ssl cannot both be True.")
|
|
||||||
|
|
||||||
if self.pk and not self.smtp_password:
|
|
||||||
previous = type(self).objects.filter(pk=self.pk).only("smtp_password").first()
|
|
||||||
self.smtp_password = previous.smtp_password if previous else ""
|
|
||||||
elif self.smtp_password:
|
|
||||||
self.smtp_password = encrypt_text(self.smtp_password)
|
|
||||||
|
|
||||||
if self.pk and not self.member_center_oauth_client_secret:
|
if self.pk and not self.member_center_oauth_client_secret:
|
||||||
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
previous = type(self).objects.filter(pk=self.pk).only("member_center_oauth_client_secret").first()
|
||||||
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
self.member_center_oauth_client_secret = previous.member_center_oauth_client_secret if previous else ""
|
||||||
@ -293,6 +313,73 @@ class NewsletterSystemSettings(BaseGenericSetting):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@register_setting
|
||||||
|
class SystemNotificationMailSettings(BaseGenericSetting):
|
||||||
|
contact_form_from_name = models.CharField(max_length=255, blank=True)
|
||||||
|
contact_form_from_email = models.EmailField(blank=True)
|
||||||
|
contact_form_reply_to_email = models.EmailField(blank=True)
|
||||||
|
contact_form_to_emails = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="可填多個收件人,以逗號或換行分隔。",
|
||||||
|
)
|
||||||
|
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]")
|
||||||
|
contact_form_user_subject_template = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default="已收到您的聯絡表單",
|
||||||
|
)
|
||||||
|
contact_form_user_text_template = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default=(
|
||||||
|
"您好 {{name}}:\n\n"
|
||||||
|
"我們已收到您的來信,以下為存檔資訊:\n"
|
||||||
|
"Email: {{email}}\n"
|
||||||
|
"聯絡方式: {{contact}}\n"
|
||||||
|
"問題類別: {{category}}\n\n"
|
||||||
|
"留言內容:\n{{message}}\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
contact_form_user_html_template = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default=(
|
||||||
|
"<p>您好 {{name}}:</p>"
|
||||||
|
"<p>我們已收到您的來信,以下為存檔資訊:</p>"
|
||||||
|
"<ul>"
|
||||||
|
"<li>Email: {{email}}</li>"
|
||||||
|
"<li>聯絡方式: {{contact}}</li>"
|
||||||
|
"<li>問題類別: {{category}}</li>"
|
||||||
|
"</ul>"
|
||||||
|
"<p>留言內容:</p>"
|
||||||
|
"<p>{{message}}</p>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
default_charset = models.CharField(max_length=50, default="utf-8")
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("contact_form_from_name"),
|
||||||
|
FieldPanel("contact_form_from_email"),
|
||||||
|
FieldPanel("contact_form_reply_to_email"),
|
||||||
|
FieldPanel("contact_form_to_emails"),
|
||||||
|
FieldPanel("contact_form_subject_prefix"),
|
||||||
|
FieldPanel("contact_form_user_subject_template"),
|
||||||
|
FieldPanel("contact_form_user_text_template"),
|
||||||
|
FieldPanel("contact_form_user_html_template"),
|
||||||
|
FieldPanel("default_charset"),
|
||||||
|
],
|
||||||
|
heading="Contact Us Notification Mail",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "System Notification Mail Settings"
|
||||||
|
|
||||||
|
def contact_form_recipient_list(self) -> list[str]:
|
||||||
|
raw = (self.contact_form_to_emails or "").replace("\n", ",")
|
||||||
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
class OneClickUnsubscribeAudit(models.Model):
|
class OneClickUnsubscribeAudit(models.Model):
|
||||||
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
|
||||||
subscriber_id = models.CharField(max_length=128, blank=True)
|
subscriber_id = models.CharField(max_length=128, blank=True)
|
||||||
@ -516,3 +603,32 @@ class FooterText(
|
|||||||
|
|
||||||
class Meta(TranslatableMixin.Meta):
|
class Meta(TranslatableMixin.Meta):
|
||||||
verbose_name_plural = "Footer Text"
|
verbose_name_plural = "Footer Text"
|
||||||
|
|
||||||
|
|
||||||
|
class ContactFormSubmission(models.Model):
|
||||||
|
CATEGORY_COLLABORATION = "collaboration"
|
||||||
|
CATEGORY_WEBSITE_ISSUE = "website_issue"
|
||||||
|
CATEGORY_CAREER = "career"
|
||||||
|
CATEGORY_OTHER = "other"
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
(CATEGORY_COLLABORATION, "合作邀約"),
|
||||||
|
(CATEGORY_WEBSITE_ISSUE, "網站問題回報"),
|
||||||
|
(CATEGORY_CAREER, "求職專區"),
|
||||||
|
(CATEGORY_OTHER, "其他"),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
email = models.EmailField(blank=True)
|
||||||
|
contact = models.CharField(max_length=255)
|
||||||
|
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES)
|
||||||
|
message = models.TextField()
|
||||||
|
source_page = models.CharField(max_length=512, blank=True)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_category_display()} - {self.name}"
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from django.conf import settings
|
|||||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
from wagtail.rich_text import expand_db_html
|
from wagtail.rich_text import expand_db_html
|
||||||
|
|
||||||
from .models import NewsletterSystemSettings
|
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
|
||||||
from .security import decrypt_text
|
from .security import decrypt_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -444,35 +444,47 @@ class SendEngineClient:
|
|||||||
return APIResult(ok=False, status=0, data={}, error=str(exc))
|
return APIResult(ok=False, status=0, data={}, error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
def send_subscribe_email(*, to_email: str, subject: str, text_body: str, html_body: str, config: NewsletterSystemSettings) -> int:
|
def _build_smtp_connection(smtp_config: MailSmtpSettings):
|
||||||
from_email = build_from_email(config.sender_name, config.sender_email)
|
if not smtp_config.smtp_relay_host:
|
||||||
reply_to = [config.reply_to_email] if config.reply_to_email else None
|
|
||||||
|
|
||||||
if not config.smtp_relay_host:
|
|
||||||
raise ValueError("SMTP relay host is empty. Please save SMTP settings first.")
|
raise ValueError("SMTP relay host is empty. Please save SMTP settings first.")
|
||||||
if config.smtp_use_tls and config.smtp_use_ssl:
|
if smtp_config.smtp_use_tls and smtp_config.smtp_use_ssl:
|
||||||
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
|
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
|
||||||
|
|
||||||
password = ""
|
password = ""
|
||||||
encrypted_password = (config.smtp_password or "").strip()
|
encrypted_password = (smtp_config.smtp_password or "").strip()
|
||||||
if encrypted_password:
|
if encrypted_password:
|
||||||
try:
|
try:
|
||||||
password = decrypt_text(encrypted_password)
|
password = decrypt_text(encrypted_password)
|
||||||
except Exception:
|
except Exception:
|
||||||
password = ""
|
password = ""
|
||||||
|
|
||||||
connection = get_connection(
|
return get_connection(
|
||||||
backend="django.core.mail.backends.smtp.EmailBackend",
|
backend="django.core.mail.backends.smtp.EmailBackend",
|
||||||
host=config.smtp_relay_host,
|
host=smtp_config.smtp_relay_host,
|
||||||
port=config.smtp_relay_port,
|
port=smtp_config.smtp_relay_port,
|
||||||
username=config.smtp_username or None,
|
username=smtp_config.smtp_username or None,
|
||||||
password=password or None,
|
password=password or None,
|
||||||
use_tls=bool(config.smtp_use_tls),
|
use_tls=bool(smtp_config.smtp_use_tls),
|
||||||
use_ssl=bool(config.smtp_use_ssl),
|
use_ssl=bool(smtp_config.smtp_use_ssl),
|
||||||
timeout=max(1, int(config.smtp_timeout_seconds or 15)),
|
timeout=max(1, int(smtp_config.smtp_timeout_seconds or 15)),
|
||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_subscribe_email(
|
||||||
|
*,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
text_body: str,
|
||||||
|
html_body: str,
|
||||||
|
config: NewsletterSystemSettings,
|
||||||
|
smtp_config: MailSmtpSettings | None = None,
|
||||||
|
) -> int:
|
||||||
|
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||||
|
from_email = build_from_email(config.sender_name, config.sender_email)
|
||||||
|
reply_to = [config.reply_to_email] if config.reply_to_email else None
|
||||||
|
connection = _build_smtp_connection(smtp_config)
|
||||||
|
|
||||||
message = EmailMultiAlternatives(
|
message = EmailMultiAlternatives(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=text_body,
|
body=text_body,
|
||||||
@ -490,12 +502,88 @@ def send_subscribe_email(*, to_email: str, subject: str, text_body: str, html_bo
|
|||||||
logger.info(
|
logger.info(
|
||||||
"newsletter email send result sent_count=%s smtp_host=%s smtp_port=%s smtp_tls=%s smtp_ssl=%s smtp_timeout=%s from_email=%s to_email=%s",
|
"newsletter email send result sent_count=%s smtp_host=%s smtp_port=%s smtp_tls=%s smtp_ssl=%s smtp_timeout=%s from_email=%s to_email=%s",
|
||||||
sent_count,
|
sent_count,
|
||||||
config.smtp_relay_host,
|
smtp_config.smtp_relay_host,
|
||||||
config.smtp_relay_port,
|
smtp_config.smtp_relay_port,
|
||||||
bool(config.smtp_use_tls),
|
bool(smtp_config.smtp_use_tls),
|
||||||
bool(config.smtp_use_ssl),
|
bool(smtp_config.smtp_use_ssl),
|
||||||
max(1, int(config.smtp_timeout_seconds or 15)),
|
max(1, int(smtp_config.smtp_timeout_seconds or 15)),
|
||||||
from_email or settings.DEFAULT_FROM_EMAIL,
|
from_email or settings.DEFAULT_FROM_EMAIL,
|
||||||
to_email,
|
to_email,
|
||||||
)
|
)
|
||||||
return sent_count
|
return sent_count
|
||||||
|
|
||||||
|
|
||||||
|
def send_contact_notification_email(
|
||||||
|
*,
|
||||||
|
subject: str,
|
||||||
|
text_body: str,
|
||||||
|
html_body: str,
|
||||||
|
notification_config: SystemNotificationMailSettings,
|
||||||
|
smtp_config: MailSmtpSettings | None = None,
|
||||||
|
) -> int:
|
||||||
|
recipients = notification_config.contact_form_recipient_list()
|
||||||
|
if not recipients:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||||
|
from_email = build_from_email(
|
||||||
|
notification_config.contact_form_from_name,
|
||||||
|
notification_config.contact_form_from_email,
|
||||||
|
)
|
||||||
|
reply_to = (
|
||||||
|
[notification_config.contact_form_reply_to_email]
|
||||||
|
if notification_config.contact_form_reply_to_email
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
connection = _build_smtp_connection(smtp_config)
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=recipients,
|
||||||
|
reply_to=reply_to,
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
if html_body:
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
|
||||||
|
message.encoding = charset
|
||||||
|
return message.send(fail_silently=False)
|
||||||
|
|
||||||
|
|
||||||
|
def send_contact_user_email(
|
||||||
|
*,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
text_body: str,
|
||||||
|
html_body: str,
|
||||||
|
notification_config: SystemNotificationMailSettings,
|
||||||
|
smtp_config: MailSmtpSettings | None = None,
|
||||||
|
) -> int:
|
||||||
|
if not (to_email or "").strip():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
smtp_config = smtp_config or MailSmtpSettings.load()
|
||||||
|
from_email = build_from_email(
|
||||||
|
notification_config.contact_form_from_name,
|
||||||
|
notification_config.contact_form_from_email,
|
||||||
|
)
|
||||||
|
reply_to = (
|
||||||
|
[notification_config.contact_form_reply_to_email]
|
||||||
|
if notification_config.contact_form_reply_to_email
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
connection = _build_smtp_connection(smtp_config)
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[to_email.strip()],
|
||||||
|
reply_to=reply_to,
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
if html_body:
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
charset = (notification_config.default_charset or "utf-8").strip() or "utf-8"
|
||||||
|
message.encoding = charset
|
||||||
|
return message.send(fail_silently=False)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
extract_token,
|
extract_token,
|
||||||
@ -7,6 +8,7 @@ from .newsletter import (
|
|||||||
render_newsletter_html,
|
render_newsletter_html,
|
||||||
verify_one_click_token,
|
verify_one_click_token,
|
||||||
)
|
)
|
||||||
|
from .models import ContactFormSubmission
|
||||||
from .security import decrypt_text, encrypt_text
|
from .security import decrypt_text, encrypt_text
|
||||||
|
|
||||||
|
|
||||||
@ -74,3 +76,39 @@ class NewsletterTemplateTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn('href="https://news.example.com/a"', rendered)
|
self.assertIn('href="https://news.example.com/a"', rendered)
|
||||||
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
self.assertIn('src="https://news.example.com/media/x.jpg"', rendered)
|
||||||
|
|
||||||
|
def test_contact_form_submit_saves_submission(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contact_form_submit"),
|
||||||
|
data={
|
||||||
|
"name": "Tester",
|
||||||
|
"contact": "tester@example.com",
|
||||||
|
"email": "tester@example.com",
|
||||||
|
"category": "other",
|
||||||
|
"message": "hello",
|
||||||
|
"source_page": "/",
|
||||||
|
},
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(ContactFormSubmission.objects.count(), 1)
|
||||||
|
submission = ContactFormSubmission.objects.first()
|
||||||
|
self.assertEqual(submission.name, "Tester")
|
||||||
|
self.assertEqual(submission.email, "tester@example.com")
|
||||||
|
self.assertEqual(submission.category, "other")
|
||||||
|
|
||||||
|
def test_contact_form_submit_rejects_invalid_email(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contact_form_submit"),
|
||||||
|
data={
|
||||||
|
"name": "Tester",
|
||||||
|
"contact": "123",
|
||||||
|
"email": "not-an-email",
|
||||||
|
"category": "other",
|
||||||
|
"message": "hello",
|
||||||
|
"source_page": "/",
|
||||||
|
},
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(ContactFormSubmission.objects.count(), 0)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@ -9,27 +10,38 @@ from django.core.validators import validate_email
|
|||||||
from django.http import HttpResponseNotAllowed, JsonResponse
|
from django.http import HttpResponseNotAllowed, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.html import escape
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
||||||
from .forms import NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
from .forms import ContactForm, NewsletterSubscribeForm, NewsletterUnsubscribeForm
|
||||||
from .models import (
|
from .models import (
|
||||||
|
MailSmtpSettings,
|
||||||
NewsletterCampaign,
|
NewsletterCampaign,
|
||||||
NewsletterSystemSettings,
|
NewsletterSystemSettings,
|
||||||
NewsletterTemplateSettings,
|
NewsletterTemplateSettings,
|
||||||
OneClickUnsubscribeAudit,
|
OneClickUnsubscribeAudit,
|
||||||
|
SystemNotificationMailSettings,
|
||||||
)
|
)
|
||||||
from .newsletter import (
|
from .newsletter import (
|
||||||
MemberCenterClient,
|
MemberCenterClient,
|
||||||
build_from_email,
|
build_from_email,
|
||||||
extract_token,
|
extract_token,
|
||||||
render_placeholders,
|
render_placeholders,
|
||||||
|
send_contact_notification_email,
|
||||||
|
send_contact_user_email,
|
||||||
send_subscribe_email,
|
send_subscribe_email,
|
||||||
)
|
)
|
||||||
from .newsletter_scheduler import dispatch_campaign
|
from .newsletter_scheduler import dispatch_campaign
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def _load_settings():
|
|
||||||
return NewsletterSystemSettings.load(), NewsletterTemplateSettings.load()
|
def _load_settings(request_or_site=None):
|
||||||
|
return (
|
||||||
|
NewsletterSystemSettings.load(request_or_site=request_or_site),
|
||||||
|
NewsletterTemplateSettings.load(request_or_site=request_or_site),
|
||||||
|
MailSmtpSettings.load(request_or_site=request_or_site),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_context(*, title: str, message: str, success: bool):
|
def _build_context(*, title: str, message: str, success: bool):
|
||||||
@ -40,6 +52,112 @@ def _build_context(*, title: str, message: str, success: bool):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_contact_template(template: str, values: dict[str, str]) -> str:
|
||||||
|
rendered = template or ""
|
||||||
|
for key, value in values.items():
|
||||||
|
rendered = rendered.replace(f"{{{{{key}}}}}", value)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def contact_form_submit(request):
|
||||||
|
form = ContactForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
||||||
|
messages.error(request, "表單欄位未填完整,請確認後再送出。")
|
||||||
|
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||||
|
|
||||||
|
submission = form.save(commit=False)
|
||||||
|
submission.ip_address = request.META.get("REMOTE_ADDR") or ""
|
||||||
|
submission.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
||||||
|
submission.save()
|
||||||
|
notification_settings = SystemNotificationMailSettings.load(request_or_site=request)
|
||||||
|
smtp_settings = MailSmtpSettings.load(request_or_site=request)
|
||||||
|
subject_prefix = (notification_settings.contact_form_subject_prefix or "").strip()
|
||||||
|
subject = f"{subject_prefix} {submission.get_category_display()}".strip()
|
||||||
|
escaped_message_html = escape(submission.message).replace("\n", "<br>")
|
||||||
|
text_body = (
|
||||||
|
f"Name: {submission.name}\n"
|
||||||
|
f"Email: {submission.email}\n"
|
||||||
|
f"Contact: {submission.contact}\n"
|
||||||
|
f"Category: {submission.get_category_display()}\n"
|
||||||
|
f"Source Page: {submission.source_page}\n\n"
|
||||||
|
f"Message:\n{submission.message}\n"
|
||||||
|
)
|
||||||
|
html_body = (
|
||||||
|
"<p><strong>Name:</strong> "
|
||||||
|
f"{escape(submission.name)}</p>"
|
||||||
|
"<p><strong>Email:</strong> "
|
||||||
|
f"{escape(submission.email)}</p>"
|
||||||
|
"<p><strong>Contact:</strong> "
|
||||||
|
f"{escape(submission.contact)}</p>"
|
||||||
|
"<p><strong>Category:</strong> "
|
||||||
|
f"{escape(submission.get_category_display())}</p>"
|
||||||
|
"<p><strong>Source Page:</strong> "
|
||||||
|
f"{escape(submission.source_page or '')}</p>"
|
||||||
|
"<p><strong>Message:</strong></p>"
|
||||||
|
f"<p>{escaped_message_html}</p>"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_contact_notification_email(
|
||||||
|
subject=subject,
|
||||||
|
text_body=text_body,
|
||||||
|
html_body=html_body,
|
||||||
|
notification_config=notification_settings,
|
||||||
|
smtp_config=smtp_settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("contact form admin notification email failed: %s", exc)
|
||||||
|
|
||||||
|
if submission.email:
|
||||||
|
values_text = {
|
||||||
|
"name": submission.name,
|
||||||
|
"email": submission.email,
|
||||||
|
"contact": submission.contact,
|
||||||
|
"category": submission.get_category_display(),
|
||||||
|
"message": submission.message,
|
||||||
|
"source_page": submission.source_page or "",
|
||||||
|
}
|
||||||
|
values_html = {
|
||||||
|
"name": escape(submission.name),
|
||||||
|
"email": escape(submission.email),
|
||||||
|
"contact": escape(submission.contact),
|
||||||
|
"category": escape(submission.get_category_display()),
|
||||||
|
"message": escape(submission.message).replace("\n", "<br>"),
|
||||||
|
"source_page": escape(submission.source_page or ""),
|
||||||
|
}
|
||||||
|
user_subject = _render_contact_template(
|
||||||
|
notification_settings.contact_form_user_subject_template,
|
||||||
|
values_text,
|
||||||
|
)
|
||||||
|
user_text = _render_contact_template(
|
||||||
|
notification_settings.contact_form_user_text_template,
|
||||||
|
values_text,
|
||||||
|
)
|
||||||
|
user_html = _render_contact_template(
|
||||||
|
notification_settings.contact_form_user_html_template,
|
||||||
|
values_html,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_contact_user_email(
|
||||||
|
to_email=submission.email,
|
||||||
|
subject=user_subject,
|
||||||
|
text_body=user_text,
|
||||||
|
html_body=user_html,
|
||||||
|
notification_config=notification_settings,
|
||||||
|
smtp_config=smtp_settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("contact form user copy email failed: %s", exc)
|
||||||
|
|
||||||
|
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"success": True})
|
||||||
|
|
||||||
|
messages.success(request, "感謝您的來信,我們已收到您的表單。")
|
||||||
|
return redirect(request.META.get("HTTP_REFERER") or "/")
|
||||||
|
|
||||||
|
|
||||||
def _extract_one_click_token(request):
|
def _extract_one_click_token(request):
|
||||||
token = (request.GET.get("token") or "").strip()
|
token = (request.GET.get("token") or "").strip()
|
||||||
if token:
|
if token:
|
||||||
@ -61,7 +179,7 @@ def _extract_one_click_token(request):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_one_click_unsubscribe(*, request, token):
|
def _handle_one_click_unsubscribe(*, request, token):
|
||||||
system_settings, _ = _load_settings()
|
system_settings, _, _ = _load_settings(request)
|
||||||
|
|
||||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else ""
|
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else ""
|
||||||
|
|
||||||
@ -115,7 +233,7 @@ def _handle_one_click_unsubscribe(*, request, token):
|
|||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
def newsletter_subscribe(request):
|
def newsletter_subscribe(request):
|
||||||
form = NewsletterSubscribeForm(request.POST)
|
form = NewsletterSubscribeForm(request.POST)
|
||||||
system_settings, template_settings = _load_settings()
|
system_settings, template_settings, smtp_settings = _load_settings(request)
|
||||||
|
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
return render(
|
return render(
|
||||||
@ -174,6 +292,7 @@ def newsletter_subscribe(request):
|
|||||||
text_body=render_placeholders(template_settings.subscribe_text_template, values),
|
text_body=render_placeholders(template_settings.subscribe_text_template, values),
|
||||||
html_body=render_placeholders(template_settings.subscribe_html_template, values),
|
html_body=render_placeholders(template_settings.subscribe_html_template, values),
|
||||||
config=system_settings,
|
config=system_settings,
|
||||||
|
smtp_config=smtp_settings,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return render(
|
return render(
|
||||||
@ -202,7 +321,7 @@ def newsletter_subscribe(request):
|
|||||||
def newsletter_confirm(request):
|
def newsletter_confirm(request):
|
||||||
token = (request.GET.get("token") or "").strip()
|
token = (request.GET.get("token") or "").strip()
|
||||||
email = (request.GET.get("email") or "").strip().lower()
|
email = (request.GET.get("email") or "").strip().lower()
|
||||||
system_settings, template_settings = _load_settings()
|
system_settings, template_settings, _ = _load_settings(request)
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return render(
|
return render(
|
||||||
@ -242,7 +361,7 @@ def newsletter_confirm(request):
|
|||||||
|
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def newsletter_unsubscribe(request):
|
def newsletter_unsubscribe(request):
|
||||||
system_settings, template_settings = _load_settings()
|
system_settings, template_settings, _ = _load_settings(request)
|
||||||
client = MemberCenterClient(system_settings)
|
client = MemberCenterClient(system_settings)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
@ -379,6 +498,7 @@ def newsletter_smtp_test(request):
|
|||||||
return redirect(redirect_to)
|
return redirect(redirect_to)
|
||||||
|
|
||||||
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
|
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
|
||||||
|
smtp_settings = MailSmtpSettings.load(request_or_site=request)
|
||||||
try:
|
try:
|
||||||
sent_count = send_subscribe_email(
|
sent_count = send_subscribe_email(
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
@ -386,6 +506,7 @@ def newsletter_smtp_test(request):
|
|||||||
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
|
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
|
||||||
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
|
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
|
||||||
config=settings_obj,
|
config=settings_obj,
|
||||||
|
smtp_config=smtp_settings,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messages.error(request, f"測試信寄送失敗:{exc}")
|
messages.error(request, f"測試信寄送失敗:{exc}")
|
||||||
|
|||||||
198
innovedus_cms/mysite/static/css/contact_form.css
Normal file
198
innovedus_cms/mysite/static/css/contact_form.css
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
.contact-form-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #0e1b42cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__dialog {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(680px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100dvh - 48px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 28px 24px;
|
||||||
|
background: #0e1b42e6;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__subtitle {
|
||||||
|
margin: 12px 0 18px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
max-width: 415px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form,
|
||||||
|
.contact-form * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field input,
|
||||||
|
.contact-form__field select,
|
||||||
|
.contact-form__field textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #ffffff4d;
|
||||||
|
color: #0e1b42;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field input:focus,
|
||||||
|
.contact-form__field select:focus,
|
||||||
|
.contact-form__field textarea:focus {
|
||||||
|
background: #ffffffcc;
|
||||||
|
color: #0e1b42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field input::placeholder {
|
||||||
|
color: #ffffffcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field select {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field textarea {
|
||||||
|
min-height: 180px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__actions button {
|
||||||
|
min-width: 196px;
|
||||||
|
height: 38px;
|
||||||
|
border: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0e1b42;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__result {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
min-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.contact-form-modal__dialog {
|
||||||
|
width: calc(90vw - 24px);
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 12px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form-modal__subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field label {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field input,
|
||||||
|
.contact-form__field select,
|
||||||
|
.contact-form__field textarea {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field select {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__field textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__actions button {
|
||||||
|
min-width: 160px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form__result {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
innovedus_cms/mysite/static/js/contact_form.js
Normal file
70
innovedus_cms/mysite/static/js/contact_form.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
(function () {
|
||||||
|
const modal = document.querySelector("[data-contact-form]");
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const form = modal.querySelector("[data-contact-form-body]");
|
||||||
|
const result = modal.querySelector("[data-contact-result]");
|
||||||
|
const categoryInput = modal.querySelector("#contact-form-category");
|
||||||
|
const closeTargets = modal.querySelectorAll("[data-contact-close]");
|
||||||
|
const triggers = document.querySelectorAll("[data-contact-trigger]");
|
||||||
|
let lastActive = null;
|
||||||
|
|
||||||
|
const setOpen = (open) => {
|
||||||
|
modal.hidden = !open;
|
||||||
|
document.body.style.overflow = open ? "hidden" : "";
|
||||||
|
if (!open && lastActive) {
|
||||||
|
lastActive.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
triggers.forEach((el) => {
|
||||||
|
el.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
lastActive = el;
|
||||||
|
const preset = el.getAttribute("data-contact-category");
|
||||||
|
if (preset && categoryInput) {
|
||||||
|
categoryInput.value = preset;
|
||||||
|
}
|
||||||
|
if (result) result.textContent = "";
|
||||||
|
setOpen(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeTargets.forEach((el) => {
|
||||||
|
el.addEventListener("click", () => setOpen(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && !modal.hidden) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
body: data,
|
||||||
|
headers: {
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
if (result) result.textContent = "送出失敗,請確認欄位後再試一次。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) result.textContent = "已送出,我們會盡快與您聯繫。";
|
||||||
|
form.reset();
|
||||||
|
} catch (_) {
|
||||||
|
if (result) result.textContent = "送出失敗,請稍後再試。";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -3,27 +3,49 @@
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
const toggle = root.querySelector(".subscribe-fab__toggle");
|
const toggle = root.querySelector(".subscribe-fab__toggle");
|
||||||
|
const input = root.querySelector(".subscribe-fab__input");
|
||||||
|
const triggers = document.querySelectorAll("[data-subscribe-trigger]");
|
||||||
if (!toggle) return;
|
if (!toggle) return;
|
||||||
|
|
||||||
const setOpen = (open) => {
|
const focusInput = () => {
|
||||||
|
if (!input) return;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setOpen = (open, shouldFocusInput) => {
|
||||||
root.classList.toggle("is-open", open);
|
root.classList.toggle("is-open", open);
|
||||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (open && shouldFocusInput) {
|
||||||
|
focusInput();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
toggle.addEventListener("click", function () {
|
toggle.addEventListener("click", function () {
|
||||||
const isOpen = root.classList.contains("is-open");
|
const isOpen = root.classList.contains("is-open");
|
||||||
setOpen(!isOpen);
|
setOpen(!isOpen, !isOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
triggers.forEach(function (trigger) {
|
||||||
|
trigger.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setOpen(true, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
document.addEventListener("click", function (event) {
|
||||||
|
if (event.target.closest("[data-subscribe-trigger]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!root.contains(event.target)) {
|
if (!root.contains(event.target)) {
|
||||||
setOpen(false);
|
setOpen(false, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (event) {
|
document.addEventListener("keydown", function (event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/mysite.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/mysite.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/footer.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/footer.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'css/subscribe_fab.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'css/subscribe_fab.css' %}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
{# Override this in templates to add extra stylesheets #}
|
{# Override this in templates to add extra stylesheets #}
|
||||||
@ -54,11 +55,13 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include "includes/footer.html" %}
|
{% include "includes/footer.html" %}
|
||||||
|
{% include "includes/contact_form.html" %}
|
||||||
{% include "includes/subscribe_fab.html" %}
|
{% include "includes/subscribe_fab.html" %}
|
||||||
|
|
||||||
{# Global javascript #}
|
{# Global javascript #}
|
||||||
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
|
<script type="text/javascript" src="{% static 'js/mysite.js' %}"></script>
|
||||||
<script type="text/javascript" src="{% static 'js/header.js' %}"></script>
|
<script type="text/javascript" src="{% static 'js/header.js' %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'js/contact_form.js' %}"></script>
|
||||||
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
|
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
|
||||||
{# Instagram embed script to render IG oEmbeds #}
|
{# Instagram embed script to render IG oEmbeds #}
|
||||||
<script async src="https://www.instagram.com/embed.js"></script>
|
<script async src="https://www.instagram.com/embed.js"></script>
|
||||||
|
|||||||
52
innovedus_cms/mysite/templates/includes/contact_form.html
Normal file
52
innovedus_cms/mysite/templates/includes/contact_form.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<div class="contact-form-modal" data-contact-form hidden>
|
||||||
|
<div class="contact-form-modal__backdrop" data-contact-close></div>
|
||||||
|
<div class="contact-form-modal__dialog" role="dialog" aria-modal="true" aria-label="聯絡表單">
|
||||||
|
<button class="contact-form-modal__close" type="button" aria-label="關閉" data-contact-close>×</button>
|
||||||
|
|
||||||
|
<h2 class="contact-form-modal__title">聯絡我們</h2>
|
||||||
|
<p class="contact-form-modal__subtitle">有任何問題或想要與我們交流的地方,都歡迎留言聯絡</p>
|
||||||
|
|
||||||
|
<form class="contact-form" method="post" action="{% url 'contact_form_submit' %}" data-contact-form-body>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="source_page" value="{{ request.get_full_path|default:'/' }}" />
|
||||||
|
|
||||||
|
<div class="contact-form__row">
|
||||||
|
<div class="contact-form__field">
|
||||||
|
<label for="contact-form-name">該如何稱呼您</label>
|
||||||
|
<input id="contact-form-name" type="text" name="name" required placeholder="請留下您的姓名" />
|
||||||
|
</div>
|
||||||
|
<div class="contact-form__field">
|
||||||
|
<label for="contact-form-contact">聯絡方式</label>
|
||||||
|
<input id="contact-form-contact" type="text" name="contact" required placeholder="+886..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-form__field">
|
||||||
|
<label for="contact-form-email">Email</label>
|
||||||
|
<input id="contact-form-email" type="email" name="email" placeholder="name@example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-form__field">
|
||||||
|
<label for="contact-form-category">問題類別</label>
|
||||||
|
<select id="contact-form-category" name="category" required>
|
||||||
|
<option value="" selected disabled>請選擇問題類別</option>
|
||||||
|
<option value="collaboration">合作邀約</option>
|
||||||
|
<option value="website_issue">網站問題回報</option>
|
||||||
|
<option value="career">求職專區</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-form__field">
|
||||||
|
<label for="contact-form-message">留言內容</label>
|
||||||
|
<textarea id="contact-form-message" name="message" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-form__actions">
|
||||||
|
<button type="submit">送出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="contact-form__result" data-contact-result aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -40,9 +40,9 @@
|
|||||||
<!-- <div class="footer-col footer-col--right"> -->
|
<!-- <div class="footer-col footer-col--right"> -->
|
||||||
<div class="footer-fixed-links">
|
<div class="footer-fixed-links">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{% url 'newsletter_subscribe' %}">訂閱電子報</a></li>
|
<li><a href="#" data-subscribe-trigger>訂閱電子報</a></li>
|
||||||
<li><a href="#">合作提案</a></li>
|
<li><a href="#" data-contact-trigger data-contact-category="collaboration">合作提案</a></li>
|
||||||
<li><a href="#">聯絡我們</a></li>
|
<li><a href="#" data-contact-trigger data-contact-category="other">聯絡我們</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
|
||||||
path("search/", search_views.search, name="search"),
|
path("search/", search_views.search, name="search"),
|
||||||
path("newsletter/subscribe/", base_views.newsletter_subscribe, name="newsletter_subscribe"),
|
path("newsletter/subscribe/", base_views.newsletter_subscribe, name="newsletter_subscribe"),
|
||||||
|
path("contact-form/submit/", base_views.contact_form_submit, name="contact_form_submit"),
|
||||||
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
path("newsletter/confirm/", base_views.newsletter_confirm, name="newsletter_confirm"),
|
||||||
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
path("newsletter/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
|
||||||
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user