Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-ratewise-pwa-etag-convergence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,移除 jsDelivr 跨域 If-None-Match 條件式請求,匯率與換錢所 CDN 皆以 cache no-cache 強制重新驗證
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,8 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性)
- UI/UX 或基礎設施深度審查時,偏好並行多 agent 分面向分析並產出可執行 SPEC。
- 調整 Cloudflare 邊緣設定(Worker、Cache Rules、Transform Rules)前,必須先做本地安全備份。
- 生產環境有真實使用者;release 與 edge 變更需依 phased 順序(app → Worker → purge → live 驗證)且足夠嚴謹。
- 合併 PR 時,較大型或功能型 PR 必須等待使用者明確批准,不可自動合併;可先合併無衝突的小型 PR。
- RateWise 產品與 SEO 文案定位強調銀行賣出價(實際換匯/刷卡),而非中間價;目標成為台灣最精準匯率工具。

## Learned Workspace Facts

Expand All @@ -814,6 +816,10 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性)
- `wrangler.jsonc` 的 observability/logs 取樣不應長期維持 100%;044 目標 ≤0.1。
- 本地 `wrangler deploy` 後若 `curl` 的 `x-security-policy-version` 與預期不符,可能是 edge 仍回舊版,需 purge 或 cache-busting 後再驗證。
- RateWise 2026 UX 產品規格 SSOT:`docs/superpowers/specs/2026-06-12-ratewise-2026-product-ux-spec.md`。
- RateWise UI/UX 審查框架 SSOT:`docs/prompt/UIUX.md`。
- RateWise 行動 PWA QA 稽核 SPEC:`docs/superpowers/specs/2026-06-26-ratewise-mobile-pwa-qa-audit-spec.md`。
- SEO 治理 Master SSOT:`docs/SEO_MASTER_SSOT.md`。
- Zen 主題主色已收斂為 violet-600(`apps/ratewise/src/index.css` 的 `--color-primary`)。

---

Expand Down
232 changes: 207 additions & 25 deletions apps/ratewise/public/offline.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,184 @@
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#8B5CF6" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#7C3AED" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>離線模式 - HaoRate</title>
<!-- [fix:2026-01-08] 使用相對路徑,確保在任何 base path 下都能正確載入 -->
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<script>
(function () {
var themeColors = {
zen: '#7C3AED',
nitro: '#0096E6',
kawaii: '#BE185D',
classic: '#8B4513',
ocean: '#06B6D4',
forest: '#059669',
};
var supportedStyles = Object.keys(themeColors);
var selectedStyle = 'zen';

try {
var storedTheme = localStorage.getItem('ratewise-theme');
if (storedTheme) {
var parsedTheme = JSON.parse(storedTheme);
if (supportedStyles.indexOf(parsedTheme.style) >= 0) {
selectedStyle = parsedTheme.style;
}
}
} catch (e) {
selectedStyle = 'zen';
}

document.documentElement.dataset.style = selectedStyle;

var themeColor = document.querySelector('meta[name="theme-color"]');
if (themeColor) {
themeColor.setAttribute('content', themeColors[selectedStyle] || themeColors.zen);
}
})();
</script>
<style>
html[data-style='zen'] {
--offline-theme-color: #7c3aed;
--offline-background: #f8fafc;
--offline-surface: #ffffff;
--offline-border: #e2e8f0;
--offline-text: #0f172a;
--offline-text-muted: #64748b;
--offline-primary: #7c3aed;
--offline-primary-foreground: #ffffff;
--offline-secondary: #6366f1;
--offline-accent: #8b5cf6;
--offline-primary-tint: rgba(124, 58, 237, 0.08);
--offline-primary-border-tint: rgba(124, 58, 237, 0.18);
--offline-primary-shadow-soft: rgba(124, 58, 237, 0.18);
--offline-primary-shadow-strong: rgba(124, 58, 237, 0.26);
--offline-primary-shadow-hover: rgba(124, 58, 237, 0.34);
--offline-warning-tint: rgba(245, 158, 11, 0.16);
--offline-warning: #f59e0b;
--offline-success-tint: rgba(34, 197, 94, 0.12);
--offline-success: #22c55e;
}

html[data-style='nitro'] {
--offline-theme-color: #0096e6;
--offline-background: #020617;
--offline-surface: #0f172a;
--offline-border: #1e293b;
--offline-text: #ffffff;
--offline-text-muted: #cbd5e1;
--offline-primary: #0096e6;
--offline-primary-foreground: #020617;
--offline-secondary: #818cf8;
--offline-accent: #00ff88;
--offline-primary-tint: rgba(0, 150, 230, 0.08);
--offline-primary-border-tint: rgba(0, 150, 230, 0.18);
--offline-primary-shadow-soft: rgba(0, 150, 230, 0.18);
--offline-primary-shadow-strong: rgba(0, 150, 230, 0.26);
--offline-primary-shadow-hover: rgba(0, 150, 230, 0.34);
--offline-warning-tint: rgba(251, 191, 36, 0.16);
--offline-warning: #fbbf24;
--offline-success-tint: rgba(52, 211, 153, 0.12);
--offline-success: #34d399;
}

html[data-style='kawaii'] {
--offline-theme-color: #be185d;
--offline-background: #fffaf4;
--offline-surface: #ffffff;
--offline-border: #ffe4e1;
--offline-text: #5c2d3c;
--offline-text-muted: #66414c;
--offline-primary: #be185d;
--offline-primary-foreground: #ffffff;
--offline-secondary: #be185d;
--offline-accent: #ffb6c1;
--offline-primary-tint: rgba(190, 24, 93, 0.08);
--offline-primary-border-tint: rgba(190, 24, 93, 0.18);
--offline-primary-shadow-soft: rgba(190, 24, 93, 0.18);
--offline-primary-shadow-strong: rgba(190, 24, 93, 0.26);
--offline-primary-shadow-hover: rgba(190, 24, 93, 0.34);
--offline-warning-tint: rgba(253, 224, 71, 0.16);
--offline-warning: #fde047;
--offline-success-tint: rgba(134, 239, 172, 0.12);
--offline-success: #86efac;
}

html[data-style='classic'] {
--offline-theme-color: #8b4513;
--offline-background: #fffafb;
--offline-surface: #ffffff;
--offline-border: #f5e6dc;
--offline-text: #431407;
--offline-text-muted: #78503c;
--offline-primary: #8b4513;
--offline-primary-foreground: #ffffff;
--offline-secondary: #a16207;
--offline-accent: #b47850;
--offline-primary-tint: rgba(139, 69, 19, 0.08);
--offline-primary-border-tint: rgba(139, 69, 19, 0.18);
--offline-primary-shadow-soft: rgba(139, 69, 19, 0.18);
--offline-primary-shadow-strong: rgba(139, 69, 19, 0.26);
--offline-primary-shadow-hover: rgba(139, 69, 19, 0.34);
--offline-warning-tint: rgba(180, 83, 9, 0.16);
--offline-warning: #b45309;
--offline-success-tint: rgba(22, 163, 74, 0.12);
--offline-success: #16a34a;
}

html[data-style='ocean'] {
--offline-theme-color: #06b6d4;
--offline-background: #f0f9ff;
--offline-surface: #ffffff;
--offline-border: #bae6fd;
--offline-text: #075985;
--offline-text-muted: #164e63;
--offline-primary: #06b6d4;
--offline-primary-foreground: #020617;
--offline-secondary: #0ea5e9;
--offline-accent: #14b8a6;
--offline-primary-tint: rgba(6, 182, 212, 0.08);
--offline-primary-border-tint: rgba(6, 182, 212, 0.18);
--offline-primary-shadow-soft: rgba(6, 182, 212, 0.18);
--offline-primary-shadow-strong: rgba(6, 182, 212, 0.26);
--offline-primary-shadow-hover: rgba(6, 182, 212, 0.34);
--offline-warning-tint: rgba(245, 158, 11, 0.16);
--offline-warning: #f59e0b;
--offline-success-tint: rgba(20, 184, 166, 0.12);
--offline-success: #14b8a6;
}

html[data-style='forest'] {
--offline-theme-color: #059669;
--offline-background: #f0fdf4;
--offline-surface: #ffffff;
--offline-border: #bbf7d0;
--offline-text: #14532d;
--offline-text-muted: #166534;
--offline-primary: #059669;
--offline-primary-foreground: #020617;
--offline-secondary: #16a34a;
--offline-accent: #65a30d;
--offline-primary-tint: rgba(5, 150, 105, 0.08);
--offline-primary-border-tint: rgba(5, 150, 105, 0.18);
--offline-primary-shadow-soft: rgba(5, 150, 105, 0.18);
--offline-primary-shadow-strong: rgba(5, 150, 105, 0.26);
--offline-primary-shadow-hover: rgba(5, 150, 105, 0.34);
--offline-warning-tint: rgba(234, 179, 8, 0.16);
--offline-warning: #eab308;
--offline-success-tint: rgba(34, 197, 94, 0.12);
--offline-success: #22c55e;
}
html[data-style='nitro'] {
color-scheme: dark;
}

* {
margin: 0;
padding: 0;
Expand All @@ -19,51 +190,58 @@
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8ecf4 0%, #f3e8ff 50%, #ede9fe 100%);
padding: 1rem;
background: linear-gradient(
135deg,
var(--offline-background) 0%,
var(--offline-primary-tint) 50%,
var(--offline-surface) 100%
);
padding: calc(1rem + env(safe-area-inset-top, 0px)) 1rem
calc(1rem + env(safe-area-inset-bottom, 0px));
text-align: center;
}

.container {
max-width: 400px;
padding: 2rem;
background: rgba(255, 255, 255, 0.9);
background: var(--offline-surface);
border-radius: 24px;
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.1);
box-shadow: 0 8px 32px var(--offline-primary-shadow-soft);
border: 1px solid var(--offline-primary-border-tint);
}

.icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
background: linear-gradient(135deg, var(--offline-primary) 0%, var(--offline-accent) 100%);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.3);
box-shadow: 0 4px 16px var(--offline-primary-shadow-strong);
}

.icon svg {
width: 48px;
height: 48px;
color: white;
color: var(--offline-primary-foreground);
}

h1 {
font-size: 1.5rem;
color: #4c1d95;
color: var(--offline-text);
margin-bottom: 0.75rem;
font-weight: 600;
}

p {
color: #6b7280;
color: var(--offline-text-muted);
line-height: 1.6;
margin-bottom: 1.5rem;
}
Expand All @@ -73,8 +251,8 @@
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #fef3c7;
color: #92400e;
background: var(--offline-warning-tint);
color: var(--offline-warning);
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
Expand All @@ -85,7 +263,7 @@
content: '';
width: 8px;
height: 8px;
background: #f59e0b;
background: var(--offline-warning);
border-radius: 50%;
animation: pulse 2s infinite;
}
Expand All @@ -105,8 +283,12 @@
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
background: linear-gradient(
135deg,
var(--offline-primary) 0%,
var(--offline-secondary) 100%
);
color: var(--offline-primary-foreground);
border: none;
border-radius: 12px;
font-size: 1rem;
Expand All @@ -115,12 +297,12 @@
transition:
transform 0.2s,
box-shadow 0.2s;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
box-shadow: 0 4px 12px var(--offline-primary-shadow-strong);
}

.retry-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
box-shadow: 0 6px 16px var(--offline-primary-shadow-hover);
}

.retry-btn:active {
Expand All @@ -130,12 +312,12 @@
.tips {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
border-top: 1px solid var(--offline-border);
}

.tips h2 {
font-size: 0.875rem;
color: #6b7280;
color: var(--offline-text-muted);
font-weight: 500;
margin-bottom: 0.75rem;
}
Expand All @@ -150,23 +332,23 @@
align-items: flex-start;
gap: 0.5rem;
font-size: 0.875rem;
color: #4b5563;
color: var(--offline-text);
margin-bottom: 0.5rem;
}

.tips li::before {
content: '•';
color: #8b5cf6;
color: var(--offline-primary);
font-weight: bold;
}

.cached-data {
margin-top: 1rem;
padding: 0.75rem;
background: #f0fdf4;
background: var(--offline-success-tint);
border-radius: 8px;
font-size: 0.875rem;
color: #166534;
color: var(--offline-success);
}
</style>
</head>
Expand Down
2 changes: 1 addition & 1 deletion apps/ratewise/public/open-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ print(data['details']['JPY']['cash']['buy'])

### 2. jsDelivr CDN 和 GitHub Raw 端點有何差異?

jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限,支援 ETag 條件式請求(瀏覽器可讀取 ETag,實作 If-None-Match 省流量)。GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求,CORS 不暴露 ETag,瀏覽器端無法使用條件式請求
jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求。瀏覽器端建議以 HTTP cache 搭配 client 端 5 分鐘快取控制重複請求

### 3. 有備援端點嗎?

Expand Down
Loading
Loading