diff --git a/innovedus_cms/base/admin.py b/innovedus_cms/base/admin.py index 8c38f3f..a71db95 100644 --- a/innovedus_cms/base/admin.py +++ b/innovedus_cms/base/admin.py @@ -1,3 +1,20 @@ 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", + ) diff --git a/innovedus_cms/base/forms.py b/innovedus_cms/base/forms.py index cac2eea..60fecbf 100644 --- a/innovedus_cms/base/forms.py +++ b/innovedus_cms/base/forms.py @@ -1,4 +1,5 @@ from django import forms +from .models import ContactFormSubmission class NewsletterSubscribeForm(forms.Form): @@ -17,3 +18,12 @@ class NewsletterUnsubscribeForm(forms.Form): ), ) 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(), + } diff --git a/innovedus_cms/base/migrations/0007_contactformsubmission.py b/innovedus_cms/base/migrations/0007_contactformsubmission.py new file mode 100644 index 0000000..a79829f --- /dev/null +++ b/innovedus_cms/base/migrations/0007_contactformsubmission.py @@ -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="
您好 {{name}}:
我們已收到您的來信,以下為存檔資訊:
留言內容:
{{message}}
", + ), + ), + ("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", + ), + ] diff --git a/innovedus_cms/base/models.py b/innovedus_cms/base/models.py index f40ce50..ac7da36 100644 --- a/innovedus_cms/base/models.py +++ b/innovedus_cms/base/models.py @@ -29,6 +29,8 @@ from wagtail.fields import StreamField from wagtail import blocks from .security import encrypt_text + + @register_setting class HeaderSettings(BaseGenericSetting): logo_light = models.ForeignKey( @@ -115,6 +117,53 @@ class SocialMediaSettings(BaseGenericSetting): 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 class NewsletterSystemSettings(BaseGenericSetting): 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_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_email = models.EmailField(blank=True) reply_to_email = models.EmailField(blank=True) @@ -252,22 +291,12 @@ class NewsletterSystemSettings(BaseGenericSetting): ), 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_email"), FieldPanel("reply_to_email"), FieldPanel("default_charset"), ], - heading="SMTP / Mail", + heading="Newsletter Mail", ), ] @@ -275,15 +304,6 @@ class NewsletterSystemSettings(BaseGenericSetting): verbose_name = "Newsletter System 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) - 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() 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) +@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=( + "您好 {{name}}:
" + "我們已收到您的來信,以下為存檔資訊:
" + "留言內容:
" + "{{message}}
" + ), + ) + 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): token_hash = models.CharField(max_length=64, unique=True, db_index=True) subscriber_id = models.CharField(max_length=128, blank=True) @@ -516,3 +603,32 @@ class FooterText( class Meta(TranslatableMixin.Meta): 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}" diff --git a/innovedus_cms/base/newsletter.py b/innovedus_cms/base/newsletter.py index 15495cc..a313bf5 100644 --- a/innovedus_cms/base/newsletter.py +++ b/innovedus_cms/base/newsletter.py @@ -15,7 +15,7 @@ from django.conf import settings from django.core.mail import EmailMultiAlternatives, get_connection from wagtail.rich_text import expand_db_html -from .models import NewsletterSystemSettings +from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings from .security import decrypt_text logger = logging.getLogger(__name__) @@ -444,35 +444,47 @@ class SendEngineClient: 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: - from_email = build_from_email(config.sender_name, config.sender_email) - reply_to = [config.reply_to_email] if config.reply_to_email else None - - if not config.smtp_relay_host: +def _build_smtp_connection(smtp_config: MailSmtpSettings): + if not smtp_config.smtp_relay_host: 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.") password = "" - encrypted_password = (config.smtp_password or "").strip() + encrypted_password = (smtp_config.smtp_password or "").strip() if encrypted_password: try: password = decrypt_text(encrypted_password) except Exception: password = "" - connection = get_connection( + return get_connection( backend="django.core.mail.backends.smtp.EmailBackend", - host=config.smtp_relay_host, - port=config.smtp_relay_port, - username=config.smtp_username or None, + host=smtp_config.smtp_relay_host, + port=smtp_config.smtp_relay_port, + username=smtp_config.smtp_username or None, password=password or None, - use_tls=bool(config.smtp_use_tls), - use_ssl=bool(config.smtp_use_ssl), - timeout=max(1, int(config.smtp_timeout_seconds or 15)), + use_tls=bool(smtp_config.smtp_use_tls), + use_ssl=bool(smtp_config.smtp_use_ssl), + timeout=max(1, int(smtp_config.smtp_timeout_seconds or 15)), 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( subject=subject, body=text_body, @@ -490,12 +502,88 @@ def send_subscribe_email(*, to_email: str, subject: str, text_body: str, html_bo 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", sent_count, - config.smtp_relay_host, - config.smtp_relay_port, - bool(config.smtp_use_tls), - bool(config.smtp_use_ssl), - max(1, int(config.smtp_timeout_seconds or 15)), + smtp_config.smtp_relay_host, + smtp_config.smtp_relay_port, + bool(smtp_config.smtp_use_tls), + bool(smtp_config.smtp_use_ssl), + max(1, int(smtp_config.smtp_timeout_seconds or 15)), from_email or settings.DEFAULT_FROM_EMAIL, to_email, ) 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) diff --git a/innovedus_cms/base/tests.py b/innovedus_cms/base/tests.py index db622d1..2f7d1cb 100644 --- a/innovedus_cms/base/tests.py +++ b/innovedus_cms/base/tests.py @@ -1,4 +1,5 @@ from django.test import TestCase +from django.urls import reverse from .newsletter import ( extract_token, @@ -7,6 +8,7 @@ from .newsletter import ( render_newsletter_html, verify_one_click_token, ) +from .models import ContactFormSubmission 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('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) diff --git a/innovedus_cms/base/views.py b/innovedus_cms/base/views.py index 2b3c3a3..7b1cdf9 100644 --- a/innovedus_cms/base/views.py +++ b/innovedus_cms/base/views.py @@ -1,3 +1,4 @@ +import logging from urllib.parse import urlencode import hashlib import json @@ -9,27 +10,38 @@ from django.core.validators import validate_email from django.http import HttpResponseNotAllowed, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.html import escape from django.views.decorators.csrf import csrf_exempt 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 ( + MailSmtpSettings, NewsletterCampaign, NewsletterSystemSettings, NewsletterTemplateSettings, OneClickUnsubscribeAudit, + SystemNotificationMailSettings, ) from .newsletter import ( MemberCenterClient, build_from_email, extract_token, render_placeholders, + send_contact_notification_email, + send_contact_user_email, send_subscribe_email, ) 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): @@ -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", "Name: " + f"{escape(submission.name)}
" + "Email: " + f"{escape(submission.email)}
" + "Contact: " + f"{escape(submission.contact)}
" + "Category: " + f"{escape(submission.get_category_display())}
" + "Source Page: " + f"{escape(submission.source_page or '')}
" + "Message:
" + f"{escaped_message_html}
" + ) + 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", "這是一封測試信,代表 SMTP 設定可正常寄送。
", config=settings_obj, + smtp_config=smtp_settings, ) except Exception as exc: messages.error(request, f"測試信寄送失敗:{exc}") diff --git a/innovedus_cms/mysite/static/css/contact_form.css b/innovedus_cms/mysite/static/css/contact_form.css new file mode 100644 index 0000000..cf3de31 --- /dev/null +++ b/innovedus_cms/mysite/static/css/contact_form.css @@ -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; + } +} diff --git a/innovedus_cms/mysite/static/js/contact_form.js b/innovedus_cms/mysite/static/js/contact_form.js new file mode 100644 index 0000000..71de4ed --- /dev/null +++ b/innovedus_cms/mysite/static/js/contact_form.js @@ -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 = "送出失敗,請稍後再試。"; + } + }); + } +})(); diff --git a/innovedus_cms/mysite/static/js/subscribe_fab.js b/innovedus_cms/mysite/static/js/subscribe_fab.js index b060545..a0e284c 100644 --- a/innovedus_cms/mysite/static/js/subscribe_fab.js +++ b/innovedus_cms/mysite/static/js/subscribe_fab.js @@ -3,27 +3,49 @@ if (!root) return; const toggle = root.querySelector(".subscribe-fab__toggle"); + const input = root.querySelector(".subscribe-fab__input"); + const triggers = document.querySelectorAll("[data-subscribe-trigger]"); 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); toggle.setAttribute("aria-expanded", open ? "true" : "false"); + if (open && shouldFocusInput) { + focusInput(); + } }; toggle.addEventListener("click", function () { 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) { + if (event.target.closest("[data-subscribe-trigger]")) { + return; + } if (!root.contains(event.target)) { - setOpen(false); + setOpen(false, false); } }); document.addEventListener("keydown", function (event) { if (event.key === "Escape") { - setOpen(false); + setOpen(false, false); } }); })(); diff --git a/innovedus_cms/mysite/templates/base.html b/innovedus_cms/mysite/templates/base.html index ca30983..41ed983 100644 --- a/innovedus_cms/mysite/templates/base.html +++ b/innovedus_cms/mysite/templates/base.html @@ -36,6 +36,7 @@ + {% block extra_css %} {# Override this in templates to add extra stylesheets #} @@ -54,11 +55,13 @@ {% include "includes/footer.html" %} + {% include "includes/contact_form.html" %} {% include "includes/subscribe_fab.html" %} {# Global javascript #} + {# Instagram embed script to render IG oEmbeds #} diff --git a/innovedus_cms/mysite/templates/includes/contact_form.html b/innovedus_cms/mysite/templates/includes/contact_form.html new file mode 100644 index 0000000..1df8746 --- /dev/null +++ b/innovedus_cms/mysite/templates/includes/contact_form.html @@ -0,0 +1,52 @@ +有任何問題或想要與我們交流的地方,都歡迎留言聯絡
+ + +