Compare commits

...

11 Commits

Author SHA1 Message Date
Warren Chen
b441106d2d feat(migrations): Update newsletter and contact form templates for improved user communication 2026-03-18 12:01:32 +09:00
Warren Chen
be99e5e635 feat(pagination): Enhance disabled state styling for pagination links 2026-03-17 14:41:44 +09:00
Warren Chen
ccb675f4bc feat: Refactor article and news templates for improved layout and styling 2026-03-17 12:09:30 +09:00
Warren Chen
5048f865f2 Add internationalization support and update translations
- Updated Wagtail hooks to use gettext for translatable strings in the NewsletterCampaignViewSet and related help texts.
- Added LOCALE_PATHS to settings for loading translation files.
- Updated base.html to include favicon links.
- Translated various strings in the wagtailsettings edit template to support internationalization.
- Created and populated zh_Hant translation files for Django messages.
- Added a favicon.ico file to the static directory.
2026-03-12 13:42:06 +09:00
Warren Chen
7a632c5ebd 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.
2026-03-10 17:00:42 +09:00
Warren Chen
4679cc70ef Add subscription floating action button with toggle functionality
- Implemented a floating action button (FAB) for newsletter subscription in the template.
- Added JavaScript to handle the toggle state of the FAB and close it on outside clicks or Escape key press.
- Created CSS styles for the FAB, including animations and responsive design.
- Added a Django template tag to return a random default cover image for the FAB.
- Integrated a form for email input and submission within the FAB.
2026-03-06 18:54:33 +09:00
Warren Chen
8a0621d1ce feat(footer): Add footer component with responsive design and social media links 2026-03-06 14:26:19 +09:00
Warren Chen
9b3673831a feat(newsletter): Implement one-click unsubscribe functionality and update related settings 2026-02-26 18:32:15 +09:00
Warren Chen
6ea501dc62 feat: Implement one-click unsubscribe feature and newsletter campaign management
- Added one-click unsubscribe functionality with token generation and verification.
- Introduced a new model for tracking one-click unsubscribe audits.
- Enhanced newsletter campaign management with the ability to send campaigns immediately.
- Implemented a scheduler for dispatching due newsletter campaigns.
- Updated views and templates to support one-click unsubscribe and campaign previews.
- Added management commands for running the newsletter scheduler.
- Removed obsolete SSL certificate file.
- Updated entrypoint script to handle different application roles.
2026-02-25 14:42:46 +09:00
Warren Chen
4c78500ec9 fix(docker): Update Dockerfile to install ca-certificates and create unprivileged user
fix(settings): Ensure SSL_CERT_FILE is set using certifi if not already defined

chore(requirements): Add certifi to requirements for SSL certificate handling
2026-02-18 12:02:35 +09:00
Warren Chen
69ef3ccf72 feat(newsletter): Implement newsletter subscription and unsubscription features
- Added models for NewsletterSystemSettings and NewsletterTemplateSettings to manage configuration.
- Created forms for subscribing and unsubscribing from the newsletter.
- Developed views for handling subscription, confirmation, and unsubscription processes.
- Integrated Member Center API for managing newsletter subscriptions.
- Implemented email sending functionality with SMTP settings.
- Added templates for displaying subscription status and unsubscription confirmation.
- Enhanced CSS for newsletter forms and status messages.
- Included tests for newsletter functionality and security measures for sensitive data.
2026-02-17 17:57:16 +09:00
64 changed files with 5022 additions and 419 deletions

View File

@ -1,6 +1,6 @@
# 電子報介接備忘錄(租戶端 / Wagtail
最後更新2026-02-11
最後更新2026-02-18
## 1. 目標與前提
@ -154,3 +154,270 @@
3. 再完成退訂頁與退訂 API 串接。
4. 建立電子報 app 與 HTML 編輯器資料模型。
5. 最後接排程任務與 Send Engine 發信。
## 8. Member Center API 實際規格(以 member_center 程式碼 / OpenAPI 為準)
資料來源2026-02-17 比對):
- `../member_center/src/MemberCenter.Api/Controllers/NewsletterController.cs`
- `../member_center/docs/openapi.yaml`
### 8.1 Base URL 與路徑
- OpenAPI `servers.url``/api`,部署時實際呼叫通常為:
- `https://{member-center}/api/newsletter/...`
- 若部署已在 gateway 做 path rewrite也可為
- `https://{member-center}/newsletter/...`
- 租戶端必須以實際部署路徑設定 `member_center_base_url`
### 8.2 Endpoint / Method / Request
1. 訂閱
- `POST /newsletter/subscribe`
- JSON body必要
- `list_id`Guid
- `email`string
- JSON body可選
- `preferences`object
- `source`string
- 回傳包含 `confirm_token`
2. 訂閱確認(雙重驗證)
- `GET /newsletter/confirm?token=...`
- 注意:**confirm 是 GET不是 POST**
3. 單一名單退訂
- `POST /newsletter/unsubscribe`
- JSON body必要
- `token`string
4. 申請退訂 token
- `POST /newsletter/unsubscribe-token`
- JSON body必要
- `list_id`Guid
- `email`string
- 回傳:
- `unsubscribe_token`string
### 8.3 與租戶端目前實作對齊結果
- `subscribe`:已使用 `POST`
- `confirm`:已改為 `GET + query token`(修正 HTTP 405 問題)。
- `unsubscribe`:已使用 `POST`,且 body 僅送 `token`
- `unsubscribe-token`:已使用 `POST`,且 body 送 `list_id + email`
## 9. List-Unsubscribe One-Click 規範(發信流程階段導入)
說明:
- 本節為「電子報 app + 排程發信」階段的目標規範。
- 已完成的訂閱/退訂流程維持現況,不回頭重做;待發信流程上線時再整合 one-click。
### 9.1 目標
- 提供電子報編輯與排程。
- 產生 `List-Unsubscribe` one-click header。
- 呼叫發信代理 API。
- 提供 unsubscribe endpointone-click + 人類點擊頁)。
### 9.2 Unsubscribe Token 設計
建議使用 JWT 或 HMAC token。
Token 內容建議:
- `subscriber_id`
- `list_id`
- `site_id`
- `exp`
- `nonce`optional
安全要求:
- 不需登入即可退訂。
- 必須可 idempotent。
- Token 需有過期時間。
- Token 必須簽章驗證。
### 9.3 Unsubscribe Endpoint
1. One-click endpoint
- `POST /u/unsubscribe`
- Request`token`
- 流程:
- 驗證 token
- 呼叫會員平台 API
- 回傳 `200`
- 不可:
- 要求登入
- 要求二次確認
2. 人類點擊頁面
- `GET /u/unsubscribe?token=xxx`
- 流程:
- 驗證 token
- 顯示退訂成功頁面
### 9.4 發信 Header 規則
Newsletter 必須包含:
- `List-Unsubscribe: <https://domain/u/unsubscribe?token=xxx>`
- `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
註記:
- Transactional email 不應包含 List-Unsubscribe。
### 9.5 與會員平台整合one-click 目標介面)
呼叫 API
- `POST /api/subscriptions/unsubscribe`
Request
- `subscriber_id`
- `list_id`
- `source: "one_click"`
- `campaign_id`
回傳:
- `success`
- `already_unsubscribed`
### 9.6 測試案例
1. Token 驗證
- 正常 token -> 成功退訂
- 過期 token -> `400``410`
- 已退訂 -> `200`
- 簽章錯誤 -> `400`
2. Header 驗證
- Newsletter 發送時必定包含 `List-Unsubscribe`
- Transactional email 不包含 `List-Unsubscribe`
- Header 格式正確
### 9.7 安全與 UX
安全:
- Unsubscribe endpoint 不受 CSRF 限制
- Token 驗證必須 server-side
- 記錄 audit log
- 不顯示 `subscriber_id` 在 URL 明文
UX
- 退訂成功頁面簡潔
- 提供重新訂閱入口
- 不強迫填問卷
### 9.8 目前實作狀態Wagtail
- 已提供 one-click endpoint
- `POST /u/unsubscribe`token 可由 query/body 提供)
- `GET /u/unsubscribe?token=...`(人類點擊頁)
- 已實作 token 驗證HMAC + `exp`)與 idempotent 行為(重複退訂維持 200
- 已實作 server-side audit log`OneClickUnsubscribeAudit`)。
- 已提供 one-click URL + Header 產生工具:
- `List-Unsubscribe`
- `List-Unsubscribe-Post`
### 9.9 Send Engine 發送介接(依 `../mass_mail_engine/docs/openapi.yaml` 對齊)
已對齊的 API
1. 建立 send job
- `POST /api/send-jobs`
- request 以 `CreateSendJobRequest` 為主(`list_id``subject``body_html/body_text/template`
- response 讀取:
- `send_job_id`
- `status`
2. 查詢 send job
- `GET /api/send-jobs/{id}`
- 若建立後狀態非最終態,持續輪詢直到最終態或超過輪詢上限。
狀態對應Wagtail campaign
- Send Engine `completed` -> campaign `sent`
- Send Engine `failed` / `cancelled` -> campaign `failed`
- 建立失敗 / 輪詢逾時 / 輪詢錯誤 -> campaign `failed`
Send Engine 最終態terminal
- `completed`
- `failed`
- `cancelled`
備註:
- 目前 dispatch 紀錄改為「每次送 job / 重試一次一筆」,不再是逐收件者一筆。
- 若要做投遞到單一收件者的最終監控delivery/bounce/complaint仍建議接 Send Engine webhook 或事件回寫機制處理。
### 9.10 One-Click Token 模式(目前採用)
目前採用:`MemberCenter token relay`
流程:
1. CMS 發送 payload 時同時提供:
- `template.list_unsubscribe_url_template`(給 Send Engine 產生 `List-Unsubscribe` header
信內可點連結由 `body_html/body_text` 直接使用 `{{unsubscribe_token}}` 組 URL。
例如:`https://{cms}/u/unsubscribe?token={{unsubscribe_token}}`
2. Send Engine 於發送時向 Member Center 取得每位收件者的退訂 token並替換 `{{unsubscribe_token}}`
3. 使用者點擊信內退訂(或 mailbox one-click進入 CMS `/u/unsubscribe`
4. CMS 不自行驗簽 token直接以 S2S 呼叫 Member Center `unsubscribe(token)` 完成退訂。
備註:
- 舊的 CMS 自簽 HMAC one-click token 保留工具函式作為備援,但主流程已切到 relay 模式。
## 10. 下一階段演進備忘(先記錄,待試用回饋後再排)
說明:
- 本節為「規劃中」項目,先記錄方向,不立即實作。
- 優先等待:電子報排版產出 + 實際 user 試用回饋。
### 10.1 編輯器能力演進
現況:
- 目前使用 Wagtail Draftail 作為 HTML 編輯器,已可插入圖片(圖庫選擇 / 上傳)。
待回饋後評估:
- 若編輯需求提升table、欄位版型、拖拉區塊、進階 email 元件),再評估導入更完整的 newsletter editor。
- 原則:先確認真實編輯痛點,再導入新編輯器,避免過早複雜化。
### 10.2 電子報樣式CSS策略
建議方向:
- 採「固定樣式基底 + 內容編輯」模式。
- CSS 不建議只放 `<head><style>`,實務上需考慮 email client 相容性,通常會在發送前做 CSS inlining將重要樣式轉為 inline style
- 可保留少量 head style例如 media query但核心排版建議以 inline 為主。
### 10.3 外框模板Header / Footer / Shell管理
目標:
- 提供可編輯的 newsletter shell例如 `layout.html`),內含 head、header、footer 與內容插槽。
建議實作概念:
- 內容編輯區僅負責 body 內文。
- 發送時由 backend 組裝:`shell + content + one-click headers/links`
- 預覽也走同一組裝流程,避免「預覽長得跟實際寄出不一致」。
### 10.4 退訂連結與個人化參數責任分工
共識方向:
- 退訂連結需與收件者身分綁定(每位收件者唯一 token / URL
- 內容模板需預留替換位(例如 `{{unsubscribe_url}}`)。
責任分工建議:
- CMS產內容、產 placeholder、組裝信件與 one-click header。
- Send Engine執行發送、接收投遞回報bounce/complaint 等)。
- SES或下游 provider最終投遞與回執來源。
### 10.5 文章區塊直接引入
需求方向:
- 支援從既有文章中挑選區塊或全文片段匯入電子報。
建議:
- 第一階段可先做「選文章 -> 帶入標題/摘要/首圖/連結」。
- 第二階段再考慮「可編輯快照」或「保持動態同步」策略。
### 10.6 預覽能力
需求方向:
- 內文編輯預覽。
- 樣式shell編輯預覽。
- 能注入假資料(假收件者、假 token、假文章看最終結果。
建議:
- 做「最終寄出 HTML 預覽」功能,預覽管線與正式發送使用同一套 renderer。
- 後續可加多裝置寬度與常見 email client 快速檢視模式(若使用者回饋有需要)。

View File

@ -10,7 +10,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /code
# Create an unprivileged user to run the app
RUN adduser --disabled-password --gecos '' app
RUN set -ex && \
apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
adduser --disabled-password --gecos '' app
COPY requirements.txt /tmp/requirements.txt
RUN set -ex && \

View File

@ -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",
)

View File

@ -0,0 +1,29 @@
from django import forms
from .models import ContactFormSubmission
class NewsletterSubscribeForm(forms.Form):
email = forms.EmailField(max_length=254)
class NewsletterUnsubscribeForm(forms.Form):
email = forms.EmailField(
max_length=254,
required=False,
widget=forms.EmailInput(
attrs={
"readonly": "readonly",
"aria-readonly": "true",
}
),
)
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(),
}

View File

@ -0,0 +1,20 @@
from django.core.management.base import BaseCommand
from base.newsletter_scheduler import dispatch_due_campaigns
class Command(BaseCommand):
help = "Dispatch due newsletter campaigns"
def add_arguments(self, parser):
parser.add_argument("--limit", type=int, default=20)
def handle(self, *args, **options):
result = dispatch_due_campaigns(limit=options["limit"])
self.stdout.write(
self.style.SUCCESS(
"processed_campaigns={processed_campaigns} sent_recipients={sent_recipients} failed_campaigns={failed_campaigns}".format(
**result
)
)
)

View File

@ -0,0 +1,62 @@
# Generated by Django 5.2.7 on 2026-02-12 07:23
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0004_remove_headersettings_logo_headersettings_logo_dark_and_more'),
]
operations = [
migrations.CreateModel(
name='NewsletterSystemSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('member_center_base_url', models.URLField(blank=True)),
('member_center_subscribe_path', models.CharField(blank=True, default='/newsletter/subscribe', max_length=255)),
('member_center_confirm_path', models.CharField(blank=True, default='/newsletter/confirm', max_length=255)),
('member_center_unsubscribe_token_path', models.CharField(blank=True, default='/newsletter/unsubscribe-token', max_length=255)),
('member_center_unsubscribe_path', models.CharField(blank=True, default='/newsletter/unsubscribe', max_length=255)),
('member_center_tenant_id', models.CharField(blank=True, max_length=128)),
('member_center_list_id', models.CharField(blank=True, max_length=128)),
('member_center_timeout_seconds', models.PositiveIntegerField(default=10)),
('send_engine_base_url', models.URLField(blank=True)),
('send_engine_oauth_scope', models.CharField(blank=True, max_length=255)),
('send_engine_timeout_seconds', models.PositiveIntegerField(default=10)),
('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 常用 SSLImplicit TLS587 常用 STARTTLSTLS')),
('smtp_timeout_seconds', models.PositiveIntegerField(default=15)),
('smtp_username', models.CharField(blank=True, max_length=255)),
('smtp_password', models.TextField(blank=True)),
('sender_name', models.CharField(blank=True, max_length=255)),
('sender_email', models.EmailField(blank=True, max_length=254)),
('reply_to_email', models.EmailField(blank=True, max_length=254)),
('default_charset', models.CharField(default='utf-8', max_length=50)),
],
options={
'verbose_name': 'Newsletter System Settings',
},
),
migrations.CreateModel(
name='NewsletterTemplateSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subscribe_subject_template', models.CharField(default='請確認您的電子報訂閱', max_length=255)),
('subscribe_html_template', models.TextField(default="<p>您好,請點擊以下連結完成訂閱:</p><p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>")),
('subscribe_text_template', models.TextField(default='您好,請點擊以下連結完成訂閱:{{confirm_url}}')),
('confirm_success_template', wagtail.fields.RichTextField(blank=True, default='<p>歡迎加入debute AI 新聞</p><p>與我們一起探索發掘未來科技更多可能性</p>')),
('confirm_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>訂閱確認失敗,請稍後再試。</p>')),
('unsubscribe_intro_template', wagtail.fields.RichTextField(blank=True, default='<p>感謝您這陣子與我們並肩同行,</p><p>請在下方輸入您的電子郵件,即可完成退訂</p>')),
('unsubscribe_success_template', wagtail.fields.RichTextField(blank=True, default='<p>已完成退訂。</p>')),
('unsubscribe_failure_template', wagtail.fields.RichTextField(blank=True, default='<p>退訂失敗,請稍後再試。</p>')),
],
options={
'verbose_name': 'Newsletter Template Settings',
},
),
]

View File

@ -0,0 +1,143 @@
# Generated by Django 5.2.7 on 2026-02-19 08:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0005_newslettersystemsettings_newslettertemplatesettings'),
]
operations = [
migrations.CreateModel(
name='OneClickUnsubscribeAudit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('subscriber_id', models.CharField(blank=True, max_length=128)),
('list_id', models.CharField(blank=True, max_length=128)),
('site_id', models.CharField(blank=True, max_length=128)),
('campaign_id', models.CharField(blank=True, max_length=128)),
('status', models.CharField(blank=True, max_length=32)),
('response_status', models.PositiveIntegerField(blank=True, null=True)),
('response_payload', models.JSONField(blank=True, default=dict)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('processed_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_one_click_unsubscribe_path',
field=models.CharField(blank=True, default='/api/subscriptions/unsubscribe', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='one_click_endpoint_path',
field=models.CharField(blank=True, default='/u/unsubscribe', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='one_click_token_secret',
field=models.CharField(blank=True, help_text='One-click token 簽章 secret留空則使用 Django SECRET_KEY。', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='one_click_token_ttl_seconds',
field=models.PositiveIntegerField(default=2592000),
),
migrations.CreateModel(
name='NewsletterCampaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('list_id', models.CharField(blank=True, max_length=128)),
('subject_template', models.CharField(max_length=255)),
('html_template', models.TextField()),
('text_template', models.TextField(blank=True)),
('status', models.CharField(choices=[('draft', 'Draft'), ('scheduled', 'Scheduled'), ('sending', 'Sending'), ('sent', 'Sent'), ('failed', 'Failed')], default='draft', max_length=16)),
('scheduled_at', models.DateTimeField(blank=True, null=True)),
('sent_at', models.DateTimeField(blank=True, null=True)),
('last_error', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_subscriptions_path',
field=models.CharField(blank=True, default='/newsletter/subscriptions', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_oauth_token_path',
field=models.CharField(blank=True, default='/oauth/token', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_oauth_client_id',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_oauth_client_secret',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_oauth_scope',
field=models.CharField(blank=True, default='newsletter:list.read', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='member_center_oauth_audience',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='send_engine_send_jobs_path',
field=models.CharField(blank=True, default='/api/send-jobs', max_length=255),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='send_engine_retry_interval_seconds',
field=models.PositiveIntegerField(default=300),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='send_engine_retry_max_attempts',
field=models.PositiveIntegerField(default=3),
),
migrations.AddField(
model_name='newslettersystemsettings',
name='site_base_url',
field=models.URLField(blank=True, help_text='排程發送使用的站台網址(例如 https://news.example.com'),
),
migrations.CreateModel(
name='NewsletterDispatchRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subscriber_id', models.CharField(blank=True, max_length=128)),
('email', models.EmailField(blank=True, max_length=254)),
('status', models.CharField(blank=True, max_length=32)),
('retry_count', models.PositiveIntegerField(default=0)),
('next_retry_at', models.DateTimeField(blank=True, null=True)),
('response_status', models.PositiveIntegerField(blank=True, null=True)),
('response_payload', models.JSONField(blank=True, default=dict)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dispatch_records', to='base.newslettercampaign')),
],
options={
'ordering': ['-created_at'],
},
),
]

View 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 常用 SSLImplicit TLS587 常用 STARTTLSTLS"),
),
("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\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><p>我們會儘快與您聯繫</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",
),
]

View File

@ -1,4 +1,6 @@
from django import forms as django_forms
from django.db import models
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import (
FieldPanel,
MultiFieldPanel,
@ -27,6 +29,9 @@ from wagtail.snippets.models import register_snippet
from wagtail.fields import StreamField
from wagtail import blocks
from .security import encrypt_text
@register_setting
class HeaderSettings(BaseGenericSetting):
logo_light = models.ForeignKey(
@ -35,7 +40,8 @@ class HeaderSettings(BaseGenericSetting):
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="深色底用(亮色 logo",
help_text=_("Use on dark background (light logo)."),
verbose_name=_("Light Logo"),
)
logo_dark = models.ForeignKey(
"wagtailimages.Image",
@ -43,15 +49,16 @@ class HeaderSettings(BaseGenericSetting):
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="淺色底用(深色 logo",
help_text=_("Use on light background (dark logo)."),
verbose_name=_("Dark Logo"),
)
site_name = models.CharField(max_length=255, blank=True)
site_name = models.CharField(max_length=255, blank=True, verbose_name=_("Site Name"))
extra_links = StreamField([
("link", blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))
], use_json_field=True, blank=True, null=True)
], use_json_field=True, blank=True, null=True, verbose_name=_("Extra Links"))
panels = [
MultiFieldPanel(
@ -61,61 +68,520 @@ class HeaderSettings(BaseGenericSetting):
FieldPanel("site_name"),
FieldPanel("extra_links"),
],
heading="Header Settings",
heading=_("Header Settings"),
),
]
class Meta:
verbose_name = "Header Settings"
verbose_name = _("Header Settings")
@register_setting
class NavigationSettings(BaseGenericSetting):
footer_links = StreamField([
("section", blocks.StructBlock([
("title", blocks.CharBlock(required=False)),
("title", blocks.CharBlock(required=False, label=_("Section Title"))),
("links", blocks.ListBlock(blocks.StructBlock([
("label", blocks.CharBlock()),
("url", blocks.URLBlock())
("label", blocks.CharBlock(label=_("Label"))),
("url", blocks.URLBlock(label=_("URL")))
]))),
]))
], use_json_field=True, blank=True, null=True)
], use_json_field=True, blank=True, null=True, verbose_name=_("Footer Links"))
panels = [
FieldPanel("footer_links"),
]
class Meta:
verbose_name = "Footer Navigation"
verbose_name = _("Footer Navigation")
class SocialLinkBlock(blocks.StructBlock):
SOCIAL_MEDIA_CHOICES = [
("facebook", "Facebook"),
("twitter", "Twitter"),
("instagram", "Instagram"),
("threads", "Threads"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
("facebook", _("Facebook")),
("twitter", _("Twitter")),
("instagram", _("Instagram")),
("threads", _("Threads")),
("linkedin", _("LinkedIn")),
("youtube", _("YouTube")),
]
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES)
url = blocks.URLBlock()
platform = blocks.ChoiceBlock(choices=SOCIAL_MEDIA_CHOICES, label=_("Platform"))
url = blocks.URLBlock(label=_("URL"))
class Meta:
icon = "link"
label = "Social Link"
label = _("Social Link")
@register_setting
class SocialMediaSettings(BaseGenericSetting):
links = StreamField([
("link", SocialLinkBlock()),
], use_json_field=True)
], use_json_field=True, verbose_name=_("Social Links"))
panels = [FieldPanel("links")]
class Meta:
verbose_name = _("Social Media Settings")
@register_setting
class MailSmtpSettings(BaseGenericSetting):
smtp_relay_host = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Relay Host"))
smtp_relay_port = models.PositiveIntegerField(default=587, verbose_name=_("SMTP Relay Port"))
smtp_use_tls = models.BooleanField(default=True, verbose_name=_("Use TLS"))
smtp_use_ssl = models.BooleanField(
default=False,
help_text=_("Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."),
verbose_name=_("Use SSL"),
)
smtp_timeout_seconds = models.PositiveIntegerField(default=15, verbose_name=_("SMTP Timeout Seconds"))
smtp_username = models.CharField(max_length=255, blank=True, verbose_name=_("SMTP Username"))
smtp_password = models.TextField(blank=True, verbose_name=_("SMTP Password"))
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, verbose_name=_("Member Center Base URL"))
member_center_subscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscribe",
verbose_name=_("Subscribe Path"),
)
member_center_confirm_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/confirm",
verbose_name=_("Confirm Path"),
)
member_center_unsubscribe_token_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe-token",
verbose_name=_("Unsubscribe Token Path"),
)
member_center_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/unsubscribe",
verbose_name=_("Unsubscribe Path"),
)
member_center_subscriptions_path = models.CharField(
max_length=255,
blank=True,
default="/newsletter/subscriptions",
verbose_name=_("Subscriptions Path"),
)
member_center_oauth_token_path = models.CharField(
max_length=255,
blank=True,
default="/oauth/token",
verbose_name=_("OAuth Token Path"),
)
member_center_oauth_client_id = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Client ID"))
member_center_oauth_client_secret = models.TextField(blank=True, verbose_name=_("OAuth Client Secret"))
member_center_oauth_scope = models.CharField(
max_length=255,
blank=True,
default="newsletter:list.read",
verbose_name=_("OAuth Scope"),
)
member_center_oauth_audience = models.CharField(max_length=255, blank=True, verbose_name=_("OAuth Audience"))
member_center_one_click_unsubscribe_path = models.CharField(
max_length=255,
blank=True,
default="/api/subscriptions/unsubscribe",
verbose_name=_("One-Click Unsubscribe Path"),
)
member_center_tenant_id = models.CharField(max_length=128, blank=True, verbose_name=_("Tenant ID"))
member_center_list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
member_center_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Member Center Timeout Seconds"))
send_engine_base_url = models.URLField(blank=True, verbose_name=_("Send Engine Base URL"))
send_engine_send_jobs_path = models.CharField(
max_length=255,
blank=True,
default="/api/send-jobs",
verbose_name=_("Send Jobs Path"),
)
send_engine_oauth_scope = models.CharField(max_length=255, blank=True, verbose_name=_("Send Engine OAuth Scope"))
send_engine_timeout_seconds = models.PositiveIntegerField(default=10, verbose_name=_("Send Engine Timeout Seconds"))
send_engine_retry_interval_seconds = models.PositiveIntegerField(default=300, verbose_name=_("Retry Interval Seconds"))
send_engine_retry_max_attempts = models.PositiveIntegerField(default=3, verbose_name=_("Retry Max Attempts"))
sender_name = models.CharField(max_length=255, blank=True, verbose_name=_("Sender Name"))
sender_email = models.EmailField(blank=True, verbose_name=_("Sender Email"))
reply_to_email = models.EmailField(blank=True, verbose_name=_("Reply-To Email"))
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
one_click_endpoint_path = models.CharField(
max_length=255,
blank=True,
default="/u/unsubscribe",
verbose_name=_("One-Click Endpoint Path"),
)
one_click_token_secret = models.CharField(
max_length=255,
blank=True,
help_text=_("One-click token signing secret. Leave blank to use Django SECRET_KEY."),
verbose_name=_("One-Click Token Secret"),
)
one_click_token_ttl_seconds = models.PositiveIntegerField(default=60 * 60 * 24 * 30, verbose_name=_("One-Click Token TTL Seconds"))
site_base_url = models.URLField(
blank=True,
help_text=_("Site base URL for scheduler sends, e.g. https://news.example.com"),
verbose_name=_("Site Base URL"),
)
panels = [
MultiFieldPanel(
[
FieldPanel("member_center_base_url"),
FieldPanel("member_center_subscribe_path"),
FieldPanel("member_center_confirm_path"),
FieldPanel("member_center_unsubscribe_token_path"),
FieldPanel("member_center_unsubscribe_path"),
FieldPanel("member_center_subscriptions_path"),
FieldPanel("member_center_oauth_token_path"),
FieldPanel("member_center_oauth_client_id"),
FieldPanel(
"member_center_oauth_client_secret",
widget=django_forms.PasswordInput(render_value=False),
),
FieldPanel("member_center_oauth_scope"),
FieldPanel("member_center_oauth_audience"),
FieldPanel("member_center_one_click_unsubscribe_path"),
FieldPanel("member_center_tenant_id"),
FieldPanel("member_center_list_id"),
FieldPanel("member_center_timeout_seconds"),
],
heading=_("Member Center"),
),
MultiFieldPanel(
[
FieldPanel("send_engine_base_url"),
FieldPanel("send_engine_send_jobs_path"),
FieldPanel("send_engine_oauth_scope"),
FieldPanel("send_engine_timeout_seconds"),
FieldPanel("send_engine_retry_interval_seconds"),
FieldPanel("send_engine_retry_max_attempts"),
],
heading=_("Send Engine"),
),
MultiFieldPanel(
[
FieldPanel("one_click_endpoint_path"),
FieldPanel("one_click_token_secret"),
FieldPanel("one_click_token_ttl_seconds"),
FieldPanel("site_base_url"),
],
heading=_("List-Unsubscribe One-Click"),
),
MultiFieldPanel(
[
FieldPanel("sender_name"),
FieldPanel("sender_email"),
FieldPanel("reply_to_email"),
FieldPanel("default_charset"),
],
heading=_("Newsletter Mail"),
),
]
class Meta:
verbose_name = _("Newsletter System Settings")
def save(self, *args, **kwargs):
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 ""
elif self.member_center_oauth_client_secret:
self.member_center_oauth_client_secret = encrypt_text(self.member_center_oauth_client_secret)
super().save(*args, **kwargs)
@register_setting
class SystemNotificationMailSettings(BaseGenericSetting):
contact_form_from_name = models.CharField(max_length=255, blank=True, verbose_name=_("Contact Form Sender Name"))
contact_form_from_email = models.EmailField(blank=True, verbose_name=_("Contact Form Sender Email"))
contact_form_reply_to_email = models.EmailField(blank=True, verbose_name=_("Contact Form Reply-To Email"))
contact_form_to_emails = models.TextField(
blank=True,
help_text=_("Multiple recipients separated by comma or newline."),
verbose_name=_("Contact Form Notification Recipients"),
)
contact_form_subject_prefix = models.CharField(max_length=255, blank=True, default="[Contact Us]", verbose_name=_("Contact Form Subject Prefix"))
contact_form_user_subject_template = models.CharField(
max_length=255,
blank=True,
default="已收到您的聯絡表單",
verbose_name=_("User Copy Subject Template"),
)
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"
),
verbose_name=_("User Copy Text Template"),
)
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>"
),
verbose_name=_("User Copy HTML Template"),
)
default_charset = models.CharField(max_length=50, default="utf-8", verbose_name=_("Default Charset"))
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)
list_id = models.CharField(max_length=128, blank=True)
site_id = models.CharField(max_length=128, blank=True)
campaign_id = models.CharField(max_length=128, blank=True)
status = models.CharField(max_length=32, blank=True)
response_status = models.PositiveIntegerField(null=True, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
class NewsletterCampaign(models.Model):
STATUS_DRAFT = "draft"
STATUS_SCHEDULED = "scheduled"
STATUS_SENDING = "sending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_DRAFT, _("Draft")),
(STATUS_SCHEDULED, _("Scheduled")),
(STATUS_SENDING, _("Sending")),
(STATUS_SENT, _("Sent")),
(STATUS_FAILED, _("Failed")),
]
title = models.CharField(max_length=255, verbose_name=_("Title"))
list_id = models.CharField(max_length=128, blank=True, verbose_name=_("List ID"))
subject_template = models.CharField(max_length=255, verbose_name=_("Subject Template"))
html_template = models.TextField(verbose_name=_("HTML Template"))
text_template = models.TextField(blank=True, verbose_name=_("Text Template"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, verbose_name=_("Status"))
scheduled_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Scheduled At"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
last_error = models.TextField(blank=True, verbose_name=_("Last Error"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
panels = [
FieldPanel("title"),
FieldPanel("list_id"),
FieldPanel("subject_template"),
FieldPanel("html_template"),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
]
class Meta:
ordering = ["-created_at"]
verbose_name = _("Newsletter Campaign")
verbose_name_plural = _("Newsletter Campaigns")
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if self._state.adding and not (self.list_id or "").strip():
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if default_list_id:
self.list_id = default_list_id
super().save(*args, **kwargs)
class NewsletterDispatchRecord(models.Model):
campaign = models.ForeignKey(
NewsletterCampaign,
on_delete=models.CASCADE,
related_name="dispatch_records",
verbose_name=_("Campaign"),
)
subscriber_id = models.CharField(max_length=128, blank=True, verbose_name=_("Subscriber ID"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
status = models.CharField(max_length=32, blank=True, verbose_name=_("Status"))
retry_count = models.PositiveIntegerField(default=0, verbose_name=_("Retry Count"))
next_retry_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Next Retry At"))
response_status = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Response Status"))
response_payload = models.JSONField(default=dict, blank=True, verbose_name=_("Response Payload"))
error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
panels = [
FieldPanel("campaign"),
FieldPanel("subscriber_id"),
FieldPanel("email"),
FieldPanel("status"),
FieldPanel("retry_count"),
FieldPanel("next_retry_at"),
FieldPanel("response_status"),
FieldPanel("response_payload"),
FieldPanel("error_message"),
]
class Meta:
ordering = ["-created_at"]
verbose_name = _("Newsletter Dispatch Record")
verbose_name_plural = _("Newsletter Dispatch Records")
def __str__(self):
return f"{self.campaign_id}:{self.email or self.subscriber_id}"
@register_setting
class NewsletterTemplateSettings(BaseGenericSetting):
subscribe_subject_template = models.CharField(
max_length=255,
default="請確認您的電子報訂閱",
verbose_name=_("Subscribe Subject Template"),
)
subscribe_html_template = models.TextField(
default=(
"<p>您好,請點擊以下連結完成訂閱:</p>"
"<p><a href='{{confirm_url}}'>{{confirm_url}}</a></p>"
),
verbose_name=_("Subscribe HTML Template"),
)
subscribe_text_template = models.TextField(
default="您好,請點擊以下連結完成訂閱:{{confirm_url}}",
verbose_name=_("Subscribe Text Template"),
)
confirm_success_template = RichTextField(
blank=True,
default="<p>訂閱確認成功。</p>",
verbose_name=_("Confirm Success Template"),
)
confirm_failure_template = RichTextField(
blank=True,
default="<p>訂閱確認失敗,請稍後再試。</p>",
verbose_name=_("Confirm Failure Template"),
)
unsubscribe_intro_template = RichTextField(
blank=True,
default="<p>確認要退訂電子報嗎?</p>",
verbose_name=_("Unsubscribe Intro Template"),
)
unsubscribe_success_template = RichTextField(
blank=True,
default="<p>已完成退訂。</p>",
verbose_name=_("Unsubscribe Success Template"),
)
unsubscribe_failure_template = RichTextField(
blank=True,
default="<p>退訂失敗,請稍後再試。</p>",
verbose_name=_("Unsubscribe Failure Template"),
)
panels = [
MultiFieldPanel(
[
FieldPanel("subscribe_subject_template"),
FieldPanel("subscribe_html_template"),
FieldPanel("subscribe_text_template"),
],
heading=_("Subscribe Confirmation Email"),
),
MultiFieldPanel(
[
FieldPanel("confirm_success_template"),
FieldPanel("confirm_failure_template"),
FieldPanel("unsubscribe_intro_template"),
FieldPanel("unsubscribe_success_template"),
FieldPanel("unsubscribe_failure_template"),
],
heading=_("Page Templates"),
),
]
class Meta:
verbose_name = _("Newsletter Template Settings")
@register_snippet
class BannerSnippet(models.Model):
key = models.CharField(max_length=50, blank=True, help_text="識別用 key例如 home / category")
title = models.CharField(max_length=255, blank=True)
key = models.CharField(max_length=50, blank=True, help_text=_("Identifier key, e.g. home / category"), verbose_name=_("Key"))
title = models.CharField(max_length=255, blank=True, verbose_name=_("Title"))
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@ -124,9 +590,9 @@ class BannerSnippet(models.Model):
related_name="+",
)
link_url = models.URLField(blank=True)
link_text = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
link_text = models.CharField(max_length=100, blank=True, verbose_name=_("Link Text"))
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
sort_order = models.PositiveIntegerField(default=0, verbose_name=_("Sort Order"))
panels = [
FieldPanel("key"),
@ -140,8 +606,8 @@ class BannerSnippet(models.Model):
class Meta:
ordering = ["sort_order", "id"]
verbose_name = "Banner"
verbose_name_plural = "Banners"
verbose_name = _("Banner")
verbose_name_plural = _("Banners")
def __str__(self):
return self.title or f"Banner {self.pk}"
@ -172,4 +638,35 @@ class FooterText(
return {"footer_text": self.body}
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, _("Collaboration")),
(CATEGORY_WEBSITE_ISSUE, _("Website Issue")),
(CATEGORY_CAREER, _("Career")),
(CATEGORY_OTHER, _("Other")),
]
name = models.CharField(max_length=100, verbose_name=_("Name"))
email = models.EmailField(blank=True, verbose_name=_("Email"))
contact = models.CharField(max_length=255, verbose_name=_("Contact"))
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, verbose_name=_("Category"))
message = models.TextField(verbose_name=_("Message"))
source_page = models.CharField(max_length=512, blank=True, verbose_name=_("Source Page"))
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP Address"))
user_agent = models.TextField(blank=True, verbose_name=_("User Agent"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
class Meta:
ordering = ["-created_at"]
verbose_name = _("Contact Form Submission")
verbose_name_plural = _("Contact Form Submissions")
def __str__(self):
return f"{self.get_category_display()} - {self.name}"

View File

@ -0,0 +1,589 @@
import json
import logging
import hmac
import re
import secrets
import time
from dataclasses import dataclass
from email.utils import formataddr
from hashlib import sha256
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from wagtail.rich_text import expand_db_html
from .models import MailSmtpSettings, NewsletterSystemSettings, SystemNotificationMailSettings
from .security import decrypt_text
logger = logging.getLogger(__name__)
PLACEHOLDER_KEYS = (
"token",
"email",
"list_id",
"tenant_id",
"confirm_url",
"unsubscribe_url",
)
@dataclass
class APIResult:
ok: bool
status: int
data: dict
error: str = ""
class MemberCenterClient:
def __init__(self, config: NewsletterSystemSettings):
self.config = config
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
def subscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_subscribe_path, payload)
def confirm(self, token: str) -> APIResult:
return self._get(self.config.member_center_confirm_path, {"token": token})
def request_unsubscribe_token(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_token_path, payload)
def unsubscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_unsubscribe_path, payload)
def one_click_unsubscribe(self, payload: dict) -> APIResult:
return self._post(self.config.member_center_one_click_unsubscribe_path, payload)
def list_subscriptions(self, list_id: str) -> APIResult:
auth_headers, auth_error = self._auth_headers()
if auth_error:
return APIResult(ok=False, status=0, data={}, error=auth_error)
return self._get(
self.config.member_center_subscriptions_path,
{"list_id": list_id},
headers=auth_headers,
)
def _get(self, path: str, query: dict, headers: dict | None = None) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
if query:
endpoint = f"{endpoint}?{urlencode(query)}"
request = Request(
endpoint,
headers={"Accept": "application/json", **(headers or {})},
method="GET",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def _post(self, path: str, payload: dict, headers: dict | None = None) -> APIResult:
base_url = (self.config.member_center_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="member_center_base_url is empty")
endpoint = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
body = json.dumps(payload).encode("utf-8")
request = Request(
endpoint,
data=body,
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def _auth_headers(self) -> tuple[dict, str]:
scope = (self.config.member_center_oauth_scope or "").strip() or "newsletter:list.read"
token, error = self._get_oauth_access_token(required_scope=scope)
if not token:
return {}, error or "member center oauth token is empty"
return {"Authorization": f"Bearer {token}"}, ""
def _get_oauth_access_token(self, required_scope: str) -> tuple[str, str]:
scope = (required_scope or "").strip()
now = int(time.time())
cached = self._oauth_token_cache.get(scope)
if cached and now < cached[1] - 30:
return cached[0], ""
base_url = (self.config.member_center_base_url or "").strip()
if not base_url:
return "", "member_center_base_url is empty (cannot resolve oauth token url)"
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
token_url = urljoin(f"{base_url.rstrip('/')}/", token_path.lstrip("/"))
client_id = (self.config.member_center_oauth_client_id or "").strip()
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
client_secret = ""
if encrypted_secret:
try:
client_secret = decrypt_text(encrypted_secret)
except Exception:
client_secret = encrypted_secret
if not client_id or not client_secret:
return "", "member center oauth client_id/client_secret is empty"
body = urlencode(
{
k: v
for k, v in {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
"audience": (self.config.member_center_oauth_audience or "").strip(),
}.items()
if v
}
).encode("utf-8")
request = Request(
token_url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
timeout = max(1, int(self.config.member_center_timeout_seconds or 10))
result = self._send(request, timeout=timeout)
if not result.ok:
return "", f"oauth token request failed: HTTP {result.status} {result.error}".strip()
access_token = str(result.data.get("access_token") or "").strip()
if not access_token:
return "", "oauth token response missing access_token"
expires_in = int(result.data.get("expires_in") or 300)
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
return access_token, ""
def _send(self, request: Request, timeout: int) -> APIResult:
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
status = getattr(response, "status", 200)
return APIResult(ok=200 <= status < 300, status=status, data=data)
except HTTPError as exc:
raw = exc.read().decode("utf-8") if exc.fp else ""
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {}
return APIResult(ok=False, status=exc.code, data=data, error=str(exc))
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
return APIResult(ok=False, status=0, data={}, error=str(exc))
def render_placeholders(template: str, values: dict) -> str:
rendered = template or ""
for key in PLACEHOLDER_KEYS:
rendered = rendered.replace(f"{{{{{key}}}}}", str(values.get(key, "")))
return rendered
def _absolutize_links(html: str, site_base_url: str) -> str:
base = (site_base_url or "").strip().rstrip("/")
if not base:
return html
def _replace(match):
prefix = match.group("prefix")
url = match.group("url")
return f"{prefix}{urljoin(f'{base}/', url.lstrip('/'))}"
pattern = re.compile(r"(?P<prefix>\b(?:src|href)=['\"])(?P<url>/[^'\"]*)")
return pattern.sub(_replace, html)
def render_newsletter_html(template: str, values: dict, site_base_url: str = "") -> str:
rendered = render_placeholders(template, values)
try:
rendered = expand_db_html(rendered)
except Exception:
pass
return _absolutize_links(rendered, site_base_url)
def render_newsletter_html_for_send_job(template: str, site_base_url: str = "") -> str:
rendered = template or ""
try:
rendered = expand_db_html(rendered)
except Exception:
pass
return _absolutize_links(rendered, site_base_url)
def extract_token(payload: dict) -> str:
if not payload:
return ""
for key in ("token", "confirm_token", "unsubscribe_token"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
data = payload.get("data")
if isinstance(data, dict):
for key in ("token", "confirm_token", "unsubscribe_token"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def build_from_email(sender_name: str, sender_email: str) -> str:
if sender_name and sender_email:
return formataddr((sender_name, sender_email))
return sender_email
def resolve_one_click_secret(config: NewsletterSystemSettings) -> str:
return (config.one_click_token_secret or "").strip() or settings.SECRET_KEY
def _b64url_encode(data: bytes) -> str:
import base64
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(data: str) -> bytes:
import base64
padding = "=" * ((4 - len(data) % 4) % 4)
return base64.urlsafe_b64decode((data + padding).encode("ascii"))
def generate_one_click_token(*, subscriber_id: str, list_id: str, site_id: str, campaign_id: str, secret: str, ttl_seconds: int) -> str:
payload = {
"subscriber_id": subscriber_id,
"list_id": list_id,
"site_id": site_id,
"campaign_id": campaign_id,
"exp": int(time.time()) + max(1, int(ttl_seconds)),
"nonce": secrets.token_urlsafe(8),
}
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
payload_part = _b64url_encode(payload_json)
sig = hmac.new(secret.encode("utf-8"), payload_part.encode("ascii"), sha256).digest()
sig_part = _b64url_encode(sig)
return f"{payload_part}.{sig_part}"
def verify_one_click_token(token: str, secret: str) -> tuple[dict | None, str | None]:
if not token or "." not in token:
return None, "invalid"
payload_part, sig_part = token.split(".", 1)
try:
received_sig = _b64url_decode(sig_part)
except Exception:
return None, "invalid"
expected_sig = hmac.new(secret.encode("utf-8"), payload_part.encode("ascii"), sha256).digest()
if not hmac.compare_digest(received_sig, expected_sig):
return None, "invalid"
try:
payload = json.loads(_b64url_decode(payload_part).decode("utf-8"))
except Exception:
return None, "invalid"
required_keys = ("subscriber_id", "list_id", "site_id", "exp")
if any(not payload.get(key) for key in required_keys):
return None, "invalid"
if int(payload.get("exp", 0)) < int(time.time()):
return None, "expired"
return payload, None
def build_one_click_unsubscribe_url(*, site_base_url: str, endpoint_path: str, token: str) -> str:
base = site_base_url.rstrip("/")
endpoint = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
return f"{base}{endpoint}?{urlencode({'token': token})}"
def build_list_unsubscribe_headers(*, one_click_url: str) -> dict:
return {
"List-Unsubscribe": f"<{one_click_url}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
class SendEngineClient:
def __init__(self, config: NewsletterSystemSettings):
self.config = config
self._oauth_token_cache: dict[str, tuple[str, int]] = {}
def create_send_job(self, payload: dict) -> APIResult:
base_url = (self.config.send_engine_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="send_engine_base_url is empty")
endpoint = urljoin(f"{base_url.rstrip('/')}/", self.config.send_engine_send_jobs_path.lstrip("/"))
body = json.dumps(payload).encode("utf-8")
auth_headers, auth_error = self._auth_headers(required_scope="newsletter:send.write")
if auth_error:
return APIResult(ok=False, status=0, data={}, error=auth_error)
headers = {"Content-Type": "application/json", **auth_headers}
request = Request(endpoint, data=body, headers=headers, method="POST")
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def get_send_job(self, send_job_id: str) -> APIResult:
base_url = (self.config.send_engine_base_url or "").strip()
if not base_url:
return APIResult(ok=False, status=0, data={}, error="send_engine_base_url is empty")
if not send_job_id:
return APIResult(ok=False, status=0, data={}, error="send_job_id is empty")
endpoint = urljoin(
f"{base_url.rstrip('/')}/",
f"{self.config.send_engine_send_jobs_path.lstrip('/')}/{send_job_id}",
)
auth_headers, auth_error = self._auth_headers(required_scope="newsletter:send.read")
if auth_error:
return APIResult(ok=False, status=0, data={}, error=auth_error)
headers = {"Accept": "application/json", **auth_headers}
request = Request(endpoint, headers=headers, method="GET")
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
return self._send(request, timeout=timeout)
def _auth_headers(self, required_scope: str = "") -> tuple[dict, str]:
token, error = self._get_member_center_access_token(required_scope=required_scope)
if error:
return {}, error
return {"Authorization": f"Bearer {token}"}, ""
def _get_member_center_access_token(self, required_scope: str = "") -> tuple[str, str]:
scope = (
(required_scope or "").strip()
or (self.config.send_engine_oauth_scope or "").strip()
or "newsletter:send.write"
)
now = int(time.time())
cached = self._oauth_token_cache.get(scope)
if cached and now < cached[1] - 30:
return cached[0], ""
member_center_base_url = (self.config.member_center_base_url or "").strip()
if not member_center_base_url:
return "", "member_center_base_url is empty (cannot resolve oauth token url)"
token_path = (self.config.member_center_oauth_token_path or "/oauth/token").strip()
token_url = urljoin(f"{member_center_base_url.rstrip('/')}/", token_path.lstrip("/"))
client_id = (self.config.member_center_oauth_client_id or "").strip()
encrypted_secret = (self.config.member_center_oauth_client_secret or "").strip()
client_secret = ""
if encrypted_secret:
try:
client_secret = decrypt_text(encrypted_secret)
except Exception:
client_secret = encrypted_secret
if not client_id or not client_secret:
return ("", "member center oauth client_id/client_secret is empty")
body = urlencode(
{
k: v
for k, v in {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
"audience": (self.config.member_center_oauth_audience or "").strip(),
}.items()
if v
}
).encode("utf-8")
request = Request(
token_url,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
timeout = max(1, int(self.config.send_engine_timeout_seconds or 10))
result = self._send(request, timeout=timeout)
if not result.ok:
return "", f"oauth token request failed: HTTP {result.status} {result.error}".strip()
access_token = str(result.data.get("access_token") or "").strip()
if not access_token:
return "", "oauth token response missing access_token"
expires_in = int(result.data.get("expires_in") or 300)
self._oauth_token_cache[scope] = (access_token, int(time.time()) + max(60, expires_in))
return access_token, ""
def _send(self, request: Request, timeout: int) -> APIResult:
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
status = getattr(response, "status", 200)
return APIResult(ok=200 <= status < 300, status=status, data=data)
except HTTPError as exc:
raw = exc.read().decode("utf-8") if exc.fp else ""
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {}
return APIResult(ok=False, status=exc.code, data=data, error=str(exc))
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
return APIResult(ok=False, status=0, data={}, error=str(exc))
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 smtp_config.smtp_use_tls and smtp_config.smtp_use_ssl:
raise ValueError("SMTP TLS and SSL cannot both be enabled.")
password = ""
encrypted_password = (smtp_config.smtp_password or "").strip()
if encrypted_password:
try:
password = decrypt_text(encrypted_password)
except Exception:
password = ""
return get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
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(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,
from_email=from_email or settings.DEFAULT_FROM_EMAIL,
to=[to_email],
reply_to=reply_to,
connection=connection,
)
if html_body:
message.attach_alternative(html_body, "text/html")
charset = (config.default_charset or "utf-8").strip() or "utf-8"
message.encoding = charset
sent_count = message.send(fail_silently=False)
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,
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)

View File

@ -0,0 +1,350 @@
import time
from django.utils import timezone
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
from .newsletter import SendEngineClient, render_newsletter_html_for_send_job
TERMINAL_SEND_JOB_STATUSES = {"completed", "failed", "cancelled"}
SUCCESS_SEND_JOB_STATUSES = {"completed"}
def _normalize_status(value: str) -> str:
return (value or "").strip().lower()
def _is_terminal_status(value: str) -> bool:
return _normalize_status(value) in TERMINAL_SEND_JOB_STATUSES
def _is_success_status(value: str) -> bool:
return _normalize_status(value) in SUCCESS_SEND_JOB_STATUSES
def _build_send_job_payload(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings, list_id: str) -> dict:
site_base_url = (settings_obj.site_base_url or "").strip().rstrip("/")
body_html = render_newsletter_html_for_send_job(campaign.html_template, site_base_url=site_base_url)
body_text = (campaign.text_template or "").strip()
payload = {
"list_id": list_id,
"name": campaign.title,
"subject": campaign.subject_template,
}
tenant_id = (settings_obj.member_center_tenant_id or "").strip()
if tenant_id:
payload["tenant_id"] = tenant_id
if body_html:
payload["body_html"] = body_html
if body_text:
payload["body_text"] = body_text
if site_base_url:
one_click_path = (settings_obj.one_click_endpoint_path or "/u/unsubscribe").strip()
if not one_click_path.startswith("/"):
one_click_path = f"/{one_click_path}"
unsubscribe_url_template = f"{site_base_url}{one_click_path}?token={{{{unsubscribe_token}}}}"
payload["template"] = {
# Send Engine uses this key to emit List-Unsubscribe headers.
"list_unsubscribe_url_template": unsubscribe_url_template,
}
return payload
def _create_dispatch_record(
*,
campaign: NewsletterCampaign,
status: str,
retry_count: int,
response_status: int | None,
response_payload: dict,
error_message: str = "",
):
NewsletterDispatchRecord.objects.create(
campaign=campaign,
status=status,
retry_count=max(0, int(retry_count)),
response_status=response_status,
response_payload=response_payload or {},
error_message=error_message or "",
)
def _schedule_retry(*, campaign: NewsletterCampaign, attempt: int, response_status: int, response_payload: dict, error_message: str, settings_obj: NewsletterSystemSettings):
max_attempts = max(0, int(settings_obj.send_engine_retry_max_attempts or 0))
interval = max(1, int(settings_obj.send_engine_retry_interval_seconds or 300))
if attempt >= max_attempts:
return
NewsletterDispatchRecord.objects.create(
campaign=campaign,
status="retry_scheduled",
retry_count=attempt + 1,
next_retry_at=timezone.now() + timezone.timedelta(seconds=interval),
response_status=response_status,
response_payload=response_payload or {},
error_message=error_message or "",
)
def _poll_send_job_to_terminal(*, send_engine: SendEngineClient, send_job_id: str, poll_interval_seconds: int = 3, max_polls: int = 40) -> tuple[bool, int, dict, str]:
history = []
last_status = ""
for _ in range(max(1, int(max_polls))):
poll = send_engine.get_send_job(send_job_id)
if not poll.ok:
error = poll.error
if poll.status in (401, 403):
error = (
f"send engine auth failed when polling send job (HTTP {poll.status}); "
"token may miss scope newsletter:send.read"
)
return False, poll.status, {"send_job_id": send_job_id, "poll_history": history, "last_response": poll.data}, error
status = _normalize_status(str(poll.data.get("status", "")))
last_status = status
history.append(
{
"polled_at": timezone.now().isoformat(),
"status": status,
"response_status": poll.status,
}
)
if _is_terminal_status(status):
return True, poll.status, {"send_job_id": send_job_id, "send_job_status": status, "poll_history": history, "send_job": poll.data}, ""
time.sleep(max(1, int(poll_interval_seconds)))
return (
False,
0,
{"send_job_id": send_job_id, "send_job_status": last_status or "unknown", "poll_history": history},
f"send job did not reach terminal status after {max(1, int(max_polls))} polls",
)
def _dispatch_once(*, campaign: NewsletterCampaign, settings_obj: NewsletterSystemSettings) -> dict:
send_engine = SendEngineClient(settings_obj)
list_id = (campaign.list_id or settings_obj.member_center_list_id or "").strip()
if not list_id:
return {
"ok": False,
"response_status": 0,
"response_payload": {},
"error": "list_id is empty",
"send_job_status": "",
}
payload = _build_send_job_payload(campaign=campaign, settings_obj=settings_obj, list_id=list_id)
if not payload.get("body_html") and not payload.get("body_text") and not payload.get("template"):
return {
"ok": False,
"response_status": 0,
"response_payload": {},
"error": "body_html/body_text/template must provide at least one value",
"send_job_status": "",
}
create_result = send_engine.create_send_job(payload)
if not create_result.ok:
create_error = create_result.error
if create_result.status in (401, 403):
create_error = (
f"send engine auth failed when creating send job (HTTP {create_result.status}); "
"verify bearer token and scope newsletter:send.write"
)
return {
"ok": False,
"response_status": create_result.status,
"response_payload": {"create_response": create_result.data, "payload": payload},
"error": create_error,
"send_job_status": "",
}
send_job_id = str(create_result.data.get("send_job_id") or create_result.data.get("id") or "").strip()
create_status = _normalize_status(str(create_result.data.get("status", "")))
response_payload = {
"payload": payload,
"create_response": create_result.data,
"send_job_id": send_job_id,
"send_job_status": create_status,
}
if not send_job_id:
return {
"ok": False,
"response_status": create_result.status,
"response_payload": response_payload,
"error": "send_job_id missing from create response",
"send_job_status": create_status,
}
if _is_terminal_status(create_status):
return {
"ok": _is_success_status(create_status),
"response_status": create_result.status,
"response_payload": response_payload,
"error": "" if _is_success_status(create_status) else f"send job terminal status: {create_status}",
"send_job_status": create_status,
}
poll_ok, poll_status, poll_payload, poll_error = _poll_send_job_to_terminal(
send_engine=send_engine,
send_job_id=send_job_id,
)
merged_payload = {**response_payload, **poll_payload}
final_status = _normalize_status(str(merged_payload.get("send_job_status", "")))
if poll_ok and _is_success_status(final_status):
return {
"ok": True,
"response_status": poll_status,
"response_payload": merged_payload,
"error": "",
"send_job_status": final_status,
}
error_message = poll_error or f"send job terminal status: {final_status or 'unknown'}"
return {
"ok": False,
"response_status": poll_status,
"response_payload": merged_payload,
"error": error_message,
"send_job_status": final_status,
}
def dispatch_campaign(campaign: NewsletterCampaign, schedule_retry_on_failure: bool = True) -> dict:
settings_obj = NewsletterSystemSettings.load()
campaign.status = NewsletterCampaign.STATUS_SENDING
campaign.last_error = ""
campaign.save(update_fields=["status", "last_error", "updated_at"])
result = _dispatch_once(campaign=campaign, settings_obj=settings_obj)
if result["ok"]:
campaign.sent_at = timezone.now()
campaign.status = NewsletterCampaign.STATUS_SENT
campaign.last_error = ""
campaign.save(update_fields=["sent_at", "status", "last_error", "updated_at"])
_create_dispatch_record(
campaign=campaign,
status="sent",
retry_count=0,
response_status=result["response_status"],
response_payload=result["response_payload"],
)
return {"sent": 1, "failed": 0, "error": "", "send_job_status": result["send_job_status"]}
campaign.status = NewsletterCampaign.STATUS_FAILED
campaign.last_error = result["error"]
campaign.save(update_fields=["status", "last_error", "updated_at"])
_create_dispatch_record(
campaign=campaign,
status="failed",
retry_count=0,
response_status=result["response_status"],
response_payload=result["response_payload"],
error_message=result["error"],
)
if schedule_retry_on_failure:
_schedule_retry(
campaign=campaign,
attempt=0,
response_status=result["response_status"],
response_payload=result["response_payload"],
error_message=result["error"],
settings_obj=settings_obj,
)
return {"sent": 0, "failed": 1, "error": result["error"], "send_job_status": result["send_job_status"]}
def process_due_retries(limit: int = 200) -> dict:
settings_obj = NewsletterSystemSettings.load()
now = timezone.now()
due_retries = list(
NewsletterDispatchRecord.objects.filter(
status="retry_scheduled",
next_retry_at__isnull=False,
next_retry_at__lte=now,
)
.select_related("campaign")
.order_by("next_retry_at", "id")[: max(1, int(limit))]
)
sent = 0
failed = 0
processed = 0
for retry in due_retries:
processed += 1
campaign = retry.campaign
retry.status = "retry_consumed"
retry.save(update_fields=["status"])
if campaign.status == NewsletterCampaign.STATUS_SENT:
continue
result = _dispatch_once(campaign=campaign, settings_obj=settings_obj)
if result["ok"]:
sent += 1
campaign.sent_at = timezone.now()
campaign.status = NewsletterCampaign.STATUS_SENT
campaign.last_error = ""
campaign.save(update_fields=["sent_at", "status", "last_error", "updated_at"])
_create_dispatch_record(
campaign=campaign,
status="sent",
retry_count=retry.retry_count,
response_status=result["response_status"],
response_payload=result["response_payload"],
)
else:
failed += 1
campaign.status = NewsletterCampaign.STATUS_FAILED
campaign.last_error = result["error"]
campaign.save(update_fields=["status", "last_error", "updated_at"])
_create_dispatch_record(
campaign=campaign,
status="failed",
retry_count=retry.retry_count,
response_status=result["response_status"],
response_payload=result["response_payload"],
error_message=result["error"],
)
_schedule_retry(
campaign=campaign,
attempt=retry.retry_count,
response_status=result["response_status"],
response_payload=result["response_payload"],
error_message=result["error"],
settings_obj=settings_obj,
)
return {"processed_retries": processed, "sent_retries": sent, "failed_retries": failed}
def dispatch_due_campaigns(limit: int = 20) -> dict:
retry_result = process_due_retries(limit=200)
now = timezone.now()
due_campaigns = list(
NewsletterCampaign.objects.filter(
status=NewsletterCampaign.STATUS_SCHEDULED,
scheduled_at__isnull=False,
scheduled_at__lte=now,
)
.order_by("scheduled_at", "id")[: max(1, int(limit))]
)
total_sent = 0
total_failed = 0
processed = 0
for campaign in due_campaigns:
processed += 1
result = dispatch_campaign(campaign)
total_sent += int(result.get("sent", 0))
if result.get("failed", 0):
total_failed += 1
return {
"processed_campaigns": processed,
"sent_recipients": total_sent,
"failed_campaigns": total_failed,
**retry_result,
}

View File

@ -0,0 +1,71 @@
import base64
import hmac
import os
from hashlib import sha256
from django.conf import settings
from django.utils.crypto import pbkdf2
_VERSION = b"v1"
_SALT_SIZE = 16
_TAG_SIZE = 32
_PREFIX = "enc1:"
def _derive_key(salt: bytes) -> bytes:
return pbkdf2(settings.SECRET_KEY, salt, 260000, digest=sha256)
def _keystream(key: bytes, length: int) -> bytes:
out = bytearray()
counter = 0
while len(out) < length:
block = sha256(key + counter.to_bytes(4, "big")).digest()
out.extend(block)
counter += 1
return bytes(out[:length])
def encrypt_text(plaintext: str) -> str:
if not plaintext:
return ""
raw = plaintext.encode("utf-8")
salt = os.urandom(_SALT_SIZE)
key = _derive_key(salt)
stream = _keystream(key, len(raw))
ciphertext = bytes(a ^ b for a, b in zip(raw, stream))
tag = hmac.new(key, ciphertext, sha256).digest()
payload = _VERSION + salt + tag + ciphertext
return _PREFIX + base64.urlsafe_b64encode(payload).decode("ascii")
def decrypt_text(token: str) -> str:
if not token:
return ""
if not token.startswith(_PREFIX):
return token
payload = base64.urlsafe_b64decode(token[len(_PREFIX) :].encode("ascii"))
version = payload[:2]
if version != _VERSION:
raise ValueError("Unsupported encrypted payload version")
salt_start = 2
salt_end = salt_start + _SALT_SIZE
salt = payload[salt_start:salt_end]
tag_start = salt_end
tag_end = tag_start + _TAG_SIZE
tag = payload[tag_start:tag_end]
ciphertext = payload[tag_end:]
key = _derive_key(salt)
expected = hmac.new(key, ciphertext, sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("Encrypted payload validation failed")
stream = _keystream(key, len(ciphertext))
raw = bytes(a ^ b for a, b in zip(ciphertext, stream))
return raw.decode("utf-8")

View File

@ -0,0 +1,173 @@
.newsletter-page {
background: #0e1b42 url("../img/newsletter_bg.png") center center / cover no-repeat fixed;
}
.newsletter-page .site-main > .site-container {
padding-top: 64px;
padding-bottom: 64px;
min-height: 400px;
}
.newsletter-status {
max-width: 640px;
margin: 0 auto;
padding: 24px;
color: #ffffff;
}
.newsletter-status h1 {
font-size: 32px;
margin-top: 0;
}
.newsletter-status p {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
.newsletter-status-message {
margin-bottom: 16px;
}
.newsletter-unsubscribe-form label {
display: block;
margin-bottom: 8px;
}
.newsletter-unsubscribe-form input[type="email"],
.newsletter-unsubscribe-form input[type="text"] {
width: 300px;
padding: 8px;
border: 0;
background: #ffffff4d;
color: #ffffff;
}
.newsletter-unsubscribe-form button {
width: 131px;
height: 49px;
border: 0;
background: #ffffff;
font-size: 14px;
font-weight: 500;
color: #000000;
padding: 10px 16px;
cursor: pointer;
margin-top: 12px;
}
.newsletter-back-link {
display: inline-flex;
align-items: center;
gap: 16px;
margin-top: 20px;
color: #ffffff;
text-decoration: none;
}
.newsletter-back-link__icon {
position: relative;
width: 52px;
height: 52px;
background: #ffffff80;
}
.newsletter-back-link__icon::before,
.newsletter-back-link__icon::after {
content: "";
position: absolute;
left: 18px;
width: 15px;
height: 1.5px;
background: #0e1b42;
}
.newsletter-back-link__icon::before {
top: 21px;
transform: rotate(-45deg);
}
.newsletter-back-link__icon::after {
top: 31px;
transform: rotate(45deg);
}
.newsletter-back-link__text {
font-size: 16px;
line-height: 1.2;
}
@media (min-width: 575px) and (max-width: 767px) {
.newsletter-page .site-main > .site-container {
min-height: 360px;
}
.newsletter-status {
padding: 16px;
}
.newsletter-status h1 {
font-size: 24px;
}
.newsletter-status p {
font-size: 16px;
}
.newsletter-unsubscribe-form input[type="email"],
.newsletter-unsubscribe-form input[type="text"] {
width: 240px;
}
}
@media (max-width: 574px) {
.newsletter-page .site-main > .site-container {
min-height: 320px;
}
.newsletter-status {
padding: 16px;
}
.newsletter-status h1 {
font-size: 20px;
}
.newsletter-status p {
font-size: 14px;
}
.newsletter-unsubscribe-form input[type="email"],
.newsletter-unsubscribe-form input[type="text"] {
width: 260px;
}
.newsletter-unsubscribe-form button {
width: 90px;
height: 31px;
padding: 4px 16px;
}
.newsletter-back-link__icon {
width: 40px;
height: 40px;
}
.newsletter-back-link__icon::before {
top: 15px;
}
.newsletter-back-link__icon::after {
top: 25px;
}
.newsletter-back-link__icon::before,
.newsletter-back-link__icon::after {
left: 12px;
}
.newsletter-back-link__text {
font-size: 14px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}template-darkbackground newsletter-page{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/newsletter.css' %}">
{% endblock %}
{% block content %}
<section class="newsletter-status{% if success %} is-success{% else %} is-failure{% endif %}">
<h1>{{ title }}</h1>
<div class="newsletter-status-message">{{ message|safe }}</div>
<a class="newsletter-back-link" href="/">
<span class="newsletter-back-link__icon" aria-hidden="true"></span>
<span class="newsletter-back-link__text">回到首頁</span>
</a>
</section>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}template-darkbackground newsletter-page{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/newsletter.css' %}">
{% endblock %}
{% block content %}
<section class="newsletter-status{% if can_submit %} is-warning{% else %} is-failure{% endif %}">
<h1>很遺憾聽到您取消電子報訂閱</h1>
<div class="newsletter-status-message">{{ intro_message|safe }}</div>
{% if can_submit %}
<form method="post" action="{% url 'newsletter_unsubscribe' %}" class="newsletter-unsubscribe-form">
{% csrf_token %}
{{ form.email }}
{{ form.token }}
<button type="submit">確認退訂</button>
</form>
{% else %}
<p>退訂連結已失效或缺少必要參數。</p>
{% endif %}
<a class="newsletter-back-link" href="/">
<span class="newsletter-back-link__icon" aria-hidden="true"></span>
<span class="newsletter-back-link__text">回到首頁</span>
</a>
</section>
{% endblock %}

View File

@ -1,3 +1,114 @@
from django.test import TestCase
from django.urls import reverse
# Create your tests here.
from .newsletter import (
extract_token,
generate_one_click_token,
render_placeholders,
render_newsletter_html,
verify_one_click_token,
)
from .models import ContactFormSubmission
from .security import decrypt_text, encrypt_text
class NewsletterTemplateTests(TestCase):
def test_render_placeholders_replaces_known_keys(self):
template = "confirm={{confirm_url}} email={{email}} token={{token}}"
rendered = render_placeholders(
template,
{
"confirm_url": "https://example.com/confirm?token=abc",
"email": "demo@example.com",
"token": "abc",
},
)
self.assertEqual(
rendered,
"confirm=https://example.com/confirm?token=abc email=demo@example.com token=abc",
)
def test_extract_token_supports_top_level_and_nested_data(self):
self.assertEqual(extract_token({"token": "t1"}), "t1")
self.assertEqual(extract_token({"data": {"unsubscribe_token": "t2"}}), "t2")
self.assertEqual(extract_token({}), "")
def test_encrypt_decrypt_roundtrip(self):
secret = "mailrelay-secret"
encrypted = encrypt_text(secret)
self.assertTrue(encrypted.startswith("enc1:"))
self.assertNotEqual(encrypted, secret)
self.assertEqual(decrypt_text(encrypted), secret)
def test_one_click_token_roundtrip(self):
token = generate_one_click_token(
subscriber_id="sub-1",
list_id="list-1",
site_id="site-1",
campaign_id="cmp-1",
secret="test-secret",
ttl_seconds=60,
)
payload, error = verify_one_click_token(token, "test-secret")
self.assertIsNone(error)
self.assertEqual(payload["subscriber_id"], "sub-1")
self.assertEqual(payload["list_id"], "list-1")
def test_one_click_token_invalid_signature(self):
token = generate_one_click_token(
subscriber_id="sub-1",
list_id="list-1",
site_id="site-1",
campaign_id="cmp-1",
secret="test-secret",
ttl_seconds=60,
)
payload, error = verify_one_click_token(token, "other-secret")
self.assertIsNone(payload)
self.assertEqual(error, "invalid")
def test_render_newsletter_html_absolutizes_relative_links(self):
rendered = render_newsletter_html(
'<p><a href="/a">A</a><img src="/media/x.jpg"></p>',
values={},
site_base_url="https://news.example.com",
)
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)

View File

@ -1,3 +1,520 @@
from django.shortcuts import render
import logging
from urllib.parse import urlencode
import hashlib
import json
# Create your views here.
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.core.exceptions import ValidationError
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 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(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):
return {
"title": title,
"message": message,
"success": success,
}
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):
token = (request.GET.get("token") or "").strip()
if token:
return token
if request.method == "POST":
token = (request.POST.get("token") or "").strip()
if token:
return token
if request.body:
try:
body = json.loads(request.body.decode("utf-8"))
token = (body.get("token") or "").strip()
if token:
return token
except Exception:
pass
return ""
def _handle_one_click_unsubscribe(*, request, token):
system_settings, _, _ = _load_settings(request)
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() if token else ""
if not token:
return False, 400, "退訂連結無效。"
existing = OneClickUnsubscribeAudit.objects.filter(
token_hash=token_hash,
status__in=["success", "already_unsubscribed"],
).first()
if existing:
return True, 200, "您已完成退訂。"
result = MemberCenterClient(system_settings).unsubscribe({"token": token})
response_data = result.data if isinstance(result.data, dict) else {}
if result.ok:
already = bool(response_data.get("already_unsubscribed"))
success = bool(response_data.get("success", True)) or already
if success:
OneClickUnsubscribeAudit.objects.update_or_create(
token_hash=token_hash,
defaults={
"subscriber_id": "",
"list_id": "",
"site_id": "",
"campaign_id": "",
"status": "already_unsubscribed" if already else "success",
"response_status": result.status,
"response_payload": response_data,
},
)
return True, 200, "您已完成退訂。"
OneClickUnsubscribeAudit.objects.update_or_create(
token_hash=token_hash,
defaults={
"subscriber_id": "",
"list_id": "",
"site_id": "",
"campaign_id": "",
"status": "failed",
"response_status": result.status,
"response_payload": response_data,
"error_message": result.error,
},
)
return False, 502, "退訂服務暫時無法使用。"
@require_http_methods(["POST"])
def newsletter_subscribe(request):
form = NewsletterSubscribeForm(request.POST)
system_settings, template_settings, smtp_settings = _load_settings(request)
if not form.is_valid():
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="請輸入正確 email。",
success=False,
),
status=400,
)
email = form.cleaned_data["email"].strip().lower()
client = MemberCenterClient(system_settings)
subscribe_payload = {
"email": email,
"list_id": system_settings.member_center_list_id,
"source": "wagtail",
}
subscribe_result = client.subscribe(subscribe_payload)
if not subscribe_result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="訂閱服務暫時無法使用,請稍後再試。",
success=False,
),
status=502,
)
token = extract_token(subscribe_result.data)
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm"))
unsubscribe_url = request.build_absolute_uri(reverse("newsletter_unsubscribe"))
if token:
query = urlencode({"token": token, "email": email})
confirm_url = f"{confirm_url}?{query}"
unsubscribe_url = f"{unsubscribe_url}?{query}"
values = {
"token": token,
"email": email,
"list_id": system_settings.member_center_list_id,
"tenant_id": system_settings.member_center_tenant_id,
"confirm_url": confirm_url,
"unsubscribe_url": unsubscribe_url,
}
try:
send_subscribe_email(
to_email=email,
subject=render_placeholders(template_settings.subscribe_subject_template, values),
text_body=render_placeholders(template_settings.subscribe_text_template, values),
html_body=render_placeholders(template_settings.subscribe_html_template, values),
config=system_settings,
smtp_config=smtp_settings,
)
except Exception:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱失敗",
message="確認信寄送失敗,請稍後再試。",
success=False,
),
status=502,
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱確認已送出!",
message="感謝您的訂閱<br>下一步,請前往訂閱的信箱收取確認信函<br><br>在信函中點擊連結",
success=True,
),
)
@require_GET
def newsletter_confirm(request):
token = (request.GET.get("token") or "").strip()
email = (request.GET.get("email") or "").strip().lower()
system_settings, template_settings, _ = _load_settings(request)
if not token:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱認證失敗",
message=template_settings.confirm_failure_template,
success=False,
),
status=400,
)
result = MemberCenterClient(system_settings).confirm(token)
if result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱認證成功!",
message=template_settings.confirm_success_template,
success=True,
),
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="訂閱認證失敗",
message=template_settings.confirm_failure_template,
success=False,
),
status=400,
)
@require_http_methods(["GET", "POST"])
def newsletter_unsubscribe(request):
system_settings, template_settings, _ = _load_settings(request)
client = MemberCenterClient(system_settings)
if request.method == "GET":
token = (request.GET.get("token") or "").strip()
email = (request.GET.get("email") or "").strip().lower()
if not token and email:
token_result = client.request_unsubscribe_token(
{
"email": email,
"list_id": system_settings.member_center_list_id,
}
)
if token_result.ok:
token = extract_token(token_result.data)
form = NewsletterUnsubscribeForm(initial={"email": email, "token": token})
can_submit = bool(token)
return render(
request,
"base/newsletter/unsubscribe.html",
{
"form": form,
"can_submit": can_submit,
"intro_message": template_settings.unsubscribe_intro_template,
},
status=200 if can_submit else 400,
)
if request.method != "POST":
return HttpResponseNotAllowed(["GET", "POST"])
form = NewsletterUnsubscribeForm(request.POST)
if not form.is_valid():
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂失敗",
message=template_settings.unsubscribe_failure_template,
success=False,
),
status=400,
)
payload = {
"token": form.cleaned_data["token"],
}
result = client.unsubscribe(payload)
if result.ok:
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂成功",
message=template_settings.unsubscribe_success_template,
success=True,
),
)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂失敗",
message=template_settings.unsubscribe_failure_template,
success=False,
),
status=400,
)
@csrf_exempt
@require_http_methods(["GET", "POST"])
def one_click_unsubscribe(request):
token = _extract_one_click_token(request)
ok, status_code, message = _handle_one_click_unsubscribe(request=request, token=token)
if request.method == "POST":
body = {"success": ok}
if not ok and status_code in (400, 410):
body["error"] = message
return JsonResponse(body, status=status_code)
return render(
request,
"base/newsletter/status.html",
_build_context(
title="退訂成功" if ok else "退訂失敗",
message=f"<p>{message}</p>",
success=ok,
),
status=status_code,
)
@staff_member_required
@require_GET
def newsletter_campaign_send_now(request, campaign_id: int):
campaign = get_object_or_404(NewsletterCampaign, pk=campaign_id)
if campaign.status == NewsletterCampaign.STATUS_SENDING:
messages.error(request, "Campaign is currently sending.")
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
result = dispatch_campaign(campaign, schedule_retry_on_failure=False)
if result.get("failed", 0):
messages.error(
request,
f"Send now completed with failures. sent={result.get('sent', 0)} failed={result.get('failed', 0)}",
)
else:
messages.success(
request,
f"Send now completed. sent={result.get('sent', 0)} failed={result.get('failed', 0)}",
)
return redirect(request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home"))
@staff_member_required
@require_POST
def newsletter_smtp_test(request):
to_email = (request.POST.get("smtp_test_email") or "").strip().lower()
redirect_to = request.META.get("HTTP_REFERER") or reverse("wagtailadmin_home")
if not to_email:
messages.error(request, "請輸入測試收件 Email。")
return redirect(redirect_to)
try:
validate_email(to_email)
except ValidationError:
messages.error(request, "測試收件 Email 格式不正確。")
return redirect(redirect_to)
settings_obj = NewsletterSystemSettings.load(request_or_site=request)
smtp_settings = MailSmtpSettings.load(request_or_site=request)
try:
sent_count = send_subscribe_email(
to_email=to_email,
subject="[SMTP Test] Newsletter SMTP 設定測試",
text_body="這是一封測試信,代表 SMTP 設定可正常寄送。",
html_body="<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>",
config=settings_obj,
smtp_config=smtp_settings,
)
except Exception as exc:
messages.error(request, f"測試信寄送失敗:{exc}")
return redirect(redirect_to)
from_email = build_from_email(settings_obj.sender_name, settings_obj.sender_email)
messages.success(
request,
f"SMTP 已接受請求sent_count={sent_count}from={from_email or 'settings.DEFAULT_FROM_EMAIL'}to={to_email}",
)
return redirect(redirect_to)

View File

@ -0,0 +1,103 @@
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail import hooks
from wagtail.admin.panels import FieldPanel
from wagtail.admin.rich_text import DraftailRichTextArea
from wagtail.admin.widgets import Button
from wagtail.permission_policies import ModelPermissionPolicy
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import CreateView, SnippetViewSet
from .models import NewsletterCampaign, NewsletterDispatchRecord, NewsletterSystemSettings
class NewsletterCampaignCreateView(CreateView):
def get_initial(self):
initial = super().get_initial()
if (initial.get("list_id") or "").strip():
return initial
default_list_id = (NewsletterSystemSettings.load().member_center_list_id or "").strip()
if default_list_id:
initial["list_id"] = default_list_id
return initial
class NewsletterCampaignViewSet(SnippetViewSet):
model = NewsletterCampaign
icon = "mail"
menu_label = _("Newsletter campaigns")
menu_order = 250
add_to_admin_menu = True
add_view_class = NewsletterCampaignCreateView
list_display = ["title", "list_id", "status", "scheduled_at", "sent_at", "updated_at"]
list_filter = ["status"]
search_fields = ["title", "list_id", "subject_template"]
panels = [
FieldPanel("title"),
FieldPanel(
"list_id",
help_text=_("Leave blank to use member_center_list_id from Newsletter System Settings."),
),
FieldPanel("subject_template"),
FieldPanel(
"html_template",
widget=DraftailRichTextArea(
features=["h2", "h3", "bold", "italic", "link", "image", "ul", "ol"],
),
help_text=_("Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."),
),
FieldPanel("text_template"),
FieldPanel("scheduled_at"),
]
class ReadOnlySnippetPermissionPolicy(ModelPermissionPolicy):
def user_has_permission(self, user, action):
if action in {"add", "change", "delete"}:
return False
return super().user_has_permission(user, action)
class NewsletterDispatchRecordViewSet(SnippetViewSet):
model = NewsletterDispatchRecord
icon = "tasks"
menu_label = _("Newsletter dispatch records")
menu_order = 251
add_to_admin_menu = True
inspect_view_enabled = True
copy_view_enabled = False
permission_policy = ReadOnlySnippetPermissionPolicy(NewsletterDispatchRecord)
list_display = [
"campaign",
"email",
"subscriber_id",
"status",
"retry_count",
"next_retry_at",
"response_status",
"created_at",
]
list_filter = ["status", "created_at"]
search_fields = ["email", "subscriber_id", "campaign__title"]
register_snippet(NewsletterCampaignViewSet)
register_snippet(NewsletterDispatchRecordViewSet)
@hooks.register("register_snippet_listing_buttons")
def newsletter_campaign_listing_buttons(snippet, user, next_url=None):
if not isinstance(snippet, NewsletterCampaign):
return
if not user.is_staff:
return
if snippet.status == NewsletterCampaign.STATUS_SENDING:
return
label = _("Send now")
yield Button(
label,
reverse("newsletter_campaign_send_now", args=[snippet.pk]),
icon_name="mail",
priority=15,
)

View File

@ -1,8 +1,18 @@
#!/usr/bin/env bash
set -e
# Run pending migrations and collect static assets before starting the app
python manage.py migrate --noinput
python manage.py collectstatic --noinput
APP_ROLE="${APP_ROLE:-web}"
exec "$@"
# Run pending migrations before starting any role.
python manage.py migrate --noinput
if [ "$APP_ROLE" = "scheduler" ]; then
SCHEDULER_INTERVAL_SECONDS="${SCHEDULER_INTERVAL_SECONDS:-60}"
while true; do
python manage.py run_newsletter_scheduler --limit 20
sleep "$SCHEDULER_INTERVAL_SECONDS"
done
else
python manage.py collectstatic --noinput
exec "$@"
fi

View File

@ -204,9 +204,9 @@ class LatestPage(Page, CategoryMixin, BreadcrumbMixin):
def get_context(self, request):
context = super().get_context(request)
context["category_sections"] = [self.get_latest_articles(request)]
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
# breadcrumbs, site_root = self.build_breadcrumbs()
# context["breadcrumbs"] = breadcrumbs
# context["breadcrumb_root"] = site_root
return context
@ -216,9 +216,9 @@ class TrendingPage(Page, CategoryMixin, BreadcrumbMixin):
def get_context(self, request):
context = super().get_context(request)
context["category_sections"] = [self.get_trending_articles(request)]
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
# breadcrumbs, site_root = self.build_breadcrumbs()
# context["breadcrumbs"] = breadcrumbs
# context["breadcrumb_root"] = site_root
return context
@ -311,8 +311,8 @@ class ArticlePage(Page, BreadcrumbMixin):
context = super().get_context(request)
breadcrumbs, site_root = self.build_breadcrumbs()
context["breadcrumbs"] = breadcrumbs
context["breadcrumb_root"] = site_root
# context["breadcrumbs"] = breadcrumbs
# context["breadcrumb_root"] = site_root
category_crumbs = [
crumb
for crumb in breadcrumbs

View File

@ -1,6 +1,6 @@
.page-article-list{
/* .page-article-list{
padding-bottom: 40px;
}
} */
.first-article {
display: grid;
@ -42,7 +42,7 @@
}
.first-article .first-article-title {
font-size: 40px;
font-size: 36px;
font-weight: 400;
color: #f4a41c;
margin: 0;
@ -123,7 +123,7 @@
}
.article-title {
font-size: 40px;
font-size: 30px;
font-weight: 400;
margin: 0;
display: -webkit-box;
@ -152,6 +152,15 @@
height: 44px;
}
.pagination a.disabled {
pointer-events: none;
color: #0e1b4266;
}
.pagination a.disabled button {
color: inherit;
}
.pagination .prev-page span,
.pagination .next-page span {
height: 44px;
@ -199,6 +208,10 @@
grid-template-columns: 318px 1fr;
}
.first-article .first-article-title {
font-size: 30px;
}
.first-article .first-article-image {
width: 318px;
}
@ -208,6 +221,14 @@
height: 290px;
}
.first-article .first-article-intro {
font-size: 16px;
}
.first-article .first-article-body {
font-size: 12px;
}
.article-list-item {
grid-template-columns: 318px 1fr;
}
@ -219,6 +240,10 @@
.article-thumb img{
width: 318px;
}
.article-title {
font-size: 26px;
}
}
@media (max-width: 767px) {
@ -235,10 +260,18 @@
"image"
"content";
}
.article-title {
font-size: 20px;
}
}
@media (min-width: 575px) and (max-width: 767px) {
.first-article .first-article-image {
.first-article .first-article-title {
font-size: 24px;
}
.first-article .first-article-image {
width: 426px;
}
@ -247,6 +280,14 @@
height: 260px;
}
.first-article .first-article-intro {
font-size: 16px;
}
.first-article .first-article-body {
font-size: 12px;
}
.article-thumb {
width: 426px;
}
@ -258,6 +299,10 @@
}
@media (max-width: 574px) {
.first-article .first-article-title {
font-size: 20px;
}
.first-article .first-article-image {
width: 300px;
}
@ -267,6 +312,18 @@
height: 218px;
}
.first-article .first-article-date {
font-size: 11px;
}
.first-article .first-article-intro {
font-size: 14px;
}
.first-article .first-article-body {
font-size: 11px;
}
.article-thumb {
width: 300px;
}
@ -275,4 +332,8 @@
width: 300px;
height: 218px;
}
.article-date {
font-size: 11px;
}
}

View File

@ -58,6 +58,7 @@
.article-content p {
font-size: 14px;
line-height: 1.75;
}
.article-content .intro {
@ -319,6 +320,10 @@
font-size: 20px;
}
.article-content h2 {
font-size: 24px;
}
.back-to-top {
bottom: 20px;
}
@ -337,6 +342,10 @@
font-size: 24px;
}
.article-content h2 {
font-size: 20px;
}
.article-content .intro {
font-size: 18px;
}

View File

@ -9,6 +9,7 @@
"image content";
gap: 16px 40px;
align-items: start;
padding-bottom: 20px;
}
.block-first-article .first-article-image {
@ -41,9 +42,10 @@
}
.block-first-article .first-article-title {
font-size: 40px;
font-size: 36px;
line-height: 1.25;
font-weight: 400;
color: #000000;
color: #0e1b42;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
@ -97,8 +99,16 @@
}
.block-first-article .first-article-title {
font-size: 36px;
}
font-size: 30px;
}
.block-first-article .first-article-intro {
font-size: 16px;
}
.block-first-article .first-article-body {
font-size: 12px;
}
}
@media (max-width: 767px) {
@ -121,9 +131,20 @@
}
.block-first-article .first-article-title {
font-size: 36px;
font-size: 24px;
}
.block-first-article .first-article-date {
font-size: 12px;
}
.block-first-article .first-article-intro {
font-size: 16px;
}
.block-first-article .first-article-body {
font-size: 12px;
}
}
@media (max-width: 574px) {
@ -137,10 +158,18 @@
}
.block-first-article .first-article-title {
font-size: 24px;
font-size: 20px;
}
.block-first-article .first-article-date {
font-size: 11px;
}
.block-first-article .first-article-intro {
font-size: 16px;
}
.block-first-article .first-article-body {
font-size: 11px;
}
}

View File

@ -42,6 +42,7 @@
.block-list-items .article-title {
font-size: 20px;
line-height: 1.25;
}
.block-list-items .article-date {
@ -58,6 +59,7 @@
align-self: stretch;
margin-left: auto;
text-align: center;
padding: 24px 0;
}
.block-list-more-article a {
@ -119,6 +121,10 @@
min-width: 0;
}
.block-list-items .article-date {
font-size: 12px;
}
.block-list-more-article {
max-width: 100%;
}
@ -143,7 +149,7 @@
}
.block-list-items .article-date {
font-size: 12px;
font-size: 11px;
}
.block-list-more-article span {

View File

@ -11,6 +11,7 @@
.list-title {
align-items: center;
margin: 10px 0;
padding: 40px 0;
}
.block-title-divider {

View File

@ -90,7 +90,7 @@
pointer-events: none;
}
.section-b .horizontal-list-thumb::after {
/* .section-b .horizontal-list-thumb::after {
background: url("../img/picfrm_b194.png") no-repeat left bottom / cover;
background-size: 194px 25px;
}
@ -98,7 +98,7 @@
.section-o .horizontal-list-thumb::after {
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
background-size: 194px 25px;
}
} */
.horizontal-list li img {
display: block;
@ -124,6 +124,7 @@
}
.horizontal-list .article-intro {
min-height: 45px;
}
.horizontal-list .article-date {

View File

@ -3,14 +3,16 @@
grid-template-columns: 480px 1fr;
grid-template-areas:
"header header"
"head head"
"image content";
padding: 40px 0;
gap: 16px 40px;
align-items: start;
}
.news-hero .news-hero-header {
grid-area: header;
display: flex;
/* display: flex; */
align-items: center;
gap: 12px;
}
@ -24,13 +26,13 @@
.news-hero .fist-news-title {
grid-area: head;
max-width: 580px;
font-size: 40px;
/* max-width: 580px; */
font-size: 36px;
font-weight: 400;
color: #eb9f13;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
@ -43,7 +45,7 @@
width: 480px;
}
.news-hero .first-news-image::after {
/* .news-hero .first-news-image::after {
content: "";
position: absolute;
left: 0px;
@ -53,7 +55,7 @@
background: url("../img/picfrm_b480.png") no-repeat left bottom / cover;
background-size: 480px 25px;
pointer-events: none;
}
} */
.news-hero .first-news-image img {
display: block;
@ -64,14 +66,17 @@
}
.news-hero .first-news-content {
grid-area: content;
height: 293px;
max-height: 293px;
overflow: hidden;
}
.news-hero .first-news-content a {
grid-area: content;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.news-hero .fist-news-date,
@ -94,6 +99,7 @@
.news-hero .first-news-body {
font-size: 13px;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
position: relative;
}
@ -136,7 +142,7 @@
overflow: hidden;
}
.news-list-thumb::after {
/* .news-list-thumb::after {
content: "";
position: absolute;
left: 0px;
@ -146,7 +152,7 @@
background: url("../img/picfrm_o194.png") no-repeat left bottom / cover;
background-size: 194px 25px;
pointer-events: none;
}
} */
.news-list-thumb img {
display: block;
@ -166,6 +172,10 @@
font-size: 20px;
}
.news-list-items .article-intro {
min-height: 45px;
}
.news-list-items .article-date {
color: rgba(255, 255, 255, 0.4);
}
@ -191,17 +201,17 @@
grid-template-columns: 318px 1fr;
}
.news-hero .fist-news-title {
/* .news-hero .fist-news-title {
max-width: 320px;
}
} */
.news-hero .first-news-image {
width: 318px;
}
.news-hero .first-news-image::after {
/* .news-hero .first-news-image::after {
background-size: 318px 25px;
}
} */
.news-hero .first-news-image img {
width: 318px;
@ -219,7 +229,7 @@
@media (min-width: 575px) and (max-width: 1023px) {
.news-hero .fist-news-title {
font-size: 36px;
font-size: 32px;
}
.more-news {
@ -302,6 +312,11 @@
height:260px;
}
.news-hero .first-news-content {
height: 260px;
max-height: 260px;
}
.news-list-items a > div:nth-of-type(1) {
grid-row: 1 / span 3;
}
@ -364,6 +379,21 @@
height: 220px;
}
.news-hero .first-news-content {
height: 220px;
max-height: 220px;
}
.news-hero .first-news-body::after {
height: 90px;
background: linear-gradient(
to bottom,
rgba(14, 27, 66, 0) 0%,
rgba(14, 27, 66, 0.42) 62%,
#0e1b42 92%
);
}
.news-hero .fist-news-title {
font-size: 24px;
@ -386,10 +416,10 @@
height: 110px;
}
.news-list-thumb::after {
/* .news-list-thumb::after {
background: url("../img/picfrm_o139.png") no-repeat left bottom / cover;
background-size: 139px 25px;
}
} */
.news-list-items a > div:nth-of-type(3) {
display: none;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags static %}
{% load wagtailcore_tags wagtailimages_tags static home_tags %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/article_page.css' %}">
@ -89,7 +89,8 @@
{% image related.cover_image max-194x133 as related_cover %}
<img src="{{ related_cover.url }}" alt="{{ related.title }}">
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ related.title }}">
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ related.title }}">
{% endif %}
</a>
<p class="related-article-date">{{ related.date|date:"Y/m/d" }}</p>

View File

@ -1,19 +1,14 @@
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% block content %}
<nav class="breadcrumbs" aria-label="breadcrumb">
<ol>
<li>
{% if site_root %}
<a href="{{ site_root.url }}">首頁</a>
{% else %}
<a href="/">首頁</a>
{% endif %}
</li>
<li><span>標籤</span></li>
<li><span>#{{ tag.name }}</span></li>
</ol>
</nav>
{% include "home/includes/page-article-list.html" with category=category_sections.0 %}
{% load wagtailcore_tags static %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
{% endblock %}
{% block content %}
<div class="site-hero-band full-bleed">
<div class="site-container">
<div class="block-title category-title"><span>#{{ tag.name }}</span></div>
</div>
</div>
{% include "home/includes/page-article-list.html" with category=category_sections.0 show_hero=False empty_message="沒有符合條件的文章" %}
{% endblock %}

View File

@ -9,9 +9,10 @@
{% endblock %}
{% block content %}
<div class="site-hero-band full-bleed">
{% include "base/includes/home_banner.html" %}
<div class="site-container">
<div class="home-hero">
{% include "base/includes/home_banner.html" %}
{% with top_section=sections.top_section %}
{% for section in top_section %}
{% include "home/includes/news_list.html" with section=section %}

View File

@ -1,48 +1,77 @@
{% load wagtailimages_tags static %}
{% load wagtailimages_tags static home_tags %}
<div class="site-hero-band full-bleed">
<div class="site-container">
{% with first_article=items|first %}
{% if first_article %}
<div class="first-article">
<div class="first-article-image">
<a href="{{ first_article.url }}">
{% if first_article.cover_image %}
{% image first_article.cover_image max-410x293 as cover %}
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}"/>
{% endif %}
</a>
</div>
<div class="first-article-content">
<a href="{{ first_article.url }}">
<div class="first-article-title">
<span>{{ first_article.title }}</span>
</div>
<div class="first-article-date">
<span>{{ first_article.date|date:"Y.m.d" }}</span>
</div>
{% if first_article.intro %}
<div class="first-article-intro">
<span>{{ first_article.intro }}</span>
{% if show_hero != False %}
<div class="site-hero-band full-bleed">
<div class="site-container">
{% with first_article=items|first %}
{% if first_article %}
<div class="first-article">
<div class="first-article-image">
<a href="{{ first_article.url }}">
{% if first_article.cover_image %}
{% image first_article.cover_image max-410x293 as cover %}
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
{% else %}
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ first_article.title }}"/>
{% endif %}
</a>
</div>
<div class="first-article-content">
<a href="{{ first_article.url }}">
<div class="first-article-title">
<span>{{ first_article.title }}</span>
</div>
{% endif %}
<div class="first-article-body">
<span>{{ first_article.body_search_text|truncatechars:200 }}</span>
</div>
</a>
<div class="first-article-date">
<span>{{ first_article.date|date:"Y.m.d" }}</span>
</div>
{% if first_article.intro %}
<div class="first-article-intro">
<span>{{ first_article.intro }}</span>
</div>
{% endif %}
<div class="first-article-body">
<span>{{ first_article.body_search_text|truncatechars:200 }}</span>
</div>
</a>
</div>
</div>
</div>
{% else %}
<span class="empty">目前沒有文章</span>
{% endif %}
{% endwith %}
{% else %}
<span class="empty">{{ empty_message|default:"目前沒有文章" }}</span>
{% endif %}
{% endwith %}
</div>
</div>
</div>
<div class="article-list-lower">
{% if items|length >= 2 %}
{% for article in items|slice:"1:" %}
{% endif %}
{% if show_hero != False %}
{% if items|length > 2 %}
<div class="article-list-lower">
{% for article in items|slice:"1:" %}
<a href="{{ article.url }}">
<div class="article-list-item">
<div class="article-thumb">
{% if article.cover_image %}
{% image article.cover_image max-410x218 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
{% else %}
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
{% endif %}
</div>
<div class="article-content">
<div><span class="article-title">{{ article.title }}</span></div>
<div><span class="article-date">{{ article.date|date:"Y.m.d" }}</span></div>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% else %}
{% if items|length %}
<div class="article-list-lower">
{% for article in items %}
<a href="{{ article.url }}">
<div class="article-list-item">
<div class="article-thumb">
@ -50,7 +79,8 @@
{% image article.cover_image max-410x218 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
{% endif %}
</div>
<div class="article-content">
@ -60,5 +90,8 @@
</div>
</a>
{% endfor %}
</div>
{% else %}
<span class="empty">{{ empty_message|default:"沒有符合條件的文章" }}</span>
{% endif %}
</div>
{% endif %}

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags static %}
{% load wagtailimages_tags static home_tags %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list.css' %}">
{% endblock %}
@ -13,7 +13,8 @@
{% image first_article.cover_image max-480x293 as cover %}
<img src="{{ cover.url }}" alt="{{ first_article.title }}"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ first_article.title }}"/>
{% endif %}
</a>
</div>

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags static %}
{% load wagtailimages_tags static home_tags %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/block_list_lower.css' %}">
{% endblock %}
@ -12,7 +12,8 @@
{% image article.cover_image max-194x133 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ article.title }}"/>
{% endif %}
</div>
<div><span class="article-title">{{ article.title }}</span></div>

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags static %}
{% load wagtailimages_tags static home_tags %}
<div class="horizontal-list-wrap" data-horizontal-list>
<button class="horizontal-list-arrow is-hidden" type="button" data-dir="left" aria-label="上一頁">
@ -15,7 +15,8 @@
{% image article.cover_image max-194x133 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="133" width="194"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ article.title }}" height="133" width="194"/>
{% endif %}
</div>
<div><span class="article-title">{{ article.title }}</span></div>

View File

@ -1,4 +1,4 @@
{% load wagtailimages_tags static %}
{% load wagtailimages_tags static home_tags %}
<div class="news-list-wrap" data-news-list>
<div class="news-hero">
@ -12,25 +12,22 @@
</span>
<a href="{{ section.url }}"><span class="more-link">查看全部</span></a>
</div>
{% with first_article=section.items|first %}
{% if first_article %}
<div class="fist-news-title">
<a href="{{ first_article.url }}">
<span>{{ first_article.title }}</span>
</a>
</div>
{% endif %}
{% endwith %}
</div>
{% with first_article=section.items|first %}
{% if first_article %}
<div class="fist-news-title">
<a href="{{ first_article.url }}">
<span>{{ first_article.title }}</span>
</a>
</div>
<div class="first-news-image">
<a href="{{ first_article.url }}">
{% if first_article.cover_image %}
{% image first_article.cover_image max-480x293 as cover %}
<img src="{{ cover.url }}" alt="{{ first_article.title }}" height="293" width="480"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ first_article.title }}" height="293" width="480"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ first_article.title }}" height="293" width="480"/>
{% endif %}
</a>
</div>
@ -65,7 +62,8 @@
{% image article.cover_image max-194x133 as cover %}
<img src="{{ cover.url }}" alt="{{ article.title }}" height="133" width="194"/>
{% else %}
<img src="{% static 'img/default_cover.jpg' %}" alt="{{ article.title }}" height="133" width="194"/>
{% random_default_cover as default_cover %}
<img src="{{ default_cover }}" alt="{{ article.title }}" height="133" width="194"/>
{% endif %}
</div>
<div><span class="article-title">{{ article.title }}</span></div>

View File

@ -4,7 +4,7 @@
{% endblock %}
<div class="page-article-list">
{% include "home/includes/article_list.html" with items=category.items %}
{% include "home/includes/article_list.html" with items=category.items show_hero=show_hero empty_message=empty_message %}
{% if category.items.paginator.num_pages > 1 %}
<div class="pagination">
@ -12,7 +12,16 @@
<a class="prev-page" href="?page={{ category.items.previous_page_number }}">
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="var(--stroke-0, #0E1B42)" stroke-width="2"/>
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<span>BACK</span>
</a>
{% else %}
<a class="prev-page disabled" aria-disabled="true">
<button class="left-arrow" type="button" data-dir="left" aria-label="更多文章">
<svg class="left-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<span>BACK</span>
@ -34,10 +43,19 @@
<span>NEXT</span>
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="var(--stroke-0, #0E1B42)" stroke-width="2"/>
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</a>
{% else %}
<a class="next-page disabled" aria-disabled="true">
<span>NEXT</span>
<button class="right-arrow" type="button" data-dir="right" aria-label="更多文章">
<svg class="right-arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="none" overflow="visible" preserveAspectRatio="none" viewBox="0 0 18.1213 33.4142">
<path d="M17.4142 0.707107L1.41421 16.7071L17.4142 32.7071" id="Vector 3" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</a>
{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,16 @@
import random
from django import template
from django.templatetags.static import static
register = template.Library()
@register.simple_tag
def random_default_cover():
choices = (
"img/default_cover_1.png",
"img/default_cover_2.png",
"img/default_cover_3.png",
)
return static(random.choice(choices))

View File

@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404, render
from taggit.models import Tag
from wagtail.models import Site
from .models import ArticlePage, PAGE_SIZE
from .models import ArticlePage, CATEGORY_HOT_SIZE, PAGE_SIZE
def hashtag_search(request, slug):
@ -12,7 +12,7 @@ def hashtag_search(request, slug):
articles = (
ArticlePage.objects.live()
.filter(tags__slug=slug)
.order_by("-date")
.order_by("-date", "-id")
)
paginator = Paginator(articles, PAGE_SIZE)
@ -37,7 +37,12 @@ def hashtag_search(request, slug):
"url": request.path,
"page_range": paginator.get_elided_page_range(page_obj.number),
}
],
],
"category_trending": (
ArticlePage.objects.live()
.filter(tags__slug=slug, trending=True)
.order_by("-date", "-id")[:CATEGORY_HOT_SIZE]
),
"site_root": site_root,
"page": site_root.specific if site_root else None,
}

Binary file not shown.

View File

@ -0,0 +1,432 @@
msgid ""
msgstr ""
"Project-Id-Version: innovedus_cms\n"
"POT-Creation-Date: 2026-03-12 00:00+0000\n"
"PO-Revision-Date: 2026-03-12 00:00+0000\n"
"Last-Translator: Codex\n"
"Language-Team: zh_Hant\n"
"Language: zh_Hant\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Header Settings"
msgstr "頁首設定"
msgid "Use on dark background (light logo)."
msgstr "深色底用(亮色 logo"
msgid "Light Logo"
msgstr "亮色 Logo"
msgid "Use on light background (dark logo)."
msgstr "淺色底用(深色 logo"
msgid "Dark Logo"
msgstr "深色 Logo"
msgid "Site Name"
msgstr "網站名稱"
msgid "Extra Links"
msgstr "額外連結"
msgid "Label"
msgstr "標籤"
msgid "URL"
msgstr "連結"
msgid "Footer Navigation"
msgstr "頁尾導覽"
msgid "Footer Links"
msgstr "頁尾連結"
msgid "Section Title"
msgstr "區塊標題"
msgid "Social Link"
msgstr "社群連結"
msgid "Social Links"
msgstr "社群連結"
msgid "Platform"
msgstr "平台"
msgid "SMTP Settings"
msgstr "SMTP 設定"
msgid "SMTP Relay Host"
msgstr "SMTP Relay Host"
msgid "SMTP Relay Port"
msgstr "SMTP Relay Port"
msgid "Use TLS"
msgstr "使用 TLS"
msgid "Use SSL"
msgstr "使用 SSL"
msgid "Port 465 usually uses SSL (Implicit TLS); port 587 usually uses STARTTLS (TLS)."
msgstr "465 常用 SSLImplicit TLS587 常用 STARTTLSTLS。"
msgid "SMTP Timeout Seconds"
msgstr "SMTP 逾時秒數"
msgid "SMTP Username"
msgstr "SMTP 帳號"
msgid "SMTP Password"
msgstr "SMTP 密碼"
msgid "Newsletter System Settings"
msgstr "電子報系統設定"
msgid "Member Center"
msgstr "會員中心"
msgid "Member Center Base URL"
msgstr "會員中心 Base URL"
msgid "Subscribe Path"
msgstr "訂閱路徑"
msgid "Confirm Path"
msgstr "確認路徑"
msgid "Unsubscribe Token Path"
msgstr "退訂 Token 路徑"
msgid "Unsubscribe Path"
msgstr "退訂路徑"
msgid "Subscriptions Path"
msgstr "訂閱列表路徑"
msgid "OAuth Token Path"
msgstr "OAuth Token 路徑"
msgid "OAuth Client ID"
msgstr "OAuth Client ID"
msgid "OAuth Client Secret"
msgstr "OAuth Client Secret"
msgid "OAuth Scope"
msgstr "OAuth Scope"
msgid "OAuth Audience"
msgstr "OAuth Audience"
msgid "One-Click Unsubscribe Path"
msgstr "一鍵退訂路徑"
msgid "Tenant ID"
msgstr "Tenant ID"
msgid "List ID"
msgstr "List ID"
msgid "Member Center Timeout Seconds"
msgstr "會員中心逾時秒數"
msgid "Send Engine"
msgstr "發送引擎"
msgid "Send Engine Base URL"
msgstr "發送引擎 Base URL"
msgid "Send Jobs Path"
msgstr "發送任務路徑"
msgid "Send Engine OAuth Scope"
msgstr "發送引擎 OAuth Scope"
msgid "Send Engine Timeout Seconds"
msgstr "發送引擎逾時秒數"
msgid "Retry Interval Seconds"
msgstr "重試間隔秒數"
msgid "Retry Max Attempts"
msgstr "最大重試次數"
msgid "Newsletter Mail"
msgstr "電子報寄件設定"
msgid "Sender Name"
msgstr "寄件者名稱"
msgid "Sender Email"
msgstr "寄件者 Email"
msgid "Reply-To Email"
msgstr "回覆 Email"
msgid "Default Charset"
msgstr "預設字元編碼"
msgid "List-Unsubscribe One-Click"
msgstr "一鍵退訂設定"
msgid "One-Click Endpoint Path"
msgstr "一鍵退訂端點路徑"
msgid "One-Click Token Secret"
msgstr "一鍵退訂 Token 密鑰"
msgid "One-click token signing secret. Leave blank to use Django SECRET_KEY."
msgstr "One-click token 簽章密鑰;留空則使用 Django SECRET_KEY。"
msgid "One-Click Token TTL Seconds"
msgstr "一鍵退訂 Token 有效秒數"
msgid "Site Base URL"
msgstr "站台 Base URL"
msgid "Site base URL for scheduler sends, e.g. https://news.example.com"
msgstr "排程發送使用的站台網址(例如 https://news.example.com。"
msgid "System Notification Mail Settings"
msgstr "系統通知信設定"
msgid "Contact Us Notification Mail"
msgstr "聯絡我們通知信"
msgid "Contact Form Sender Name"
msgstr "聯絡表單寄件者名稱"
msgid "Contact Form Sender Email"
msgstr "聯絡表單寄件者 Email"
msgid "Contact Form Reply-To Email"
msgstr "聯絡表單回覆 Email"
msgid "Contact Form Notification Recipients"
msgstr "聯絡表單通知收件人"
msgid "Multiple recipients separated by comma or newline."
msgstr "可填多個收件人,以逗號或換行分隔。"
msgid "Contact Form Subject Prefix"
msgstr "聯絡表單主旨前綴"
msgid "User Copy Subject Template"
msgstr "使用者存檔信主旨模板"
msgid "User Copy Text Template"
msgstr "使用者存檔信純文字模板"
msgid "User Copy HTML Template"
msgstr "使用者存檔信 HTML 模板"
msgid "Subscribe Confirmation Email"
msgstr "訂閱確認信"
msgid "Page Templates"
msgstr "頁面模板"
msgid "Newsletter Template Settings"
msgstr "電子報模板設定"
msgid "Banner"
msgstr "橫幅"
msgid "Banners"
msgstr "橫幅"
msgid "Key"
msgstr "識別鍵"
msgid "Identifier key, e.g. home / category"
msgstr "識別用 key例如 home / category"
msgid "Title"
msgstr "標題"
msgid "Link Text"
msgstr "連結文字"
msgid "Active"
msgstr "啟用"
msgid "Sort Order"
msgstr "排序"
msgid "Footer Text"
msgstr "頁尾文字"
msgid "Draft"
msgstr "草稿"
msgid "Scheduled"
msgstr "已排程"
msgid "Sending"
msgstr "寄送中"
msgid "Sent"
msgstr "已送出"
msgid "Failed"
msgstr "失敗"
msgid "Newsletter Campaign"
msgstr "電子報"
msgid "Newsletter Campaigns"
msgstr "電子報"
msgid "Newsletter Dispatch Record"
msgstr "電子報發送紀錄"
msgid "Newsletter Dispatch Records"
msgstr "電子報發送紀錄"
msgid "Collaboration"
msgstr "合作邀約"
msgid "Website Issue"
msgstr "網站問題回報"
msgid "Career"
msgstr "求職專區"
msgid "Other"
msgstr "其他"
msgid "Name"
msgstr "姓名"
msgid "Email"
msgstr "Email"
msgid "Contact"
msgstr "聯絡方式"
msgid "Category"
msgstr "問題類別"
msgid "Message"
msgstr "留言內容"
msgid "Source Page"
msgstr "來源頁面"
msgid "IP Address"
msgstr "IP 位址"
msgid "User Agent"
msgstr "User Agent"
msgid "Created At"
msgstr "建立時間"
msgid "Contact Form Submission"
msgstr "聯絡表單提交"
msgid "Contact Form Submissions"
msgstr "聯絡表單提交"
msgid "Newsletter campaigns"
msgstr "電子報"
msgid "Newsletter dispatch records"
msgstr "電子報發送紀錄"
msgid "Social Media Settings"
msgstr "社群媒體設定"
msgid "Subject Template"
msgstr "主旨模板"
msgid "HTML Template"
msgstr "HTML 模板"
msgid "Text Template"
msgstr "純文字模板"
msgid "Status"
msgstr "狀態"
msgid "Scheduled At"
msgstr "排程時間"
msgid "Sent At"
msgstr "發送時間"
msgid "Last Error"
msgstr "最後錯誤"
msgid "Updated At"
msgstr "更新時間"
msgid "Campaign"
msgstr "活動"
msgid "Subscriber ID"
msgstr "訂閱者 ID"
msgid "Retry Count"
msgstr "重試次數"
msgid "Next Retry At"
msgstr "下次重試時間"
msgid "Response Status"
msgstr "回應狀態碼"
msgid "Response Payload"
msgstr "回應內容"
msgid "Error Message"
msgstr "錯誤訊息"
msgid "Subscribe Subject Template"
msgstr "訂閱主旨模板"
msgid "Subscribe HTML Template"
msgstr "訂閱 HTML 模板"
msgid "Subscribe Text Template"
msgstr "訂閱純文字模板"
msgid "Confirm Success Template"
msgstr "確認成功模板"
msgid "Confirm Failure Template"
msgstr "確認失敗模板"
msgid "Unsubscribe Intro Template"
msgstr "退訂前置說明模板"
msgid "Unsubscribe Success Template"
msgstr "退訂成功模板"
msgid "Unsubscribe Failure Template"
msgstr "退訂失敗模板"
msgid "Leave blank to use member_center_list_id from Newsletter System Settings."
msgstr "留空時會使用 Newsletter System Settings 的 member_center_list_id。"
msgid "Use image picker/upload from Wagtail. Prefer image URLs instead of Base64 in CMS."
msgstr "可用圖片按鈕從 Wagtail 圖庫選圖或上傳。建議使用圖片 URL不要在 CMS 端轉 Base64。"
msgid "Send now"
msgstr "馬上發送"
msgid "Send test email (save settings before sending)."
msgstr "發送測試郵件(請先儲存設定後再發送測試郵件)"
msgid "Send email to"
msgstr "發送郵件到"
msgid "This field is only for this test and will not be saved."
msgstr "此欄位僅作本次測試,不會儲存。"
msgid "Send test email"
msgstr "發送測試郵件"

View File

@ -13,9 +13,18 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
try:
import certifi
except Exception:
certifi = None
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_DIR = os.path.dirname(PROJECT_DIR)
# Ensure Python SSL always has a CA bundle unless caller explicitly sets one.
if not os.environ.get("SSL_CERT_FILE") and certifi is not None:
os.environ["SSL_CERT_FILE"] = certifi.where()
def env_list(name, default):
"""
Return a list from a comma-separated env var; fall back to provided default list.
@ -138,6 +147,10 @@ USE_I18N = True
USE_TZ = True
LOCALE_PATHS = [
os.path.join(BASE_DIR, "locale"),
]
# --- Wagtail embeds (Instagram/FB via Graph oEmbed) ---
# Reads a token from env and adds an extra oEmbed finder for IG/FB.
# Keeps the default oEmbed finder first for YouTube/Vimeo/etc.

View 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;
}
.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;
backdrop-filter: blur(12px);
}
.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;
}
}

View File

@ -0,0 +1,261 @@
.site-footer {
background: #0e1b42;
color: #ffffff;
padding: 28px 0 20px;
font-size: 14px;
}
.footer-shell {
display: flex;
flex-direction: column;
align-items: center;
}
.footer-ad-slot {
width: 100%;
}
.footer-separator {
width: 100%;
max-width: 790px;
height: 1px;
background: #ffffff4d;
margin: 0 auto 40px;
}
.footer-main {
width: 100%;
max-width: 790px;
display: grid;
grid-template-columns: 220px 220px 180px;
grid-template-areas:
"b1 b2 b3"
"b1 b2 b4";
column-gap: 40px;
row-gap: 20px;
align-items: start;
}
.footer-col--brand {
grid-area: b1;
min-width: 0;
}
.footer-brand-content img {
max-width: 100%;
height: auto;
margin-bottom: 12px;
}
.footer-brand-content p {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.75;
}
.footer-col--menu {
grid-area: b2;
min-width: 0;
}
.footer-menu-list {
margin: 0;
padding: 0;
list-style: none;
column-count: 2;
column-gap: 40px;
}
.footer-menu-list li {
break-inside: avoid;
margin: 0 0 14px;
}
.footer-menu-list a {
color: #ffffff;
text-decoration: none;
line-height: 1.35;
}
.footer-menu-list a:hover {
text-decoration: underline;
}
.footer-fixed-links{
grid-area: b3;
width: 100%;
}
.footer-fixed-links ul{
margin: 0;
padding: 0;
list-style: none;
display: flex;
justify-content: space-between;
}
.footer-fixed-links a {
font-size: 10px;
line-height: 1.35;
}
.footer-fixed-links a:hover {
text-decoration: underline;
}
.footer-socials {
grid-area: b4;
width: 100%;
display: flex;
justify-content: space-between;
}
.footer-socials .icon {
width: 25px;
height: 25px;
--fill-0: #ffffff;
}
.footer-socials .icon .icon-cutout {
fill: #0e1b42;
}
.footer-powered {
margin-top: 24px;
text-align: center;
font-size: 12px;
font-weight: 100;
line-height: 1.35;
}
@media (min-width: 1024px) {
.footer-fixed-links,
.footer-socials {
align-self: end;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.footer-separator {
max-width: 616px;
}
.footer-main {
max-width: 616px;
display: grid;
grid-template-columns: 220px 150px 1fr;
grid-template-areas:
"b1 b2 b2"
"b1 b4 b3";
gap: 32px;
}
.footer-col--right:nth-child(3) {
flex-direction: row;
align-items: flex-start;
}
.footer-menu-list {
column-count: 3;
}
}
@media (max-width: 767px) {
.site-footer {
padding: 24px 0 16px;
}
.site-footer .site-container {
max-width: 494px;
}
.footer-separator {
max-width: 494px;
}
.footer-main {
max-width: 494px;
display: grid;
grid-template-columns: 220px 150px 1fr;
grid-template-areas:
"b1 b2 b3"
"b1 b4 .";
gap: 20px;
}
.footer-menu-list {
column-gap: 0px;
}
.footer-menu-list li {
margin: 0 0 6px;
}
.footer-col--right {
grid-column: auto;
align-items: flex-start;
gap: 20px;
}
.footer-fixed-links {
flex-wrap: wrap;
gap: 18px;
}
.footer-fixed-links ul {
display: block;
}
.footer-fixed-links ul li {
margin: 0 0 4px;
}
}
@media (max-width: 574px) {
.footer-separator {
max-width: 289px;
}
.footer-main {
max-width: 220px;
margin-inline: auto;
justify-content: center;
grid-template-columns: 1fr;
grid-template-areas:
"b2"
"b1"
"b4"
"b3";
gap: 20px;
}
.footer-col--menu,
.footer-col--right,
.footer-fixed-links,
.footer-socials {
width: 100%;
}
.footer-menu-list {
text-align: center;
}
.footer-fixed-links ul {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
}
.footer-menu-list a {
font-size: 16px;
}
.footer-fixed-links a {
font-size: 12px;
}
.footer-socials .icon {
width: 35px;
height: 35px;
}
}

View File

@ -66,7 +66,7 @@ a {
.main-menu {
display: flex;
align-items: center;
gap: 24px;
gap: 18px;
list-style: none;
margin: 0;
padding: 0;
@ -276,7 +276,7 @@ a {
border: 0;
background: transparent;
outline: none;
width: 181px;
width: 153px;
}
.template-darkbackground .header-search input[type="search"] {
@ -287,88 +287,6 @@ a {
color: #ffffff88;
}
footer {
background: #0e1b42;
color: #ffffff;
padding: 24px 0;
font-size: 14px;
}
.footer-inner {
display: flex;
justify-content: center;
gap: 48px;
align-items: flex-start;
text-align: left;
}
footer .company-info {
max-width: 300px;
margin: 0;
padding: 0 16px;
}
footer .copyright img {
margin: 10px 0;
width: 265px;
height: 37px;
}
footer .copyright p {
font-size: 10px;
text-align: left;
margin: 0;
}
.footer-socials {
display: flex;
gap: 12px;
margin: 16px 0;
flex-wrap: wrap;
}
.footer-socials .icon {
width: 32px;
height: 32px;
--fill-0: #ffffff;
}
.footer-socials .icon circle {
fill: #ffffff;
fill-opacity: 0.85;
}
.footer-socials .icon .icon-cutout {
fill: #0e1b42;
}
footer .footer-links {
padding: 0 16px;
}
footer .footer-links li {
list-style-type: none;
}
footer .footer-links a {
font-size: 14px;
}
footer .footer-divider {
align-self: stretch;
display: flex;
align-items: center;
padding: 0 8px;
width: 1px;
border-left: 1px solid #ffffff;
}
footer .footer-sections {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.block-title {
display: inline-block;
width: 197px;
@ -404,35 +322,6 @@ footer .footer-sections {
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
align-items: center;
text-align: center;
}
footer .company-info,
footer .footer-links {
max-width: 100%;
}
.footer-socials {
justify-content: center;
}
footer .footer-sections {
justify-content: center;
}
footer .footer-divider {
width: 100%;
padding: 12px 0;
border-left: 0;
border-top: 1px solid #ffffff;
}
footer .copyright p {
text-align: center;
}
}
@media (min-width: 575px) and (max-width: 767px) {

View File

@ -0,0 +1,117 @@
.subscribe-fab {
--fab-toggle-width: 59px;
position: fixed;
right: 16px;
top: 12%;
transform: translateY(-50%);
z-index: 1000;
min-height: 59px;
}
.subscribe-fab__toggle {
flex: 0 0 var(--fab-toggle-width);
width: var(--fab-toggle-width);
height: 59px;
margin: 0 0 0 10px;
border: 0;
background: transparent;
color: #ffffff66;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.subscribe-fab__icon {
width: 32px;
height: 32px;
transform: scale(1.35);
transform-origin: center;
}
.subscribe-fab__text {
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.subscribe-fab__panel {
position: absolute;
width: 378px;
right: 0;
top: 0;
display: flex;
align-items: center;
min-width: 368px;
height: 59px;
padding: 0 10px 0 0;
border-radius: 36px;
background: #ffffff1a;
border: 1px solid #ffffff80;
transform: translateX(calc(100% - var(--fab-toggle-width)));
transition: transform 0.25s ease, background-color 0.2s ease;
backdrop-filter: blur(12px);
}
.subscribe-fab:hover .subscribe-fab__panel {
background: #ffffff66;
}
.subscribe-fab.is-open .subscribe-fab__panel {
background: #ffffff66;
transform: translateX(44px);
}
.subscribe-fab:hover .subscribe-fab__toggle,
.subscribe-fab.is-open .subscribe-fab__toggle {
color: #0e1b42;
}
.subscribe-fab__form {
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.subscribe-fab.is-open .subscribe-fab__form {
opacity: 1;
pointer-events: auto;
}
.subscribe-fab__input {
width: 190px;
height: 30px;
border: 0;
padding: 0 10px;
background: #ffffff80;
color: #0e1b42;
font-size: 12px;
}
.subscribe-fab__input::placeholder {
color: #0e1b4288;
}
.subscribe-fab__submit {
height: 30px;
border: 0;
padding: 0 10px;
background: #ffffffcc;
color: #0e1b42;
font-size: 12px;
cursor: pointer;
}
@media (max-width: 375px) {
.subscribe-fab__input {
width: 150px;
}
.subscribe-fab.is-open .subscribe-fab__panel {
transform: translateX(84px);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View 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 = "送出失敗,請稍後再試。";
}
});
}
})();

View File

@ -0,0 +1,51 @@
(function () {
const root = document.querySelector("[data-subscribe-fab]");
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 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, !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, false);
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
setOpen(false, false);
}
});
})();

View File

@ -17,6 +17,8 @@
<meta name="description" content="{{ page.search_description }}" />
{% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" type="image/x-icon">
{% if ga4_measurement_id %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga4_measurement_id }}"></script>
<script>
@ -34,6 +36,9 @@
{# Global stylesheets #}
<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/subscribe_fab.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'css/contact_form.css' %}">
{% block extra_css %}
{# Override this in templates to add extra stylesheets #}
@ -52,10 +57,14 @@
</main>
{% include "includes/footer.html" %}
{% include "includes/contact_form.html" %}
{% include "includes/subscribe_fab.html" %}
{# Global javascript #}
<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/contact_form.js' %}"></script>
<script type="text/javascript" src="{% static 'js/subscribe_fab.js' %}"></script>
{# Instagram embed script to render IG oEmbeds #}
<script async src="https://www.instagram.com/embed.js"></script>

View 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>

View File

@ -1,75 +1,97 @@
{% load navigation_tags %}
{% load navigation_tags wagtailcore_tags %}
<footer>
<div class="site-container footer-inner">
<div class="company-info">
<div class="copyright">
{% get_footer_text %}
</div>
{% with social_links=settings.base.SocialMediaSettings.links %}
{% if social_links %}
<div class="footer-socials" aria-label="social icons">
{% for item in social_links %}
<a href="{{ item.value.url }}" target="_blank" aria-label="{{ item.value.platform }}">
{% if item.value.platform|lower == "facebook" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="facebook" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM23.12 8.37H21.18C19.27 8.37 18.67 9.56 18.67 10.78V13.67H22.94L22.26 18.12H18.67V28.89H13.85V18.12H9.94V13.67H13.85V10.28C13.85 6.42 16.15 4.29 19.67 4.29C21.36 4.29 23.12 4.59 23.12 4.59V8.38V8.37Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
</svg>
{% elif item.value.platform|lower == "instagram" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="instagram" role="img">
<path d="M16.67 13.42C14.92 13.42 13.5 14.84 13.5 16.59C13.5 18.34 14.92 19.76 16.67 19.76C18.42 19.76 19.84 18.34 19.84 16.59C19.84 14.84 18.42 13.42 16.67 13.42Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
<path d="M20.6 8.42H12.57C10.28 8.42 8.42 10.28 8.42 12.57V20.6C8.42 22.89 10.28 24.75 12.57 24.75H20.6C22.89 24.75 24.75 22.89 24.75 20.6V12.57C24.75 10.28 22.89 8.42 20.6 8.42ZM16.67 21.54C13.94 21.54 11.72 19.32 11.72 16.59C11.72 13.86 13.94 11.64 16.67 11.64C19.4 11.64 21.62 13.86 21.62 16.59C21.62 19.32 19.4 21.54 16.67 21.54ZM21.91 12.48C21.26 12.48 20.73 11.95 20.73 11.3C20.73 10.65 21.26 10.12 21.91 10.12C22.56 10.12 23.09 10.65 23.09 11.3C23.09 11.95 22.56 12.48 21.91 12.48Z" fill="var(--fill-0, #0E1B42)" id="Vector_2"/>
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM26.64 20.6C26.64 23.93 23.93 26.64 20.6 26.64H12.57C9.24 26.64 6.53 23.93 6.53 20.6V12.57C6.53 9.24 9.24 6.53 12.57 6.53H20.6C23.93 6.53 26.64 9.24 26.64 12.57V20.6Z" fill="var(--fill-0, #0E1B42)" id="Vector_3"/>
</svg>
{% elif item.value.platform|lower == "youtube" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="youtube" role="img">
<path d="M20.36 16.45L15.14 13.61C14.93 13.49 14.19 13.64 14.19 13.89V19.43C14.19 19.67 14.92 19.83 15.13 19.71L20.59 17.01C20.81 16.89 20.59 16.57 20.36 16.45Z" fill="var(--fill-0, #0E1B42)" id="Vector"/>
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM27.74 19.42C27.74 22.05 25.61 24.18 22.98 24.18H10.81C8.18 24.18 6.05 22.05 6.05 19.42V13.76C6.05 11.13 8.18 9 10.81 9H22.98C25.61 9 27.74 11.13 27.74 13.76V19.42Z" fill="var(--fill-0, #0E1B42)" id="Vector_2"/>
</svg>
{% elif item.value.platform|lower == "threads" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="threads" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% elif item.value.platform|lower == "linkedin" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="linkedin" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% elif item.value.platform|lower == "x" or item.value.platform|lower == "twitter" or item.value.platform|lower == "twitter-x" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="twitter-x" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% else %}
{{ item.value.platform }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<footer class="site-footer">
<div class="site-container footer-shell">
<div class="footer-ad-slot" aria-hidden="true"></div>
<div class="footer-separator" aria-hidden="true"></div>
<div class="footer-divider" aria-hidden="true"></div>
<div class="footer-links">
{% if settings.base.NavigationSettings.footer_links %}
<div class="footer-sections">
{% for section in settings.base.NavigationSettings.footer_links %}
<div class="footer-section">
{% if section.value.title %}
<h3>{{ section.value.title }}</h3>
{% endif %}
<ul>
{% for link in section.value.links %}
<li><a href="{{ link.url }}" target="_blank">{{ link.label }}</a></li>
{% endfor %}
</ul>
</div>
{% endfor %}
<div class="footer-main">
<div class="footer-col footer-col--brand">
<div class="footer-brand-content">
{% get_footer_text %}
</div>
{% endif %}
</div>
<nav class="footer-col footer-col--menu" aria-label="Footer main menu">
<ul class="footer-menu-list">
{% if nav_latest_page %}
<li><a href="{{ nav_latest_page.url }}">{{ nav_latest_page.title }}</a></li>
{% else %}
<li><a href="#">最新文章</a></li>
{% endif %}
{% wagtail_site as current_site %}
{% if page %}
{% with site_root=page.get_site.root_page %}
{% for menu_page in site_root.get_children.live.in_menu %}
<li><a href="{{ menu_page.url }}">{{ menu_page.title }}</a></li>
{% endfor %}
{% endwith %}
{% elif current_site %}
{% with site_root=current_site.root_page %}
{% for menu_page in site_root.get_children.live.in_menu %}
<li><a href="{{ menu_page.url }}">{{ menu_page.title }}</a></li>
{% endfor %}
{% endwith %}
{% endif %}
</ul>
</nav>
<!-- <div class="footer-col footer-col--right"> -->
<div class="footer-fixed-links">
<ul>
<li><a href="#" data-subscribe-trigger>訂閱電子報</a></li>
<li><a href="#" data-contact-trigger data-contact-category="collaboration">合作提案</a></li>
<li><a href="#" data-contact-trigger data-contact-category="other">聯絡我們</a></li>
</ul>
</div>
{% with social_links=settings.base.SocialMediaSettings.links %}
{% if social_links %}
<div class="footer-socials" aria-label="social icons">
{% for item in social_links %}
<a href="{{ item.value.url }}" target="_blank" rel="noopener noreferrer" aria-label="{{ item.value.platform }}">
{% if item.value.platform|lower == "facebook" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="facebook" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM23.12 8.37H21.18C19.27 8.37 18.67 9.56 18.67 10.78V13.67H22.94L22.26 18.12H18.67V28.89H13.85V18.12H9.94V13.67H13.85V10.28C13.85 6.42 16.15 4.29 19.67 4.29C21.36 4.29 23.12 4.59 23.12 4.59V8.38V8.37Z" fill="var(--fill-0, #0E1B42)"/>
</svg>
{% elif item.value.platform|lower == "instagram" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="instagram" role="img">
<path d="M16.67 13.42C14.92 13.42 13.5 14.84 13.5 16.59C13.5 18.34 14.92 19.76 16.67 19.76C18.42 19.76 19.84 18.34 19.84 16.59C19.84 14.84 18.42 13.42 16.67 13.42Z" fill="var(--fill-0, #0E1B42)"/>
<path d="M20.6 8.42H12.57C10.28 8.42 8.42 10.28 8.42 12.57V20.6C8.42 22.89 10.28 24.75 12.57 24.75H20.6C22.89 24.75 24.75 22.89 24.75 20.6V12.57C24.75 10.28 22.89 8.42 20.6 8.42ZM16.67 21.54C13.94 21.54 11.72 19.32 11.72 16.59C11.72 13.86 13.94 11.64 16.67 11.64C19.4 11.64 21.62 13.86 21.62 16.59C21.62 19.32 19.4 21.54 16.67 21.54ZM21.91 12.48C21.26 12.48 20.73 11.95 20.73 11.3C20.73 10.65 21.26 10.12 21.91 10.12C22.56 10.12 23.09 10.65 23.09 11.3C23.09 11.95 22.56 12.48 21.91 12.48Z" fill="var(--fill-0, #0E1B42)"/>
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM26.64 20.6C26.64 23.93 23.93 26.64 20.6 26.64H12.57C9.24 26.64 6.53 23.93 6.53 20.6V12.57C6.53 9.24 9.24 6.53 12.57 6.53H20.6C23.93 6.53 26.64 9.24 26.64 12.57V20.6Z" fill="var(--fill-0, #0E1B42)"/>
</svg>
{% elif item.value.platform|lower == "youtube" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="youtube" role="img">
<path d="M20.36 16.45L15.14 13.61C14.93 13.49 14.19 13.64 14.19 13.89V19.43C14.19 19.67 14.92 19.83 15.13 19.71L20.59 17.01C20.81 16.89 20.59 16.57 20.36 16.45Z" fill="var(--fill-0, #0E1B42)"/>
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0ZM27.74 19.42C27.74 22.05 25.61 24.18 22.98 24.18H10.81C8.18 24.18 6.05 22.05 6.05 19.42V13.76C6.05 11.13 8.18 9 10.81 9H22.98C25.61 9 27.74 11.13 27.74 13.76V19.42Z" fill="var(--fill-0, #0E1B42)"/>
</svg>
{% elif item.value.platform|lower == "threads" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="threads" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M6.321 6.016c-.27-.18-1.166-.802-1.166-.802.756-1.081 1.753-1.502 3.132-1.502.975 0 1.803.327 2.394.948s.928 1.509 1.005 2.644q.492.207.905.484c1.109.745 1.719 1.86 1.719 3.137 0 2.716-2.226 5.075-6.256 5.075C4.594 16 1 13.987 1 7.994 1 2.034 4.482 0 8.044 0 9.69 0 13.55.243 15 5.036l-1.36.353C12.516 1.974 10.163 1.43 8.006 1.43c-3.565 0-5.582 2.171-5.582 6.79 0 4.143 2.254 6.343 5.63 6.343 2.777 0 4.847-1.443 4.847-3.556 0-1.438-1.208-2.127-1.27-2.127-.236 1.234-.868 3.31-3.644 3.31-1.618 0-3.013-1.118-3.013-2.582 0-2.09 1.984-2.847 3.55-2.847.586 0 1.294.04 1.663.114 0-.637-.54-1.728-1.9-1.728-1.25 0-1.566.405-1.967.868ZM8.716 8.19c-2.04 0-2.304.87-2.304 1.416 0 .878 1.043 1.168 1.6 1.168 1.02 0 2.067-.282 2.232-2.423a6.2 6.2 0 0 0-1.528-.161" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% elif item.value.platform|lower == "linkedin" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="linkedin" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% elif item.value.platform|lower == "x" or item.value.platform|lower == "twitter" or item.value.platform|lower == "twitter-x" %}
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33.18 33.18" aria-label="twitter-x" role="img">
<path d="M16.59 0C7.43 0 0 7.43 0 16.59C0 25.75 7.43 33.18 16.59 33.18C25.75 33.18 33.18 25.75 33.18 16.59C33.18 7.43 25.75 0 16.59 0Z" fill="var(--fill-0, #0E1B42)"/>
<path class="icon-cutout" d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" transform="translate(7.59 7.59) scale(1.125)"/>
</svg>
{% else %}
{{ item.value.platform }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- </div> -->
</div>
<div class="footer-powered">power by INNOVEDUS</div>
</div>
</footer>

View File

@ -1,4 +1,4 @@
{% load wagtailsettings_tags wagtailimages_tags %}
{% load wagtailsettings_tags wagtailimages_tags wagtailcore_tags %}
{% get_settings use_default_site=True as settings %}
<header class="site-header{% if settings.base.HeaderSettings.logo_light and settings.base.HeaderSettings.logo_dark %} has-logo-variants{% endif %}">
@ -39,67 +39,132 @@
<nav class="main-nav" id="site-nav">
<ul class="main-menu" id="main-menu">
{% with site_root=page.get_site.root_page %}
{# Top-level menu: direct children of site root #}
<li class="menu-item">
<div class="menu-item-header">
<a href="#">
<span class="main-menu-link">最新文章</span>
</a>
{% if nav_latest_page or nav_trending_page %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
{% endif %}
</div>
{% if nav_latest_page or nav_trending_page %}
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% if nav_latest_page %}
<li class="submenu-item">
<a href="{{ nav_latest_page.url }}">
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
</a>
</li>
{% endif %}
{% if nav_trending_page %}
<li class="submenu-item">
<a href="{{ nav_trending_page.url }}">
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
</a>
</li>
{% endif %}
</ul>
{% endif %}
</li>
{% for menu_page in site_root.get_children.live.in_menu %}
{% wagtail_site as current_site %}
{% if page %}
{% with site_root=page.get_site.root_page %}
{# Top-level menu: direct children of site root #}
<li class="menu-item">
<div class="menu-item-header">
<a href="{{ menu_page.url }}">
<span class="main-menu-link">{{ menu_page.title }}</span>
<a href="#">
<span class="main-menu-link">最新文章</span>
</a>
{% if nav_latest_page or nav_trending_page %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
{% endif %}
</div>
{% if nav_latest_page or nav_trending_page %}
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% if nav_latest_page %}
<li class="submenu-item">
<a href="{{ nav_latest_page.url }}">
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
</a>
</li>
{% endif %}
{% if nav_trending_page %}
<li class="submenu-item">
<a href="{{ nav_trending_page.url }}">
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
</a>
</li>
{% endif %}
</ul>
{% endif %}
</li>
{% for menu_page in site_root.get_children.live.in_menu %}
<li class="menu-item">
<div class="menu-item-header">
<a href="{{ menu_page.url }}">
<span class="main-menu-link">{{ menu_page.title }}</span>
</a>
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
{% endif %}
{% endwith %}
</div>
{# Second-level: direct children of each top-level page #}
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% for subpage in submenu %}
<li class="submenu-item">
<a href="{{ subpage.url }}">
<span class="submenu-item-link">{{ subpage.title }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
{# Second-level: direct children of each top-level page #}
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% for subpage in submenu %}
<li class="submenu-item">
<a href="{{ subpage.url }}">
<span class="submenu-item-link">{{ subpage.title }}</span>
</a>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
{% endwith %}
{% elif current_site %}
{% with site_root=current_site.root_page %}
{# Top-level menu: direct children of site root #}
<li class="menu-item">
<div class="menu-item-header">
<a href="#">
<span class="main-menu-link">最新文章</span>
</a>
{% if nav_latest_page or nav_trending_page %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
{% endif %}
{% endwith %}
</div>
{% if nav_latest_page or nav_trending_page %}
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% if nav_latest_page %}
<li class="submenu-item">
<a href="{{ nav_latest_page.url }}">
<span class="submenu-item-link">{{ nav_latest_page.title }}</span>
</a>
</li>
{% endif %}
{% if nav_trending_page %}
<li class="submenu-item">
<a href="{{ nav_trending_page.url }}">
<span class="submenu-item-link">{{ nav_trending_page.title }}</span>
</a>
</li>
{% endif %}
</ul>
{% endif %}
</li>
{% endfor %}
{% endwith %}
{% for menu_page in site_root.get_children.live.in_menu %}
<li class="menu-item">
<div class="menu-item-header">
<a href="{{ menu_page.url }}">
<span class="main-menu-link">{{ menu_page.title }}</span>
</a>
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<button class="submenu-toggle" type="button" aria-expanded="false" aria-label="Toggle submenu"></button>
{% endif %}
{% endwith %}
</div>
{# Second-level: direct children of each top-level page #}
{% with submenu=menu_page.get_children.live.in_menu %}
{% if submenu %}
<span class="menu-divider" aria-hidden="true"></span>
<ul class="submenu">
{% for subpage in submenu %}
<li class="submenu-item">
<a href="{{ subpage.url }}">
<span class="submenu-item-link">{{ subpage.title }}</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</li>
{% endfor %}
{% endwith %}
{% endif %}
{# Optional extra links from settings #}
{% if settings.base.HeaderSettings.main_links %}

View File

@ -0,0 +1,22 @@
<div class="subscribe-fab" data-subscribe-fab>
<div id="subscribe-fab-panel" class="subscribe-fab__panel">
<button class="subscribe-fab__toggle" type="button" aria-expanded="false" aria-controls="subscribe-fab-panel">
<svg class="subscribe-fab__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 6h18v12H3z" fill="none" stroke="currentColor" stroke-width="0.5"/>
<path d="M3 7l9 7 9-7" fill="none" stroke="currentColor" stroke-width="0.5"/>
</svg>
<span class="subscribe-fab__text">點我訂閱</span>
</button>
<form class="subscribe-fab__form" method="post" action="{% url 'newsletter_subscribe' %}">
{% csrf_token %}
<input
class="subscribe-fab__input"
type="email"
name="email"
required
placeholder="輸入 Email">
<button class="subscribe-fab__submit" type="submit">確認訂閱</button>
</form>
</div>
</div>

View File

@ -0,0 +1,60 @@
{% extends "wagtailadmin/generic/edit.html" %}
{% load i18n wagtailadmin_tags %}
{% block before_form %}
{% if site_switcher %}
<form class="w-mb-8" method="get" id="settings-site-switch" novalidate>
<label for="{{ site_switcher.site.id_for_label }}">
{% trans "Site" %}:
</label>
{{ site_switcher.site }}
</form>
{% elif site_for_header %}
<div class="w-mb-8">
{% trans "Site" %}:
{{ site_for_header.hostname }}
{% if site_for_header.is_default_site %}[{% trans "default" %}]{% endif %}
</div>
{% endif %}
{% endblock %}
{% block form_content %}
{% if panel %}
{{ panel.render_form_content }}
{% else %}
{{ block.super }}
{% endif %}
{% if form.smtp_relay_host and form.smtp_password %}
<section class="w-panel w-mt-8">
<div class="w-panel__header">
<h2 class="w-panel__heading">{% trans "Send test email (save settings before sending)." %}</h2>
</div>
<div class="w-panel__content">
<ul class="fields">
<li>
<label class="w-block w-font-semibold w-mb-2" for="smtp_test_email">{% trans "Send email to" %}</label>
<input
class="w-input"
type="email"
id="smtp_test_email"
name="smtp_test_email"
value="{{ form.data.smtp_test_email|default:'' }}"
placeholder="name@example.com"
>
<p class="help">{% trans "This field is only for this test and will not be saved." %}</p>
</li>
</ul>
<button
type="submit"
class="button button-secondary"
formaction="{% url 'newsletter_smtp_test' %}"
formmethod="post"
formnovalidate
>
{% trans "Send test email" %}
</button>
</div>
</section>
{% endif %}
{% endblock %}

View File

@ -8,6 +8,7 @@ from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views
from home import views as home_views
from base import views as base_views
urlpatterns = [
path("django-admin/", admin.site.urls),
@ -16,6 +17,13 @@ urlpatterns = [
# use <str:slug> so Unicode tag slugs (e.g. 台北美食) still resolve
path("tags/<str:slug>/", home_views.hashtag_search, name="hashtag_search"),
path("search/", search_views.search, name="search"),
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/unsubscribe/", base_views.newsletter_unsubscribe, name="newsletter_unsubscribe"),
path("u/unsubscribe", base_views.one_click_unsubscribe, name="one_click_unsubscribe"),
path("newsletter/smtp-test/", base_views.newsletter_smtp_test, name="newsletter_smtp_test"),
path("newsletter/campaigns/<int:campaign_id>/send-now/", base_views.newsletter_campaign_send_now, name="newsletter_campaign_send_now"),
]

View File

@ -4,4 +4,5 @@ gunicorn
dj-database-url
psycopg[binary]
python-dotenv
django-storages[boto3]
django-storages[boto3]
certifi

View File

@ -1,33 +1,29 @@
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% load wagtailcore_tags static %}
{% block body_class %}template-searchresults{% endblock %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/category.css' %}">
{% endblock %}
{% block title %}
{% if search_query %}搜尋:{{ search_query }}{% else %}搜尋{% endif %}
{% endblock %}
{% block content %}
<nav class="breadcrumbs" aria-label="breadcrumb">
<ol>
<li>
{% if site_root %}
<a href="{{ site_root.url }}">首頁</a>
{% else %}
<a href="/">首頁</a>
{% endif %}
</li>
<li><span>搜尋</span></li>
{% if search_query %}
<li><span>{{ search_query }}</span></li>
{% endif %}
</ol>
</nav>
{% if search_query %}
<div class="site-hero-band full-bleed">
<div class="site-container">
<div class="block-title category-title"><span>{{ search_query }}</span></div>
</div>
</div>
{% endif %}
<section class="search-results">
{% if search_query %}
{% if results_count %}
{% include "home/includes/page-article-list.html" %}
{% include "home/includes/page-article-list.html" with category=category_sections.0 show_hero=False empty_message="沒有符合條件的文章" %}
{% else %}
<p>找不到與「{{ search_query }}」相關的文章。</p>
{% endif %}