diff --git a/docs/newsletter_integration_memo.md b/docs/newsletter_integration_memo.md new file mode 100644 index 0000000..25f899e --- /dev/null +++ b/docs/newsletter_integration_memo.md @@ -0,0 +1,156 @@ +# 電子報介接備忘錄(租戶端 / Wagtail) + +最後更新:2026-02-11 + +## 1. 目標與前提 + +本備忘錄用於整理租戶端(Wagtail)接下來的實作方向,重點是先完成可運行流程,再逐步補完細節。 + +## 2. 已確認決策(本次會議結論) + +1. Member Center 雖為共用平台,但目前只有一個租戶。 +2. 現階段「發確認信」與「訂閱/退訂頁面」先做在 Wagtail。 +3. Wagtail 與 Member Center 的溝通以 API 為主。 +4. SMTP relay 僅負責寄出一次,重送、token 安全、簽章等由其他系統或工具負責,不屬 Wagtail 責任。 +5. 工作大項與順序固定為: + 1. 準備工作(電子報系統設定) + 2. 訂閱流程 + 3. 退訂流程 + 4. 電子報 app + 5. 發信流程(排程) + +## 3. 工作大項與執行順序(含範圍) + +## 3.1 準備工作(電子報系統設定,第一優先) + +範圍: +- 建立 Member Center 連線設定(base URL、tenant_id、list_id、API timeout 等)。 +- 建立 Send Engine 連線設定(base URL、OAuth scope、API timeout 等)。 +- 建立一般發信設定(SMTP relay、寄件者名稱/信箱、reply-to、預設編碼等)。 +- 建立模板設定: + - 訂閱確認信 template + - 訂閱確認(成功)頁面 template + - 取消訂閱頁面 template + +重點: +- 設定集中管理,避免散落在程式碼或多處後台欄位。 +- 區分「系統連線設定」與「內容模板設定」,便於權限與維運。 + +完成標準(DoD): +- 可在單一設定入口完成上述設定並通過基本驗證。 +- 模板可被後續訂閱/退訂流程直接引用。 + +## 3.2 訂閱流程(第二優先) + +範圍: +- 在站台頁面區塊(暫定 Footer)提供 email 訂閱入口。 +- 後端接收 email,呼叫 Member Center 訂閱 API。 +- 由 Wagtail 送出訂閱確認信(透過 SMTP relay)。 +- 使用者點信內連結回到 Wagtail 確認頁。 +- 確認頁自動呼叫 Member Center 確認 API。 + +重點: +- 流程中 token 需一路帶入與驗證。 +- 確認信內容採 HTML(可附純文字 fallback)。 + +完成標準(DoD): +- 可從站台成功發起訂閱。 +- 可收到確認信並完成確認。 +- 確認成功/失敗頁有明確訊息與追蹤記錄。 + +## 3.3 退訂流程(第三優先) + +範圍: +- 電子報底部提供退訂連結,導向 Wagtail 退訂頁。 +- 進入退訂頁時,先向 Member Center 申請退訂 token。 +- 使用者按確認退訂後,呼叫 Member Center 退訂 API 完成流程。 + +重點: +- token 不做長期保存。 +- 頁面需可處理 token 失效/不存在等錯誤狀態。 + +完成標準(DoD): +- 使用者能從信件連結完成退訂。 +- 退訂結果頁可正確呈現成功/失敗狀態。 + +## 3.4 電子報 app(第四優先) + +範圍: +- 在 Wagtail 後台提供電子報內容管理能力。 +- 提供 HTML 編輯器建立/編修內容。 +- 支援可替換參數(例如 token、email)。 + +重點: +- 編輯器中的連結參數以佔位符表示,發送前由 backend 替換。 +- 若有 Member Center API 相關連結需在內容中可配置,也採相同參數機制。 + +完成標準(DoD): +- 編輯器可存草稿與更新內容。 +- 內容中參數可在送出前正確替換。 + +## 3.5 發信流程(排程,第五優先) + +範圍: +- 可建立發送任務與排程時間。 +- 排程前可持續編輯內容與調整時間。 +- 到時觸發時:先取 Member Center auth,再將內容包固定 header/footer,送到 Send Engine。 + +重點: +- 「內容定稿」與「實際送出」拆開。 +- 發送前的模板組裝由 Wagtail backend 負責。 + +完成標準(DoD): +- 排程到點可成功建立 Send Engine send job。 +- 失敗可記錄原因並可人工重試。 + +## 4. 參數替換規則(先行約定) + +建議佔位符格式(待實作時可再定版): +- `{{token}}` +- `{{email}}` +- `{{list_id}}` +- `{{tenant_id}}` +- `{{confirm_url}}` +- `{{unsubscribe_url}}` + +替換時機: +- 發信前 backend 最後一步統一替換。 + +注意事項: +- URL 參數需做 URL encode。 +- 缺少必要參數時,中止發送並記錄可追蹤錯誤。 + +## 5. URL 與系統歸屬標註規範(文件撰寫規則) + +後續文件凡提到網址,必須明確標註「是哪個系統」: + +1. Wagtail(租戶站台)網址 +- 例:`https://{tenant-site}/newsletter/confirm?token=...` +- 用途:使用者互動頁、站台前台/後台頁面。 + +2. Member Center API 網址 +- 例:`https://{member-center}/newsletter/subscribe` +- 用途:訂閱、確認、退訂 token、退訂等 API。 + +3. Send Engine API 網址 +- 例:`https://{send-engine}/api/send-jobs` +- 用途:建立/查詢/取消發信任務。 + +4. SMTP Relay 連線端點 +- 用途:Wagtail 寄送確認信。 +- 備註:僅作為寄送通道,不承擔重送與安全機制。 + +## 6. 非 Wagtail 責任邊界(本階段) + +以下不納入目前 Wagtail 工作範圍: +- SMTP 後續重送策略。 +- token 簽章與高階安全策略。 +- 跨系統風控與防濫用機制。 + +## 7. 下一步(實作啟動清單) + +1. 先完成電子報系統設定(Member Center / Send Engine / SMTP / 模板)。 +2. 再完成訂閱流程 API 串接與確認頁。 +3. 再完成退訂頁與退訂 API 串接。 +4. 建立電子報 app 與 HTML 編輯器資料模型。 +5. 最後接排程任務與 Send Engine 發信。 diff --git a/innovedus_cms/home/models.py b/innovedus_cms/home/models.py index d62d0ba..d88b933 100644 --- a/innovedus_cms/home/models.py +++ b/innovedus_cms/home/models.py @@ -23,6 +23,19 @@ BLOCK_SIZE = _get_env_int("HOMEPAGE_BLOCK_SIZE", 7) # Default to 7 articles in b HORIZON_SIZE = _get_env_int("HOMEPAGE_HORIZON_SIZE", 4) # Default to 4 articles in horizon layout PAGE_SIZE = _get_env_int("HOMEPAGE_PAGE_SIZE", 10) # Default to 10 articles per page for pagination CATEGORY_HOT_SIZE = _get_env_int("CATEGORY_HOT_SIZE", 6) # Default to 4 articles in category hot layout +RELATED_ARTICLES_SIZE = _get_env_int("RELATED_ARTICLES_SIZE", 4) # Default to 4 related articles + +class BreadcrumbMixin: + def build_breadcrumbs(self): + site = self.get_site() + site_root = site.root_page if site else None + if site_root: + ancestors = self.get_ancestors().specific().filter(depth__gt=site_root.depth) + else: + ancestors = self.get_ancestors().specific() + breadcrumbs = list(ancestors) + [self] + return breadcrumbs, site_root + # Mixin for Category-related functionality class CategoryMixin: @@ -70,17 +83,6 @@ class CategoryMixin: ) return blocks - # Build breadcrumbs - def build_breadcrumbs(self): - site = self.get_site() - site_root = site.root_page if site else None - if site_root: - ancestors = self.get_ancestors().specific().filter(depth__gt=site_root.depth) - else: - ancestors = self.get_ancestors().specific() - breadcrumbs = list(ancestors) + [self] - return breadcrumbs, site_root - # Get latest articles def get_latest_articles(self, request=None): latest_page = LatestPage.objects.first() @@ -196,7 +198,7 @@ class HomePage(Page, CategoryMixin): return context -class LatestPage(Page, CategoryMixin): +class LatestPage(Page, CategoryMixin, BreadcrumbMixin): template = "home/category_page.html" def get_context(self, request): @@ -208,7 +210,7 @@ class LatestPage(Page, CategoryMixin): return context -class TrendingPage(Page, CategoryMixin): +class TrendingPage(Page, CategoryMixin, BreadcrumbMixin): template = "home/category_page.html" def get_context(self, request): @@ -220,7 +222,7 @@ class TrendingPage(Page, CategoryMixin): return context -class CategoryPage(Page, CategoryMixin): +class CategoryPage(Page, CategoryMixin, BreadcrumbMixin): @property def has_subcategories(self): return self.get_children().type(CategoryPage).live().exists() @@ -254,7 +256,7 @@ class ArticlePageTag(TaggedItemBase): on_delete=models.CASCADE, ) -class ArticlePage(Page): +class ArticlePage(Page, BreadcrumbMixin): cover_image = models.ForeignKey( "wagtailimages.Image", null=True, @@ -308,6 +310,16 @@ class ArticlePage(Page): def get_context(self, request): context = super().get_context(request) + breadcrumbs, site_root = self.build_breadcrumbs() + context["breadcrumbs"] = breadcrumbs + context["breadcrumb_root"] = site_root + category_crumbs = [ + crumb + for crumb in breadcrumbs + if crumb.id != self.id and (not site_root or crumb.id != site_root.id) + ] + context["article_category"] = category_crumbs[-1] if category_crumbs else None + tag_ids = list(self.tags.values_list("id", flat=True)) if tag_ids: related_articles = ( @@ -315,7 +327,7 @@ class ArticlePage(Page): .exclude(id=self.id) .filter(tags__id__in=tag_ids) .distinct() - .order_by("-date", "-id")[:4] + .order_by("-date", "-id")[:RELATED_ARTICLES_SIZE] ) else: related_articles = ArticlePage.objects.none() diff --git a/innovedus_cms/home/static/css/article_page.css b/innovedus_cms/home/static/css/article_page.css new file mode 100644 index 0000000..4c50733 --- /dev/null +++ b/innovedus_cms/home/static/css/article_page.css @@ -0,0 +1,396 @@ +.article-page { + width: 100%; + max-width: 718px; + margin: 0 auto; +} + +.article-breadcrumbs { + margin: 12px 0 16px; +} + +.article-breadcrumbs ol { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.article-breadcrumbs li { + font-size: 12px; + font-weight: 700; + line-height: 1.5; + color: #0e1b4266; +} + +.article-breadcrumbs li + li::before { + content: "/"; + margin: 0 8px; +} + +.article-breadcrumbs a:hover { + text-decoration: underline; +} + +.block-title { + background-color: #0e1b42; + color: #ffffff; +} + +.article-content img { + max-width: 718px; + height: auto; + display: block; + margin: 16px 0; +} + +.article-content h1 { + font-size: 36px; + margin: 24px 0; +} + +.article-content h2 { + font-size: 32px; + margin: 24px 0; +} + +.article-content p { + font-size: 14px; +} + +.article-content .intro { + font-size: 24px; +} + +.article-content .body iframe { + display: block; + width: 100% !important; + max-width: 100% !important; + border: 0; +} + +.article-content .body blockquote.instagram-media, +.article-content .body blockquote.twitter-tweet, +.article-content .body .fb-post, +.article-content .body .fb-video { + max-width: 100% !important; + min-width: 0 !important; + margin-left: auto !important; + margin-right: auto !important; +} + +.article-content .date { + display: flex; + align-items: center; + font-size: 24px; + color: #0e1b4266; + margin: 24px 0; +} + +.article-content .date::after { + content: ""; + flex: 1; + height: 1px; + background: #0e1b4266; + margin-left: 16px; +} + +.article-tags { + display: flex; + justify-content: flex-end; + margin: 24px 0; +} + +.article-tags .tag-chips { + flex-wrap: nowrap; + white-space: nowrap; + overflow-x: auto; +} + +.tag-chips li { + flex: 0 0 auto; +} + +.tag-chips a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 96px; + height: 25px; + color: #ffffff; + font-size: 10px; + font-weight: 800; + line-height: 1; + overflow: hidden; +} + +.tag-chips li:nth-child(3n + 1) a { + background: #00abf5; +} + +.tag-chips li:nth-child(3n + 2) a { + background: #f4a41c; +} + +.tag-chips li:nth-child(3n) a { + background: #ff461c; +} + +.article-share { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin: 64px 0; +} + +.article-share .icon { + width: 32px; + height: 32px; + --fill-0: #0e1b4266; +} + +.article-share .icon .icon-cutout { + fill: #ffffff; +} + +.related-articles { + margin-top: 32px; + margin-bottom: 32px; +} + +.related-articles h2 { + margin: 0 0 24px; + text-align: center; + font-size: 24px; + font-weight: 600; + color: #0e1b4266; +} + +.related-articles-list { + display: flex; + flex-direction: column; + gap: 24px; +} + +.related-articles-list > hr { + width: 100%; + margin: 12px 0; + margin-inline: 0; + border: 0; + border-top: 1px solid #0e1b4266; +} + +.related-article-item { + display: grid; + grid-template-columns: 194px 1fr; + gap: 24px; + align-items: start; +} + +.related-article-image { + display: block; + width: 194px; +} + +.related-article-image img { + width: 194px; + max-width: 100%; + height: 133px; + display: block; + object-fit: cover; + border-radius: 5px; +} + +.related-article-date { + margin: 10px 0 0; + text-align: left; + font-size: 16px; + color: #0e1b4266; +} + +.related-article-title { + margin: 0 0 12px; + font-size: 28px; + line-height: 1.3; +} + +.tag-chips { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.related-article-tags { + flex-wrap: wrap; + gap: 8px 12px; +} + +.back-to-top { + position: fixed; + right: var(--back-to-top-right, 24px); + bottom: 24px; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 0; + border: 0; + background: transparent; + color: #0e1b42; + cursor: pointer; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.back-to-top.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.back-to-top__icon { + position: relative; + display: block; + width: 48px; + height: 48px; + background: #ffffff; + border: 1px solid #d9deea; + box-shadow: 0 3px 8px rgba(14, 27, 66, 0.18); +} + +.back-to-top__icon::before, +.back-to-top__icon::after { + content: ""; + position: absolute; + top: 24px; + width: 14px; + height: 2px; + background: #0e1b42; +} + +.back-to-top__icon::before { + left: 12px; + transform: rotate(-45deg); +} + +.back-to-top__icon::after { + right: 12px; + transform: rotate(45deg); +} + +.back-to-top__label { + font-size: 24px; + line-height: 1; +} + +@media (min-width: 768px) and (max-width: 1023px) { + .article-page { + max-width: 560px; + } + + .article-content img { + max-width: 560px; + } + + .article-content h2 { + font-size: 24px; + } +} + +@media (min-width: 575px) and (max-width: 767px) { + .article-page { + max-width: 426px; + } + + .article-content img { + max-width: 426px; + } + + .related-article-title { + font-size: 20px; + } + + .back-to-top { + bottom: 20px; + } +} + +@media (max-width: 574px) { + .article-page { + max-width: 300px; + } + + .article-content img { + max-width: 300px; + } + + .article-content h1 { + font-size: 24px; + } + + .article-content .intro { + font-size: 18px; + } + + .article-content .date { + font-size: 16px; + } + + .article-tags { + justify-content: center; + } + + .related-article-item { + grid-template-columns: 139px 1fr; + gap: 12px; + } + + .related-article-image { + width: 139px; + } + + .related-article-image img { + width: 139px; + height: 110px; + } + + .related-article-date { + font-size: 12px; + } + + .related-article-title { + font-size: 16px; + } + + .tag-chips a { + width: 70px; + } + + .related-article-tags { + flex-wrap: wrap; + gap: 6px 8px; + } + + .back-to-top { + bottom: 16px; + } + + .back-to-top__label { + font-size: 18px; + } +} + +@media (max-width: 767px) { + .article-breadcrumbs { + margin: 8px 0 12px; + } +} diff --git a/innovedus_cms/home/static/css/category.css b/innovedus_cms/home/static/css/category.css index 20e3e37..a748c31 100644 --- a/innovedus_cms/home/static/css/category.css +++ b/innovedus_cms/home/static/css/category.css @@ -1,17 +1,3 @@ -.block-title { - display: inline-block; - width: 197px; - height: 87px; - vertical-align: middle; - font-size: 20px; - font-weight: 700; -} - -.block-title span { - padding-left: 21px; - line-height: 87px; -} - .category-title { background-color: #00abf5; color: #ffffff; @@ -50,17 +36,6 @@ } @media (max-width: 574px) { - .block-title { - width: 139px; - height: 55px; - font-size: 16px; - } - - .block-title span { - padding-left: 14px; - line-height: 55px; - } - .subcategory-title span { width: 139px; height: 55px; diff --git a/innovedus_cms/home/static/css/home.css b/innovedus_cms/home/static/css/home.css index 07a05e1..81a1f45 100644 --- a/innovedus_cms/home/static/css/home.css +++ b/innovedus_cms/home/static/css/home.css @@ -13,20 +13,6 @@ margin: 10px 0; } -.block-title { - display: inline-block; - width: 197px; - height: 87px; - vertical-align: middle; - font-size: 20px; - font-weight: 700; -} - -.block-title span { - padding-left: 21px; - line-height: 87px; -} - .block-title-divider { display: inline-flex; width: 28px; @@ -78,17 +64,6 @@ } @media (max-width: 574px) { - .block-title { - width: 139px; - height: 55px; - font-size: 16px; - } - - .block-title span { - padding-left: 14px; - line-height: 55px; - } - .block-title-divider { width: 20px; height: 1px; diff --git a/innovedus_cms/home/static/js/article_page.js b/innovedus_cms/home/static/js/article_page.js new file mode 100644 index 0000000..bbd53ae --- /dev/null +++ b/innovedus_cms/home/static/js/article_page.js @@ -0,0 +1,67 @@ +(function () { + const button = document.querySelector(".back-to-top"); + const articlePage = document.querySelector(".article-page"); + const articleBody = document.querySelector(".article-content .body"); + if (!button) return; + + const toggleVisibility = () => { + button.classList.toggle("is-visible", window.scrollY > 200); + }; + + const syncHorizontalPosition = () => { + if (!articlePage) return; + const rightGap = Math.max(0, window.innerWidth - articlePage.getBoundingClientRect().right); + button.style.setProperty("--back-to-top-right", `${rightGap}px`); + }; + + const makeEmbedsResponsive = () => { + if (!articleBody) return; + + articleBody.querySelectorAll("iframe").forEach((iframe) => { + const src = (iframe.getAttribute("src") || "").toLowerCase(); + const isFixedRatioPlayer = + src.includes("youtube.com") || + src.includes("youtube-nocookie.com") || + src.includes("m.youtube.com") || + src.includes("youtu.be") || + src.includes("vimeo.com") || + src.includes("player.vimeo.com") || + src.includes("dailymotion.com"); + const width = Number(iframe.getAttribute("width")); + const height = Number(iframe.getAttribute("height")); + + iframe.style.width = "100%"; + iframe.style.maxWidth = "100%"; + + if (isFixedRatioPlayer) { + if (width > 0 && height > 0) { + iframe.style.aspectRatio = `${width} / ${height}`; + } else { + iframe.style.aspectRatio = "16 / 9"; + } + iframe.style.height = "auto"; + } else { + iframe.style.removeProperty("aspect-ratio"); + iframe.style.removeProperty("height"); + } + }); + }; + + window.addEventListener("scroll", toggleVisibility, { passive: true }); + window.addEventListener("resize", syncHorizontalPosition); + + toggleVisibility(); + syncHorizontalPosition(); + makeEmbedsResponsive(); + + if (articleBody && "MutationObserver" in window) { + const observer = new MutationObserver(() => { + makeEmbedsResponsive(); + }); + observer.observe(articleBody, { childList: true, subtree: true }); + } + + button.addEventListener("click", function () { + window.scrollTo({ top: 0, behavior: "smooth" }); + }); +})(); diff --git a/innovedus_cms/home/templates/home/article_page.html b/innovedus_cms/home/templates/home/article_page.html index f2891ab..528c3ef 100644 --- a/innovedus_cms/home/templates/home/article_page.html +++ b/innovedus_cms/home/templates/home/article_page.html @@ -1,35 +1,125 @@ {% extends "base.html" %} -{% load wagtailcore_tags wagtailimages_tags %} +{% load wagtailcore_tags wagtailimages_tags static %} + +{% block extra_css %} + +{% endblock %} {% block content %} -
-

{{ page.title }}

- {% if page.banner_image %} - {% image page.banner_image original as banner %} - {{ page.title }} +
+ {% if breadcrumbs %} + {% endif %} -

{{ page.date }}

-
{{ page.intro }}
-
- {{ page.body }} + +
+ {% if page.cover_image %} + {% image page.cover_image original as cover %} + {{ page.title }} + {% endif %} +

{{ page.title }}

+

{{ page.date|date:"Y/m/d" }}

+
{{ page.intro }}
+
+ {{ page.body }} +
+
+ {% with share_url=request.build_absolute_uri|urlencode share_text=page.title|urlencode %} +
+ + + + + + + + + + + + + + + + + +
+ {% endwith %} {% with tags=page.tags.all %} {% if tags %} -
- Hashtags: -
+ +{% endblock %} + +{% block extra_js %} + {{ block.super }} + {% endblock %} diff --git a/innovedus_cms/mysite/context_processors.py b/innovedus_cms/mysite/context_processors.py new file mode 100644 index 0000000..ee31c19 --- /dev/null +++ b/innovedus_cms/mysite/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings + + +def ga4(request): + return { + "ga4_measurement_id": getattr(settings, "GA4_MEASUREMENT_ID", ""), + } diff --git a/innovedus_cms/mysite/settings/base.py b/innovedus_cms/mysite/settings/base.py index e040ad4..4b43aa4 100644 --- a/innovedus_cms/mysite/settings/base.py +++ b/innovedus_cms/mysite/settings/base.py @@ -26,6 +26,9 @@ def env_list(name, default): return default +GA4_MEASUREMENT_ID = os.environ.get("GA4_MEASUREMENT_ID", "").strip() + + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ @@ -87,6 +90,7 @@ TEMPLATES = [ "django.contrib.messages.context_processors.messages", "wagtail.contrib.settings.context_processors.settings", "home.context_processors.navigation_pages", + "mysite.context_processors.ga4", ], }, }, diff --git a/innovedus_cms/mysite/static/css/mysite.css b/innovedus_cms/mysite/static/css/mysite.css index bf79be7..57bc3db 100644 --- a/innovedus_cms/mysite/static/css/mysite.css +++ b/innovedus_cms/mysite/static/css/mysite.css @@ -369,6 +369,20 @@ footer .footer-sections { flex-wrap: wrap; } +.block-title { + display: inline-block; + width: 197px; + height: 87px; + vertical-align: middle; + font-size: 20px; + font-weight: 700; +} + +.block-title span { + padding-left: 21px; + line-height: 87px; +} + .template-darkbackground .site-hero-band { background-color: #0e1b42; color: #ffffff; @@ -578,6 +592,17 @@ footer .footer-sections { .main-nav { right: 16px; } + + .block-title { + width: 139px; + height: 55px; + font-size: 16px; + } + + .block-title span { + padding-left: 14px; + line-height: 55px; + } } @layer figreset { diff --git a/innovedus_cms/mysite/templates/base.html b/innovedus_cms/mysite/templates/base.html index a406f9a..f34b3cf 100644 --- a/innovedus_cms/mysite/templates/base.html +++ b/innovedus_cms/mysite/templates/base.html @@ -17,6 +17,15 @@ {% endif %} + {% if ga4_measurement_id %} + + + {% endif %} {# Force all links in the live preview panel to be opened in a new tab #} {% if request.in_preview_panel %}