diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c33b3fe..55ec580 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,7 +7,11 @@ import { AppShell } from "./components/layout/AppShell"; import { DataProvider } from "./data/DataContext"; import { I18nProvider } from "./i18n/I18nContext"; import { ThemeProvider } from "./theme/ThemeContext"; +import { DataDeletionPage } from "./pages/DataDeletionPage"; +import { HomePage } from "./pages/HomePage"; import { LoginPage } from "./pages/LoginPage"; +import { PrivacyPage } from "./pages/PrivacyPage"; +import { TermsPage } from "./pages/TermsPage"; const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage }))); const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage }))); const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage }))); @@ -48,6 +52,10 @@ export default function App() { }> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -93,8 +101,7 @@ export default function App() { } /> } /> - } /> - } /> + } /> diff --git a/apps/web/src/components/layout/PublicLegalChrome.tsx b/apps/web/src/components/layout/PublicLegalChrome.tsx new file mode 100644 index 0000000..19ccee3 --- /dev/null +++ b/apps/web/src/components/layout/PublicLegalChrome.tsx @@ -0,0 +1,66 @@ +import { Link } from "react-router-dom"; +import { BrandMark, Button } from "../ui"; +import { useI18n } from "../../i18n/I18nContext"; +import { PublicLocaleSwitch } from "./PublicLocaleSwitch"; + +type Props = { + /** data-testid on outer shell */ + testId: string; + /** Extra class on outer shell (e.g. hb-public--legal) */ + className?: string; + /** Nav aria label key */ + navLabelKey: string; + children: React.ReactNode; +}; + +/** + * Shared chrome for public legal / marketing pages (header + footer + locale). + */ +export function PublicLegalChrome({ testId, className = "", navLabelKey, children }: Props) { + const { t } = useI18n(); + + return ( +
+
+ + +
+ {t("app.name")} + {t("app.nameEn")} +
+ + +
+ + {children} + + +
+ ); +} diff --git a/apps/web/src/components/layout/PublicLocaleSwitch.tsx b/apps/web/src/components/layout/PublicLocaleSwitch.tsx new file mode 100644 index 0000000..f29aa29 --- /dev/null +++ b/apps/web/src/components/layout/PublicLocaleSwitch.tsx @@ -0,0 +1,30 @@ +import { useI18n } from "../../i18n/I18nContext"; +import { LOCALES, type AppLocale } from "../../lib/i18n/types"; + +/** + * Compact locale control for public (unauthenticated) pages. + * Persists via I18nProvider → uiPrefs, same as in-app settings. + */ +export function PublicLocaleSwitch() { + const { locale, setLocale, t } = useI18n(); + + return ( +
+ {LOCALES.map((l) => { + const active = locale === l.id; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/layout/PublicProductPreview.tsx b/apps/web/src/components/layout/PublicProductPreview.tsx new file mode 100644 index 0000000..20106bb --- /dev/null +++ b/apps/web/src/components/layout/PublicProductPreview.tsx @@ -0,0 +1,129 @@ +import { useI18n } from "../../i18n/I18nContext"; + +type ScreenDef = { + id: string; + titleKey: string; + captionKey: string; + variant: "scout" | "studio" | "outbox"; +}; + +const SCREENS: ScreenDef[] = [ + { + id: "scout", + titleKey: "home.preview.scout.title", + captionKey: "home.preview.scout.caption", + variant: "scout", + }, + { + id: "studio", + titleKey: "home.preview.studio.title", + captionKey: "home.preview.studio.caption", + variant: "studio", + }, + { + id: "outbox", + titleKey: "home.preview.outbox.title", + captionKey: "home.preview.outbox.caption", + variant: "outbox", + }, +]; + +/** + * Illustrative product UI frames for the public homepage (not live screenshots). + * Keeps Harbor Desk visual language without shipping real account data. + */ +export function PublicProductPreview() { + const { t } = useI18n(); + + return ( +
+ {SCREENS.map((screen) => ( +
+
+ {t(screen.titleKey)} + {t(screen.captionKey)} +
+
+
+ + + +
+
+
+ {screen.variant === "scout" ? : null} + {screen.variant === "studio" ? : null} + {screen.variant === "outbox" ? : null} +
+
+
+ ))} +
+ ); +} + +function MockScout() { + return ( + <> +
+ + +
+
+ + + +
+ + +
+
+
+ + +
+ + ); +} + +function MockStudio() { + return ( + <> +
+ + + +
+
+ + + + +
+
+ +
+ + ); +} + +function MockOutbox() { + return ( + <> + {[0, 1, 2].map((i) => ( +
+ +
+ + +
+ +
+ ))} + + ); +} diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index 97dd859..301561a 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -1044,6 +1044,176 @@ export const zhTW: MessageDict = { "login.brandTitle": "巡樓 · Lapras", + "home.navLabel": "公開導覽", + "public.localeLabel": "介面語言", + "home.nav.features": "功能", + "home.nav.pricing": "方案", + "home.heroTitle": "Threads 內容經營,從巡樓開始", + "home.heroLead": + "巡樓(Lapras)是給創作者、品牌與代操團隊的 Threads 海巡好幫手:找話題、寫回覆、管帳號與發送節奏,把日常巡邏與創作收斂在同一張工作台。", + "home.hero.point1": "海巡 → 回覆 → 排程發送,同一操作台完成", + "home.hero.point2": "人設與品牌語境,讓 AI 建議更貼你的聲音", + "home.hero.point3": "Free 就能試完整流程,用得出價值再升級", + "home.ctaSeePricing": "看方案與價格", + "home.whoTitle": "適合誰", + "home.whoBody": + "一個人經營多個帳號、品牌社群、或需要穩定產出與外展的團隊。若你每天在 Threads 找痛點、回訊息、排程貼文,巡樓就是為你設計的操作台。", + "home.whatTitle": "核心功能", + "home.whatLead": "從找機會、寫內容到送出與控用量,日常 Threads 經營需要的能力都在這裡。", + "home.feature.scout.title": "海巡外展", + "home.feature.scout.body": "依意圖與關鍵字掃描貼文,整理可回覆的機會,把巡邏變成可追蹤的批次工作。", + "home.feature.studio.title": "創作工作台", + "home.feature.studio.body": "從靈感到腳本與草稿,把產出收成可編輯的內容,再送進發送流程。", + "home.feature.crew.title": "帳號與人設", + "home.feature.crew.body": "綁定 Threads 帳號、維護人設與品牌語境,讓 AI 輔助與回覆更貼你的聲音。", + "home.feature.outbox.title": "Outbox 發送", + "home.feature.outbox.body": "排程、狀態與重試集中管理,送出前可再確認,避免漏發或重複發。", + "home.feature.inspire.title": "靈感與研究", + "home.feature.inspire.body": "用 AI 與搜尋整理話題、角度與素材,縮短從空白到可發的時間。", + "home.feature.usage.title": "用量與方案", + "home.feature.usage.body": "平台點數與分項上限一目了然;也可自備 API Key(BYOK)不占平台點。", + "home.previewTitle": "操作畫面一覽", + "home.previewLead": "以下為產品介面示意,呈現日常海巡、創作與發送的工作節奏(非即時帳號資料)。", + "home.preview.scout.title": "海巡工作台", + "home.preview.scout.caption": "鎖定意圖與關鍵字,逐則處理可回覆機會", + "home.preview.studio.title": "創作與腳本", + "home.preview.studio.caption": "草稿、人設語境與 AI 輔助寫在同一區", + "home.preview.outbox.title": "發送佇列", + "home.preview.outbox.caption": "排程狀態清楚,準備好再送出", + "home.free.badge": "Free", + "home.freeTitle": "Free 帳號可以做什麼?", + "home.freeLead": + "Free 不是空殼 demo:完整功能都能用,含帳號綁定、創作、海巡、發送、任務與靈感。差在每月平台點數較少,夠你真實試跑 2~3 週輕度節奏。", + "home.free.includesTitle": "包含什麼", + "home.free.quotaTitle": "每月額度", + "home.free.note": + "點數自然月重置、未用完不累積。用完可升級 Starter/Pro,或在設定填入自己的 API Key(BYOK)繼續用不占平台點。", + "home.free.cta": "免費開始", + "home.pricingTitle": "方案與價格", + "home.pricingLead": + "三個方案功能相同,差在每月平台點數與容量。從 Free 上手,節奏穩了再升級。", + "home.pricing.ctaFree": "免費開始", + "home.pricing.ctaPaid": "登入後升級", + "home.pricingFootnote": + "價格以台幣計價顯示;實際結帳與訂閱管理於登入後的用量/方案頁完成。平台忙碌或額度用盡時可改 BYOK。", + "home.ctaTitle": "準備好上船了嗎?", + "home.ctaBody": "用 Free 帳號登入即可進入今日工作台,開始海巡與創作。", + "home.ctaFreeDetail": "Free 能做什麼?", + "home.ctaEnter": "進入巡樓", + "home.ctaLogin": "登入", + "home.privacyLink": "隱私權政策", + "home.termsLink": "服務條款", + "home.dataDeletionLink": "資料刪除說明", + "legal.footerNav": "法律與政策連結", + + "privacy.navLabel": "隱私權政策導覽", + "privacy.title": "隱私權政策", + "privacy.updated": "最後更新:2026-07-31", + "privacy.intro": + "本隱私權政策(Privacy Policy)說明巡樓(Lapras,以下稱「本服務」或「我們」)如何蒐集、使用、儲存、分享與刪除你的個人資料與 Meta/Threads 平台資料。本政策適用於巡樓網頁主控台、公開工具頁,以及你透過 OAuth 連結之 Threads 帳號相關處理。使用本服務即表示你知悉本政策。", + "privacy.metaNote": + "本頁為公開網址、無需登入,可供 Meta App Dashboard「Privacy Policy URL」填寫。資料刪除步驟另見「資料刪除說明」頁(User Data Deletion Instructions URL)。本文件為產品合規說明草稿,重大對外版本建議經法律顧問審閱。", + "privacy.section.overview.title": "1. 控管者與適用範圍", + "privacy.section.overview.body": + "本服務由巡樓(Lapras)營運方作為個人資料控管者。\n本政策涵蓋:會員帳號、工作區內容、用量與付款識別、以及你授權本服務自 Meta/Threads API 取得之平台資料。\n若你透過邀請加入他人工作區,工作區擁有者可能另有內部規範,但不取代本政策對平台層與會員層資料之說明。\n本政策為我們自有政策,非 Meta、Threads 或 Instagram 之政策;Meta 如何處理其產品內資料,請另見 Meta 隱私中心。", + "privacy.section.collect.title": "2. 我們蒐集哪些資料", + "privacy.section.collect.body": + "(A)你直接提供的資料\n• 帳號:Email、顯示名稱、密碼雜湊(不保存明文密碼)、角色與信箱驗證狀態。\n• 你輸入的內容:人設、品牌、海巡意圖/關鍵字、創作草稿、回覆文案、Outbox 內容、工作區設定與上傳之媒體(若有)。\n• 支援與個資請求:你寄給我們的 Email 與請求內容。\n\n(B)自動蒐集\n• 登入 session、IP、基本瀏覽器/裝置資訊(安全、除錯、防濫用)。\n• 介面偏好(語言、主題)。\n• 功能用量與任務日誌(方案額度、錯誤診斷)。\n\n(C)來自 Meta/Threads 的平台資料(僅在你授權連結後)\n• Threads 使用者識別(例如 user id、username)、公開或授權範圍內的個人檔摘要。\n• 你授權範圍內的貼文、回覆、媒體中繼資料、以及為完成發送/讀取/成效所需之 API 回應。\n• 存取權杖(access token/refresh 相關憑證):僅用於代你呼叫 Threads API,與會員登入 JWT 分開存放。\n\n(D)付款(若啟用)\n• 方案等級、點數用量、金流交易識別(細節依金流供應商;我們不儲存完整卡號)。\n\n我們不會以一般使用為條件要求無關的政府身分證件。", + "privacy.section.use.title": "3. 處理目的與方式", + "privacy.section.use.body": + "我們處理上述資料的目的包括:\n• 提供帳號、工作區、權限與登入安全。\n• 執行核心功能:海巡掃描/外展、創作與 AI 輔助、Outbox 排程與發送、帳號與貼文同步、成效與用量計量。\n• 以你授權的 Threads token 代表你呼叫 Meta/Threads API(讀取授權內容、發布你確認之內容等,以實際 scope 為準)。\n• 寄送驗證碼、密碼重設與服務通知。\n• 防止濫用、保障服務穩定、除錯與產品改善(儘量去識別化)。\n• 遵守法律義務或回應合法請求。\n\n我們不會將 Meta/Threads 平台資料出售給資料掮客,也不會將該等資料用於與提供本服務無關的獨立行銷檔案建置。", + "privacy.section.threads.title": "4. Threads/Meta 平台資料(App Review 重點)", + "privacy.section.threads.body": + "連結方式:你透過 Meta OAuth 授權本服務存取 Threads。我們僅請求並使用完成產品功能所需之權限(permissions/scopes),例如讀取基本檔案、內容發布、或文件所列其他必要權限;實際清單以 Meta App 設定與授權畫面為準。\n\n用途限制:自 Meta Platform 取得的資料,僅用於提供、維護與改善你所使用的巡樓功能(例如綁定帳號、同步貼文、排程/發送回覆或貼文、顯示狀態與除錯),不會將 Platform Data 轉售,亦不會用於與你授權無關的廣告定向。\n\n儲存與隔離:Threads token 與會員 JWT 分離管理;工作區與帳號層級有權限控管。\n\n你可隨時:\n• 在本服務解除 Threads 綁定;及/或\n• 在 Meta/Threads/Instagram 設定中撤銷本 App 授權。\n解除後我們停止新的 API 存取,並依第 7 節處理刪除或匿名化。\n\nMeta/Threads 自身如何處理資料,受 Meta 隱私政策與 Threads 補充條款拘束;本服務無法控制 Meta 端行為。", + "privacy.section.share.title": "5. 資料分享與處理者", + "privacy.section.share.body": + "我們可能在下列情況分享或由處理者代為處理資料:\n• 基礎設施:雲端主機、資料庫、物件儲存、電子郵件發送、監控與日誌服務(僅為營運本服務)。\n• AI/搜尋供應商:當你使用靈感、分析、建議文案等功能時,必要提示與內容片段可能傳送至供應商(見第 6 節)。\n• 金流供應商:處理付款時。\n• 法律要求:法院、主管機關之合法要求。\n• 業務承繼:合併、收購等情形下,依適用法律與通知處理。\n\n我們要求處理者僅依指示處理,並採取合理安全措施。我們不會出售個人資料。", + "privacy.section.ai.title": "6. AI 與自動化處理", + "privacy.section.ai.body": + "部分功能會將你提供的提示、貼文樣本、結構化備註或授權同步之公開內容摘要送至 AI/搜尋供應商,以產生建議文案、風格分析或檢索結果。\n你可使用平台預設供應商,或自行設定 BYOK(Bring Your Own Key);BYOK 時請求導向你指定的供應商,請同時閱讀其條款。\n我們不會在未經你另行同意下,把你的內容當作對外行銷素材或公開訓練語料宣傳。", + "privacy.section.retention.title": "7. 保存期間", + "privacy.section.retention.body": + "• 帳號與工作區資料:於帳號有效期間保存,以維持服務。\n• Threads token:於綁定有效期間保存;解除綁定或刪除請求後失效/移除。\n• 任務日誌、用量與安全紀錄:依營運、爭議處理與法令需要保存合理期間後刪除或匿名化。\n• 備份:可能在輪替週期內短暫殘留,期滿清除。\n詳細刪除步驟見第 8 節與「資料刪除說明」頁。", + "privacy.section.deletion.title": "8. 如何請求刪除資料(Meta 必要揭露)", + "privacy.section.deletion.body": + "你可依下列方式請求刪除我們所持有、與你相關的個人資料與平台資料:\n\n方式一(建議):登入巡樓 → 會員/設定相關頁面解除 Threads 綁定,並依介面指示提出帳號或資料刪除(若已提供自助刪除)。\n\n方式二:寄信至本服務營運方,使用你註冊之 Email 寄出,主旨註明「Data Deletion Request/資料刪除請求」,並提供:\n• 註冊 Email\n• 若知悉:Threads username 或本服務內顯示之帳號識別\n• 欲刪除範圍(全部帳號/僅 Threads 連線資料/特定工作區)\n\n方式三:在 Meta 移除本 App 並請求刪除時,依 Meta 流程處理;若我們已設定 Data Deletion Callback,將依回呼處理;否則請仍依方式二聯絡我們。\n\n完整步驟與時程說明:https://(你的公開網域)/data-deletion(站內路徑 /data-deletion)。\n我們於核對身分後,將在合理期間(通常 30 日內,法令另有規定從其規定)刪除或匿名化,法令要求保留者除外。", + "privacy.section.rights.title": "9. 你的權利", + "privacy.section.rights.body": + "在適用法律允許範圍內,你可要求:查詢、更正、下載(可攜)、刪除、限制或反對特定處理。你可隨時登出、修改個人資料、解除 Threads 綁定或停止使用。行使權利請依第 12 節聯絡我們;我們可能需驗證身分。", + "privacy.section.security.title": "10. 安全措施", + "privacy.section.security.body": + "我們採取合理技術與組織措施,包括 HTTPS 傳輸、密碼雜湊、權限隔離、會員憑證與 Threads token 分離存放、以及存取控管。無系統可保證絕對安全;若發生可能影響你權益之資安事件,我們將依法或依政策通知。", + "privacy.section.children.title": "11. 兒童與未成年人", + "privacy.section.children.body": + "本服務面向內容經營與商業/創作者使用,不以兒童為對象。我們不故意蒐集未滿適用年齡(例如 13 歲,或你所在地更高門檻)者之個人資料。若你認為兒童資料被誤收,請依第 12 節聯絡,我們將儘速刪除。", + "privacy.section.contact.title": "12. 聯絡我們", + "privacy.section.contact.body": + "隱私權、個資查詢、更正或刪除請求,請使用你註冊之 Email 與本服務營運方聯繫,主旨註明「Privacy/Data Request」,並說明帳號 Email 與請求內容,以便核對身分。\n你亦可先登入後於會員/設定頁依指示操作。\n本隱私權政策公開網址路徑:/privacy。", + "privacy.section.updates.title": "13. 政策更新", + "privacy.section.updates.body": + "我們可能因功能、Meta 平台規則或法令更新本政策。更新後會修改「最後更新」日期;重大變更時可能以站內通知或 Email 告知。在法律允許範圍內,更新後繼續使用表示你知悉修訂內容。", + "privacy.backHome": "回到介紹首頁", + "privacy.footerNote": "公開頁面,無需登入;可供 Meta 審核爬蟲存取。", + + "terms.navLabel": "服務條款導覽", + "terms.title": "服務條款", + "terms.updated": "最後更新:2026-07-31", + "terms.intro": + "歡迎使用巡樓(Lapras)。本服務條款(Terms of Service)規範你與本服務營運方之間就使用網頁主控台、公開工具與相關功能之約定。使用本服務即表示你同意本條款;若不同意,請勿使用。", + "terms.section.acceptance.title": "1. 接受條款", + "terms.section.acceptance.body": + "你必須具備締結合約之法定能力,並遵守所在地法律。若你代表公司或組織使用,你保證有權使該組織受本條款拘束。", + "terms.section.service.title": "2. 服務說明", + "terms.section.service.body": + "巡樓提供 Threads 內容經營相關工具,包括但不限於帳號綁定、海巡與外展、創作與發送、人設/品牌、用量與方案等。功能可能隨版本調整;我們得基於維護、安全或法規暫停或變更部分功能。", + "terms.section.accounts.title": "3. 帳號與安全", + "terms.section.accounts.body": + "你應提供正確資料、妥善保管登入憑證,並對帳號下之行為負責。發現未授權使用應立即通知我們。我們得因安全、濫用或違規而限制或停權帳號。", + "terms.section.threads.title": "4. Threads/Meta 連線", + "terms.section.threads.body": + "部分功能需你透過 Meta OAuth 授權連結 Threads。你保證有權連結該帳號,並遵守 Meta、Threads、Instagram 之平台條款與社群規範。\n你應自行確保發送內容合法、不侵權、不違反平台政策。因你的內容或帳號行為導致平台處罰、API 拒絕或第三方主張,由你自行負責;本服務僅提供工具,不保證平台審核結果或觸及成效。\n你可以隨時解除綁定或撤銷 Meta 授權;解除後部分功能將無法使用。", + "terms.section.content.title": "5. 你的內容", + "terms.section.content.body": + "你保留對自己上傳或產生內容之權利。你授予我們為提供服務所需之非專屬、全球、免權利金授權(儲存、處理、傳輸、顯示給你與你授權之工作區成員、以及呼叫必要之第三方 API)。你保證內容不違法、不侵權。", + "terms.section.acceptable.title": "6. 禁止行為", + "terms.section.acceptable.body": + "禁止:濫用 API 或自動化干擾他人;散布垃圾、詐騙、仇恨或違法內容;規避用量或安全機制;逆向工程或未經授權存取系統;將服務用於違反 Meta 平台政策之行為;侵害他人隱私或智慧財產。", + "terms.section.billing.title": "7. 方案與用量", + "terms.section.billing.body": + "付費方案、點數與限制以產品內標示為準。未付款、超額或濫用可能導致功能降級或暫停。退款政策依購買時說明與適用法律。", + "terms.section.disclaimer.title": "8. 免責與責任限制", + "terms.section.disclaimer.body": + "服務依「現況」提供。在法律允許最大範圍內,我們不保證無中斷、無錯誤,或第三方平台(含 Threads/Meta/AI 供應商)持續可用。對間接、附隨、逸失利益等損害,除法律強制規定外不負責任;我們對你的總責任以你於事故前十二個月內就本服務已付費用為上限(若為免費使用則為零),法律禁止限制者除外。", + "terms.section.termination.title": "9. 終止", + "terms.section.termination.body": + "你可隨時停止使用並依隱私權政策請求刪除資料。我們得因違約、濫用、法律要求或停止營運而終止或暫停服務。終止後依隱私權政策處理資料。", + "terms.section.contact.title": "10. 聯絡與準據", + "terms.section.contact.body": + "條款相關問題請透過註冊 Email 聯絡營運方。準據法與管轄,除強制規定外,以本服務主要營運地之法律為準。隱私處理見 /privacy;資料刪除見 /data-deletion。", + + "deletion.navLabel": "資料刪除說明導覽", + "deletion.title": "使用者資料刪除說明", + "deletion.updated": "最後更新:2026-07-31", + "deletion.intro": + "本頁提供 Meta App Dashboard「User Data Deletion Instructions URL」所需之公開說明:如何請求巡樓(Lapras)刪除與你相關的個人資料與 Threads 平台資料。無需登入即可閱讀。", + "deletion.section.summary.title": "1. 摘要", + "deletion.section.summary.body": + "當你不再使用本服務,或自 Meta 移除本 App 並希望刪除我們所持資料時,請依下列步驟提出請求。我們會在核對身分後刪除或匿名化可刪除之資料(法令或正當利益需保留者除外)。", + "deletion.section.steps.title": "2. 如何提出刪除請求", + "deletion.section.steps.body": + "步驟 1:若仍可登入,先至巡樓解除所有 Threads 帳號綁定(設定/帳號相關頁)。\n步驟 2:使用你註冊本服務時的 Email 寄信給營運方。\n步驟 3:主旨請寫:Data Deletion Request 或「資料刪除請求」。\n步驟 4:信中請提供:\n• 註冊 Email(必填)\n• 顯示名稱或會員識別(若知悉)\n• Threads username 或本服務內帳號 id(若曾連結)\n• 刪除範圍:整個帳號/僅 Threads 連線與同步資料/特定工作區\n步驟 5:我們回覆確認後開始處理;完成後以 Email 告知結果(若該信箱仍可用)。", + "deletion.section.threads.title": "3. 與 Threads/Meta 授權的關係", + "deletion.section.threads.body": + "刪除本服務資料不會自動刪除你在 Threads/Instagram/Meta 上的貼文或帳號。\n請同時在 Meta/Threads 設定中撤銷對本 App 的授權,以停止 Meta 端的授權狀態。\n若 Meta 向我們發送 Data Deletion Callback(若已設定),我們會依 signed request 處理對應 app-scoped 使用者資料;未設定 callback 時,請一律依本頁 Email 流程。", + "deletion.section.scope.title": "4. 會刪除什麼", + "deletion.section.scope.body": + "在合理可行範圍內,我們將刪除或匿名化:\n• 會員個人資料與登入憑證\n• 工作區內由你建立之內容(人設、草稿、海巡與 Outbox 等,依請求範圍)\n• Threads 連線識別、token 與同步快取\n• 非必要之任務與分析紀錄\n\n可能暫時或依法保留:\n• 法令要求之交易/帳務紀錄\n• 安全與防濫用日誌(有限期間)\n• 備份輪替中尚未到期之副本", + "deletion.section.timeline.title": "5. 處理時程", + "deletion.section.timeline.body": + "我們通常在收到可核對之請求後 30 日內完成刪除或匿名化。若需更長時間(例如複雜工作區或法遵審查),我們會告知預估時程。備份清除可能隨後完成。", + "deletion.section.contact.title": "6. 聯絡", + "deletion.section.contact.body": + "請使用註冊 Email 聯絡本服務營運方,主旨「Data Deletion Request」。完整隱私說明見 /privacy;服務條款見 /terms。", "common.listSep": "、", "common.dash": "—", @@ -2772,6 +2942,176 @@ export const en: MessageDict = { "login.brandTitle": "巡樓 · Lapras", + "home.navLabel": "Public navigation", + "public.localeLabel": "Language", + "home.nav.features": "Features", + "home.nav.pricing": "Pricing", + "home.heroTitle": "Run Threads operations from Lapras", + "home.heroLead": + "Lapras (巡樓) helps creators, brands, and operators patrol Threads: find conversations, draft replies, manage accounts, and ship on a shared desk.", + "home.hero.point1": "Patrol → reply → schedule send on one desk", + "home.hero.point2": "Personas and brand voice keep AI suggestions on-brand", + "home.hero.point3": "Free unlocks the full flow — upgrade when it pays off", + "home.ctaSeePricing": "See pricing", + "home.whoTitle": "Who it is for", + "home.whoBody": + "Solo creators juggling accounts, brand social teams, and anyone who needs steady outreach and publishing on Threads. If your day is scanning, replying, and scheduling, this console is for you.", + "home.whatTitle": "Core features", + "home.whatLead": "From finding opportunities to writing, sending, and metering usage — everything for daily Threads ops.", + "home.feature.scout.title": "Patrol outreach", + "home.feature.scout.body": "Scan posts by intent and keywords, collect reply opportunities, and turn patrols into trackable batches.", + "home.feature.studio.title": "Studio", + "home.feature.studio.body": "Move from inspiration to scripts and drafts, then hand off into the send flow.", + "home.feature.crew.title": "Accounts and personas", + "home.feature.crew.body": "Connect Threads accounts and maintain persona and brand voice so AI help stays on-brand.", + "home.feature.outbox.title": "Outbox", + "home.feature.outbox.body": "Scheduling, status, and retries in one place — review before anything goes out.", + "home.feature.inspire.title": "Inspire and research", + "home.feature.inspire.body": "Use AI and search to gather angles and material so blank pages turn into publishable copy faster.", + "home.feature.usage.title": "Usage and plans", + "home.feature.usage.body": "See platform credits and per-meter caps clearly; optional BYOK keys do not consume platform credits.", + "home.previewTitle": "Product walkthrough", + "home.previewLead": "Illustrative UI frames for patrol, create, and send — not live account data.", + "home.preview.scout.title": "Patrol desk", + "home.preview.scout.caption": "Intent and keywords, then work reply opportunities one by one", + "home.preview.studio.title": "Create and script", + "home.preview.studio.caption": "Drafts, persona context, and AI assist in one panel", + "home.preview.outbox.title": "Send queue", + "home.preview.outbox.caption": "Clear schedule status before publish", + "home.free.badge": "Free", + "home.freeTitle": "What can a Free account do?", + "home.freeLead": + "Free is not an empty demo: full features including account linking, create, patrol, send, jobs, and inspire. The difference is fewer monthly platform credits — enough for about 2–3 weeks of light daily use so you can feel real value.", + "home.free.includesTitle": "Included", + "home.free.quotaTitle": "Monthly quota", + "home.free.note": + "Credits reset each calendar month and do not roll over. When you hit the cap, upgrade to Starter/Pro or add your own API keys (BYOK) to keep going without platform credits.", + "home.free.cta": "Start free", + "home.pricingTitle": "Plans and pricing", + "home.pricingLead": + "All three plans share the same features; they differ in monthly platform credits and capacity. Start on Free, upgrade when your rhythm is solid.", + "home.pricing.ctaFree": "Start free", + "home.pricing.ctaPaid": "Sign in to upgrade", + "home.pricingFootnote": + "Prices shown in TWD; checkout and subscription management happen after sign-in on Usage / Plans. Use BYOK when the platform is busy or credits run out.", + "home.ctaTitle": "Ready to board?", + "home.ctaBody": "Sign in with Free to open Today and start patrolling and creating.", + "home.ctaFreeDetail": "What Free includes", + "home.ctaEnter": "Enter Lapras", + "home.ctaLogin": "Sign in", + "home.privacyLink": "Privacy policy", + "home.termsLink": "Terms of service", + "home.dataDeletionLink": "Data deletion", + "legal.footerNav": "Legal links", + + "privacy.navLabel": "Privacy policy navigation", + "privacy.title": "Privacy Policy", + "privacy.updated": "Last updated: 2026-07-31", + "privacy.intro": + "This Privacy Policy explains how Lapras (巡樓, “the Service”, “we”) collects, uses, stores, shares, and deletes your personal data and Meta/Threads platform data. It covers the Lapras web console, public tools, and processing related to Threads accounts you connect via OAuth. Using the Service means you acknowledge this policy.", + "privacy.metaNote": + "This page is public (no sign-in) and suitable for the Meta App Dashboard “Privacy Policy URL”. Step-by-step deletion instructions are also at “Data deletion” (/data-deletion) for the “User Data Deletion Instructions URL”. This is a product compliance draft; have counsel review before major production filings.", + "privacy.section.overview.title": "1. Controller and scope", + "privacy.section.overview.body": + "The Lapras operator is the controller of personal data described here.\nThis policy covers: member accounts, workspace content, usage and payment identifiers, and platform data we obtain from Meta/Threads APIs after you authorize us.\nIf you join someone else’s workspace, that owner may have extra rules; they do not replace this notice for platform- and member-level data.\nThis is our own policy, not Meta’s, Threads’, or Instagram’s. See Meta’s Privacy Center for how Meta processes data in its products.", + "privacy.section.collect.title": "2. What information we collect", + "privacy.section.collect.body": + "(A) Data you provide\n• Account: email, display name, password hash (never plaintext passwords), role, email verification status.\n• Content you enter: personas, brands, patrol intents/keywords, drafts, reply copy, Outbox items, workspace settings, and uploaded media if any.\n• Support/privacy requests: emails and request details you send us.\n\n(B) Data collected automatically\n• Login sessions, IP, basic browser/device metadata (security, debugging, abuse prevention).\n• UI preferences (language, theme).\n• Feature usage and job logs (quotas, diagnostics).\n\n(C) Meta/Threads platform data (only after you connect)\n• Threads user identifiers (e.g. user id, username) and profile summary within granted scopes.\n• Posts, replies, media metadata, and API responses needed to read/send/insights within granted permissions.\n• Access tokens (and related refresh credentials) used only to call Threads APIs on your behalf; stored separately from member JWTs.\n\n(D) Billing (if enabled)\n• Plan tier, quota usage, payment transaction identifiers (we do not store full card numbers).\n\nWe do not require unrelated government ID for ordinary use.", + "privacy.section.use.title": "3. How we use data and why", + "privacy.section.use.body": + "We process data to:\n• Provide accounts, workspaces, permissions, and sign-in security.\n• Run core features: patrol/outreach, create and AI assist, Outbox schedule/send, account and post sync, insights and metering.\n• Call Meta/Threads APIs with your tokens (read authorized content, publish content you confirm, etc., subject to granted scopes).\n• Send verification codes, password resets, and service notices.\n• Prevent abuse, keep the Service reliable, debug and improve (de-identified where practical).\n• Comply with law or valid legal requests.\n\nWe do not sell Meta/Threads platform data to data brokers, and we do not use that data to build independent marketing profiles unrelated to providing the Service.", + "privacy.section.threads.title": "4. Threads / Meta platform data (App Review focus)", + "privacy.section.threads.body": + "Connection: you authorize Lapras via Meta OAuth for Threads. We request and use only permissions needed for product features (e.g. basic profile, content publish, and other scopes shown in the Meta authorization UI). The live list is defined in the Meta App settings and consent screen.\n\nUse limits: Platform Data from Meta is used only to provide, maintain, and improve Lapras features you use (connect accounts, sync posts, schedule/send replies or posts, show status, debug). We do not sell Platform Data or use it for advertising targeting unrelated to your authorization.\n\nStorage: Threads tokens are separated from member JWTs; access is permission-controlled by workspace/account.\n\nYou may at any time:\n• Disconnect Threads inside Lapras; and/or\n• Revoke the app in Meta/Threads/Instagram settings.\nAfter disconnect we stop new API access and handle deletion/anonymization under Section 8.\n\nMeta’s own processing is governed by Meta’s policies; we do not control Meta’s systems.", + "privacy.section.share.title": "5. Sharing and processors", + "privacy.section.share.body": + "We may share data with or have it processed by:\n• Infrastructure: hosting, databases, object storage, email delivery, monitoring/logs (to run the Service).\n• AI/search providers: when you use inspire, analysis, or suggestion features (see Section 6).\n• Payment processors: when billing is enabled.\n• Legal: when required by law or valid authority.\n• Business transfers: merger/acquisition under applicable law and notice.\n\nProcessors may only process under our instructions with reasonable security. We do not sell personal data.", + "privacy.section.ai.title": "6. AI and automated processing", + "privacy.section.ai.body": + "Some features send prompts, post samples, structured notes, or summaries of authorized public content to AI/search providers for suggestions, style analysis, or retrieval.\nYou may use platform defaults or BYOK keys; with BYOK, requests go to your provider—read their terms too.\nWe do not use your content as public marketing or advertised training material without separate consent.", + "privacy.section.retention.title": "7. Retention", + "privacy.section.retention.body": + "• Account and workspace data: while the account is active.\n• Threads tokens: while connected; invalidated/removed after disconnect or deletion requests.\n• Job logs, usage, security records: for a reasonable period for operations, disputes, and law, then delete or anonymize.\n• Backups: may retain residual copies until rotation completes.\nSee Section 8 and the Data deletion page for how to request deletion.", + "privacy.section.deletion.title": "8. How to request deletion of your data (required disclosure)", + "privacy.section.deletion.body": + "You can request deletion of personal data and platform data we hold about you as follows:\n\nOption 1 (recommended): Sign in to Lapras → disconnect Threads in account/settings, then use any in-product account/data deletion flow if available.\n\nOption 2: Email the Service operator from your registered email address. Subject: “Data Deletion Request”. Include:\n• Registered email\n• Threads username or in-app account id if known\n• Scope (entire account / Threads connection data only / specific workspace)\n\nOption 3: If you remove our app in Meta and request deletion, follow Meta’s flow. If we configured a Data Deletion Callback, we process signed callbacks; otherwise still use Option 2.\n\nFull instructions: public path /data-deletion on this site.\nAfter identity verification we delete or anonymize within a reasonable period (typically 30 days, or sooner/later if law requires), except records we must retain by law.", + "privacy.section.rights.title": "9. Your rights", + "privacy.section.rights.body": + "Where applicable law allows, you may request access, correction, portability, deletion, restriction, or objection to certain processing. You may sign out, edit profile data, disconnect Threads, or stop using the Service. Contact us per Section 12; we may need to verify identity.", + "privacy.section.security.title": "10. Security", + "privacy.section.security.body": + "We use reasonable technical and organizational measures, including HTTPS, password hashing, permission isolation, and separating member credentials from Threads tokens. No system is perfectly secure; we notify of incidents as required by law or policy.", + "privacy.section.children.title": "11. Children", + "privacy.section.children.body": + "The Service is intended for creators and business operators, not children. We do not knowingly collect personal data from children under the applicable age (e.g. 13, or a higher age where required). Contact us under Section 12 if you believe we collected a child’s data; we will delete it promptly.", + "privacy.section.contact.title": "12. Contact us", + "privacy.section.contact.body": + "For privacy questions or access/correction/deletion requests, contact the Service operator using your registered email. Subject: “Privacy / Data Request”. Include your account email and details so we can verify identity.\nYou may also use in-product profile/settings after sign-in.\nPublic Privacy Policy URL path: /privacy.", + "privacy.section.updates.title": "13. Updates", + "privacy.section.updates.body": + "We may update this policy for features, Meta platform rules, or law. We change the “Last updated” date; for material changes we may also notify in-product or by email. Continued use after an update means you acknowledge the revised policy to the extent permitted by law.", + "privacy.backHome": "Back to intro", + "privacy.footerNote": "Public pages; no sign-in. Accessible to Meta review crawlers.", + + "terms.navLabel": "Terms of service navigation", + "terms.title": "Terms of Service", + "terms.updated": "Last updated: 2026-07-31", + "terms.intro": + "Welcome to Lapras (巡樓). These Terms of Service govern your use of the web console, public tools, and related features. By using the Service you agree to these Terms. If you do not agree, do not use the Service.", + "terms.section.acceptance.title": "1. Acceptance", + "terms.section.acceptance.body": + "You must have legal capacity to contract and comply with applicable law. If you use the Service for an organization, you represent you have authority to bind that organization.", + "terms.section.service.title": "2. The Service", + "terms.section.service.body": + "Lapras provides Threads operations tooling, including account linking, patrol/outreach, create and send, personas/brands, usage and plans. Features may change. We may suspend or modify features for maintenance, security, or legal reasons.", + "terms.section.accounts.title": "3. Accounts and security", + "terms.section.accounts.body": + "Provide accurate information, protect credentials, and you are responsible for activity under your account. Notify us of unauthorized use. We may limit or suspend accounts for security, abuse, or violations.", + "terms.section.threads.title": "4. Threads / Meta connection", + "terms.section.threads.body": + "Some features require Meta OAuth to connect Threads. You must have the right to connect that account and must comply with Meta, Threads, and Instagram terms and community standards.\nYou are responsible for lawful, non-infringing content and for following platform rules. Platform enforcement, API denials, or third-party claims arising from your content or account use are your responsibility. Lapras is a tool and does not guarantee platform approval or performance outcomes.\nYou may disconnect or revoke Meta authorization at any time; some features will stop working after disconnect.", + "terms.section.content.title": "5. Your content", + "terms.section.content.body": + "You retain rights in content you upload or create. You grant us a non-exclusive, worldwide, royalty-free license to store, process, transmit, and display content as needed to provide the Service to you and authorized workspace members, including calling necessary third-party APIs. You warrant content is lawful and non-infringing.", + "terms.section.acceptable.title": "6. Acceptable use", + "terms.section.acceptable.body": + "You may not: abuse APIs or automate harm to others; spam, fraud, hate, or illegal content; bypass quotas or security; reverse engineer or access systems without authorization; use the Service in ways that violate Meta platform policies; or infringe others’ privacy or IP.", + "terms.section.billing.title": "7. Plans and usage", + "terms.section.billing.body": + "Paid plans, credits, and limits are as shown in product. Non-payment, overuse, or abuse may reduce or suspend features. Refunds follow the purchase terms and applicable law.", + "terms.section.disclaimer.title": "8. Disclaimers and liability", + "terms.section.disclaimer.body": + "The Service is provided “as is”. To the fullest extent permitted by law, we do not warrant uninterrupted or error-free operation, or continued availability of third platforms (including Threads/Meta/AI providers). We are not liable for indirect, incidental, or lost-profit damages except where liability cannot be limited. Our aggregate liability is capped at fees you paid for the Service in the twelve months before the claim (or zero if free), except where law forbids the cap.", + "terms.section.termination.title": "9. Termination", + "terms.section.termination.body": + "You may stop using the Service and request deletion under the Privacy Policy. We may suspend or terminate for breach, abuse, legal requirements, or if we discontinue the Service. Data handling after termination follows the Privacy Policy.", + "terms.section.contact.title": "10. Contact and governing law", + "terms.section.contact.body": + "Contact the operator via your registered email for terms questions. Governing law and venue are those of our primary place of operation unless mandatory law says otherwise. Privacy: /privacy. Data deletion: /data-deletion.", + + "deletion.navLabel": "Data deletion navigation", + "deletion.title": "User Data Deletion Instructions", + "deletion.updated": "Last updated: 2026-07-31", + "deletion.intro": + "This page is the public “User Data Deletion Instructions” for Meta App Dashboard. It explains how to ask Lapras (巡樓) to delete personal data and Threads platform data associated with you. No sign-in required to read.", + "deletion.section.summary.title": "1. Summary", + "deletion.section.summary.body": + "If you stop using the Service, or remove our app in Meta and want us to delete data we hold, follow the steps below. After identity verification we delete or anonymize deletable data, except where law or legitimate retention requires keeping records.", + "deletion.section.steps.title": "2. How to request deletion", + "deletion.section.steps.body": + "Step 1: If you can still sign in, disconnect all Threads accounts in Lapras (settings/accounts).\nStep 2: Email the Service operator from your registered email address.\nStep 3: Subject line: “Data Deletion Request”.\nStep 4: Include:\n• Registered email (required)\n• Display name or member id if known\n• Threads username or in-app account id if you connected Threads\n• Scope: entire account / Threads connection + synced data only / a specific workspace\nStep 5: We confirm receipt, process the request, and email completion when possible.", + "deletion.section.threads.title": "3. Threads / Meta authorization", + "deletion.section.threads.body": + "Deleting Lapras data does not delete your posts or account on Threads/Instagram/Meta.\nAlso revoke our app in Meta/Threads settings to clear authorization on Meta’s side.\nIf Meta sends a Data Deletion Callback (when configured), we process the signed request for the app-scoped user; if no callback is configured, use the email process on this page.", + "deletion.section.scope.title": "4. What we delete", + "deletion.section.scope.body": + "Where reasonably possible we delete or anonymize:\n• Member personal data and credentials\n• Workspace content you created (personas, drafts, patrol, Outbox, etc., per request scope)\n• Threads connection identifiers, tokens, and sync caches\n• Non-essential job and analytics records\n\nWe may retain for a limited time or by law:\n• Transaction/billing records required by law\n• Security and anti-abuse logs (limited period)\n• Backup copies until rotation completes", + "deletion.section.timeline.title": "5. Timeline", + "deletion.section.timeline.body": + "We typically complete deletion or anonymization within 30 days after a verifiable request. If more time is needed (complex workspaces or legal review), we will communicate an estimate. Backup purge may finish afterward.", + "deletion.section.contact.title": "6. Contact", + "deletion.section.contact.body": + "Email the Service operator from your registered address with subject “Data Deletion Request”. Full privacy policy: /privacy. Terms: /terms.", "common.listSep": ", ", "common.dash": "—", diff --git a/apps/web/src/pages/DataDeletionPage.tsx b/apps/web/src/pages/DataDeletionPage.tsx new file mode 100644 index 0000000..238f099 --- /dev/null +++ b/apps/web/src/pages/DataDeletionPage.tsx @@ -0,0 +1,56 @@ +import { Link } from "react-router-dom"; +import { PublicLegalChrome } from "../components/layout/PublicLegalChrome"; +import { Button, Card } from "../components/ui"; +import { useI18n } from "../i18n/I18nContext"; + +const SECTIONS = [ + { id: "summary", title: "deletion.section.summary.title", body: "deletion.section.summary.body" }, + { id: "steps", title: "deletion.section.steps.title", body: "deletion.section.steps.body" }, + { id: "threads", title: "deletion.section.threads.title", body: "deletion.section.threads.body" }, + { id: "scope", title: "deletion.section.scope.title", body: "deletion.section.scope.body" }, + { id: "timeline", title: "deletion.section.timeline.title", body: "deletion.section.timeline.body" }, + { id: "contact", title: "deletion.section.contact.title", body: "deletion.section.contact.body" }, +] as const; + +/** + * Dedicated User Data Deletion Instructions page for Meta App Dashboard + * ("User Data Deletion Instructions URL"). Public, no auth. + */ +export function DataDeletionPage() { + const { t } = useI18n(); + + return ( + +
+
+

{t("deletion.title")}

+

{t("deletion.updated")}

+

{t("deletion.intro")}

+
+ +
+ {SECTIONS.map((section) => ( + +

{t(section.body)}

+
+ ))} +
+ +
+ + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..998bd52 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -0,0 +1,289 @@ +import { Link, Navigate } from "react-router-dom"; +import { useAuth } from "../auth/AuthContext"; +import { PublicLocaleSwitch } from "../components/layout/PublicLocaleSwitch"; +import { PublicProductPreview } from "../components/layout/PublicProductPreview"; +import { Badge, BrandMark, Button, Card } from "../components/ui"; +import { useI18n } from "../i18n/I18nContext"; +import { getPlanRights } from "../lib/planRights"; +import { PLANS, type PlanId } from "../lib/usageMeter"; + +const FEATURES = [ + { icon: "scout", title: "home.feature.scout.title", body: "home.feature.scout.body" }, + { icon: "studio", title: "home.feature.studio.title", body: "home.feature.studio.body" }, + { icon: "crew", title: "home.feature.crew.title", body: "home.feature.crew.body" }, + { icon: "outbox", title: "home.feature.outbox.title", body: "home.feature.outbox.body" }, + { icon: "inspire", title: "home.feature.inspire.title", body: "home.feature.inspire.body" }, + { icon: "usage", title: "home.feature.usage.title", body: "home.feature.usage.body" }, +] as const; + +const PLAN_ORDER: PlanId[] = ["free", "starter", "pro"]; + +/** + * Public intro homepage for newcomers. Unauthenticated visitors land here at `/`. + * Signed-in members are sent into the app shell. + */ +export function HomePage() { + const { member, loading } = useAuth(); + const { t, formatPlanPrice } = useI18n(); + + if (!loading && member) { + return ; + } + + const freePlan = PLANS.free; + const freeRights = getPlanRights("free", t, formatPlanPrice); + + return ( +
+
+
+ +
+ {t("app.name")} + {t("app.nameEn")} +
+
+ +
+ +
+
+
+

{t("app.tagline")}

+

+ {t("home.heroTitle")} +

+

{t("home.heroLead")}

+
+ + + + + {t("home.ctaSeePricing")} + +
+
    +
  • {t("home.hero.point1")}
  • +
  • {t("home.hero.point2")}
  • +
  • {t("home.hero.point3")}
  • +
+
+
+
+
+ + + +
+
+
+
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+
+
+
+ +
+

+ {t("home.whoTitle")} +

+

{t("home.whoBody")}

+
+ +
+

+ {t("home.whatTitle")} +

+

{t("home.whatLead")}

+
+ {FEATURES.map((f) => ( + +

{t(f.body)}

+
+ ))} +
+
+ +
+

+ {t("home.previewTitle")} +

+

{t("home.previewLead")}

+ +
+ +
+
+ {t("home.free.badge")} +

+ {t("home.freeTitle")} +

+

{t("home.freeLead")}

+
+
+ +
    + {freeRights.rights.map((line) => ( +
  • {line}
  • + ))} + {freeRights.bullets.map((line) => ( +
  • {line}
  • + ))} +
+
+ +

+ {t("plans.creditsPerMonth", { n: freePlan.monthly_credits })} + · {formatPlanPrice(freePlan.price_twd)} +

+

{freeRights.approxCallsLine}

+

{freeRights.softCapsLine}

+

{t("home.free.note")}

+ + + +
+
+
+ +
+

+ {t("home.pricingTitle")} +

+

{t("home.pricingLead")}

+
+ {PLAN_ORDER.map((id) => { + const p = PLANS[id]; + const rights = getPlanRights(id, t, formatPlanPrice); + const popular = id === "starter"; + const isFree = id === "free"; + return ( +
+
+
+

{p.name}

+ {popular ? {t("plans.recommended")} : null} + {isFree ? {t("home.free.badge")} : null} +
+

{rights.headline}

+

+ {formatPlanPrice(p.price_twd)} + {t("plans.perMonth")} +

+

+ {t("plans.creditsPerMonth", { n: p.monthly_credits })} +

+

{rights.approxCallsLine}

+
+
    + {rights.bullets.map((b) => ( +
  • {b}
  • + ))} +
+
+ + + +
+
+ ); + })} +
+

{t("home.pricingFootnote")}

+
+ +
+

+ {t("home.ctaTitle")} +

+

{t("home.ctaBody")}

+
+ + + + + {t("home.ctaFreeDetail")} + +
+
+
+ +
+ + {t("app.name")} · {t("app.tagline")} + + +
+
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 6f1831a..e712ec8 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -78,6 +78,20 @@ export function LoginPage() { +
+ + {t("privacy.backHome")} + + + {t("home.privacyLink")} + + + {t("home.termsLink")} + + + {t("home.dataDeletionLink")} + +
diff --git a/apps/web/src/pages/PrivacyPage.tsx b/apps/web/src/pages/PrivacyPage.tsx new file mode 100644 index 0000000..58a7f49 --- /dev/null +++ b/apps/web/src/pages/PrivacyPage.tsx @@ -0,0 +1,66 @@ +import { Link } from "react-router-dom"; +import { PublicLegalChrome } from "../components/layout/PublicLegalChrome"; +import { Button, Card } from "../components/ui"; +import { useI18n } from "../i18n/I18nContext"; + +/** Order and ids matter for Meta review + tests (data-privacy-section). */ +const SECTIONS = [ + { id: "overview", title: "privacy.section.overview.title", body: "privacy.section.overview.body" }, + { id: "collect", title: "privacy.section.collect.title", body: "privacy.section.collect.body" }, + { id: "use", title: "privacy.section.use.title", body: "privacy.section.use.body" }, + { id: "threads", title: "privacy.section.threads.title", body: "privacy.section.threads.body" }, + { id: "share", title: "privacy.section.share.title", body: "privacy.section.share.body" }, + { id: "ai", title: "privacy.section.ai.title", body: "privacy.section.ai.body" }, + { id: "retention", title: "privacy.section.retention.title", body: "privacy.section.retention.body" }, + { id: "deletion", title: "privacy.section.deletion.title", body: "privacy.section.deletion.body" }, + { id: "rights", title: "privacy.section.rights.title", body: "privacy.section.rights.body" }, + { id: "security", title: "privacy.section.security.title", body: "privacy.section.security.body" }, + { id: "children", title: "privacy.section.children.title", body: "privacy.section.children.body" }, + { id: "contact", title: "privacy.section.contact.title", body: "privacy.section.contact.body" }, + { id: "updates", title: "privacy.section.updates.title", body: "privacy.section.updates.body" }, +] as const; + +/** + * Public privacy policy for Meta / Threads App Review. + * Must stay at a stable public URL (e.g. https:///privacy) without auth or geo-block. + */ +export function PrivacyPage() { + const { t } = useI18n(); + + return ( + +
+
+

{t("privacy.title")}

+

{t("privacy.updated")}

+

{t("privacy.intro")}

+

{t("privacy.metaNote")}

+
+ +
+ {SECTIONS.map((section) => ( + +

{t(section.body)}

+
+ ))} +
+ +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/TermsPage.tsx b/apps/web/src/pages/TermsPage.tsx new file mode 100644 index 0000000..4626b5c --- /dev/null +++ b/apps/web/src/pages/TermsPage.tsx @@ -0,0 +1,56 @@ +import { Link } from "react-router-dom"; +import { PublicLegalChrome } from "../components/layout/PublicLegalChrome"; +import { Button, Card } from "../components/ui"; +import { useI18n } from "../i18n/I18nContext"; + +const SECTIONS = [ + { id: "acceptance", title: "terms.section.acceptance.title", body: "terms.section.acceptance.body" }, + { id: "service", title: "terms.section.service.title", body: "terms.section.service.body" }, + { id: "accounts", title: "terms.section.accounts.title", body: "terms.section.accounts.body" }, + { id: "threads", title: "terms.section.threads.title", body: "terms.section.threads.body" }, + { id: "content", title: "terms.section.content.title", body: "terms.section.content.body" }, + { id: "acceptable", title: "terms.section.acceptable.title", body: "terms.section.acceptable.body" }, + { id: "billing", title: "terms.section.billing.title", body: "terms.section.billing.body" }, + { id: "disclaimer", title: "terms.section.disclaimer.title", body: "terms.section.disclaimer.body" }, + { id: "termination", title: "terms.section.termination.title", body: "terms.section.termination.body" }, + { id: "contact", title: "terms.section.contact.title", body: "terms.section.contact.body" }, +] as const; + +/** + * Public terms of service — often required next to Privacy Policy for Meta App Review. + * Stable public URL: /terms + */ +export function TermsPage() { + const { t } = useI18n(); + + return ( + +
+
+

{t("terms.title")}

+

{t("terms.updated")}

+

{t("terms.intro")}

+
+ +
+ {SECTIONS.map((section) => ( + +

{t(section.body)}

+
+ ))} +
+ +
+ + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/publicPages.test.tsx b/apps/web/src/pages/publicPages.test.tsx new file mode 100644 index 0000000..8bd5a36 --- /dev/null +++ b/apps/web/src/pages/publicPages.test.tsx @@ -0,0 +1,355 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KEYS } from "../data/mock/keys"; +import { I18nProvider } from "../i18n/I18nContext"; +import { DataDeletionPage } from "./DataDeletionPage"; +import { HomePage } from "./HomePage"; +import { PrivacyPage } from "./PrivacyPage"; +import { TermsPage } from "./TermsPage"; +import { translate } from "../lib/i18n/messages"; +import { PLANS } from "../lib/usageMeter"; +import { getPlanRights } from "../lib/planRights"; + +const authState = vi.hoisted(() => ({ + member: null as null | { id: string; email: string }, + loading: false, +})); + +vi.mock("../auth/AuthContext", () => ({ + useAuth: () => ({ + member: authState.member, + loading: authState.loading, + unreachable: false, + login: vi.fn(), + logout: vi.fn(), + reload: vi.fn(), + updateProfile: vi.fn(), + }), +})); + +const t = (key: string, params?: Record) => + translate("zh-TW", key, params); + +/** Same public routes App.tsx exposes outside RequireAuth / AppShell. */ +function PublicRoutes({ initialPath }: { initialPath: string }) { + return ( + + + + } /> + } /> + } /> + } /> + login} /> + app} /> + + + + ); +} + +function forceZhTwLocale() { + localStorage.setItem( + KEYS.uiPrefs, + JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }), + ); +} + +const PRIVACY_SECTION_IDS = [ + "overview", + "collect", + "use", + "threads", + "share", + "ai", + "retention", + "deletion", + "rights", + "security", + "children", + "contact", + "updates", +] as const; + +describe("public intro homepage", () => { + beforeEach(() => { + authState.member = null; + authState.loading = false; + forceZhTwLocale(); + }); + + it("renders intro copy and login CTA without RequireAuth for unauthenticated visitors", () => { + render(); + + const home = screen.getByTestId("public-home"); + expect(home).toBeInTheDocument(); + expect(screen.queryByTestId("app-stub")).not.toBeInTheDocument(); + + expect( + within(home).getByRole("heading", { + level: 1, + name: t("home.heroTitle"), + }), + ).toBeInTheDocument(); + expect(within(home).getByText(t("home.heroLead"))).toBeInTheDocument(); + expect(within(home).getByText(t("home.whoTitle"))).toBeInTheDocument(); + expect(within(home).getByText(t("home.whatTitle"))).toBeInTheDocument(); + for (const key of [ + "home.feature.scout.title", + "home.feature.studio.title", + "home.feature.crew.title", + "home.feature.outbox.title", + "home.feature.inspire.title", + "home.feature.usage.title", + ]) { + expect(within(home).getByText(t(key))).toBeInTheDocument(); + } + + // Product UI previews (illustrative frames) + expect(within(home).getByTestId("home-product-preview")).toBeInTheDocument(); + expect(within(home).getByText(t("home.preview.scout.title"))).toBeInTheDocument(); + expect(within(home).getByText(t("home.preview.studio.title"))).toBeInTheDocument(); + expect(within(home).getByText(t("home.preview.outbox.title"))).toBeInTheDocument(); + + // Free tier deep-dive uses real plan rights copy + const free = within(home).getByTestId("home-free"); + expect(within(free).getByText(t("home.freeTitle"))).toBeInTheDocument(); + const freeRights = getPlanRights("free", t); + expect(within(free).getByText(freeRights.rights[0])).toBeInTheDocument(); + expect( + within(free).getByText(t("plans.creditsPerMonth", { n: PLANS.free.monthly_credits })), + ).toBeInTheDocument(); + + // Pricing cards for free / starter / pro from shipped PLANS + const pricing = within(home).getByTestId("home-pricing"); + expect(within(pricing).getByText(t("home.pricingTitle"))).toBeInTheDocument(); + for (const id of ["free", "starter", "pro"] as const) { + const card = pricing.querySelector(`[data-plan="${id}"]`); + expect(card, `missing plan card ${id}`).toBeTruthy(); + expect(card?.textContent).toContain(PLANS[id].name); + expect(card?.textContent).toContain(String(PLANS[id].monthly_credits)); + } + + const enterLinks = within(home).getAllByRole("link", { + name: t("home.ctaEnter"), + }); + expect(enterLinks.length).toBeGreaterThanOrEqual(1); + expect(enterLinks[0]).toHaveAttribute("href", "/login"); + + const privacyLinks = within(home).getAllByRole("link", { + name: t("home.privacyLink"), + }); + expect(privacyLinks.length).toBeGreaterThanOrEqual(1); + expect(privacyLinks[0]).toHaveAttribute("href", "/privacy"); + + expect(within(home).getByRole("link", { name: t("home.termsLink") })).toHaveAttribute( + "href", + "/terms", + ); + expect(within(home).getByRole("link", { name: t("home.dataDeletionLink") })).toHaveAttribute( + "href", + "/data-deletion", + ); + }); + + it("serves the intro at root `/` for unauthenticated context (not only /app bounce)", () => { + render(); + + expect(screen.getByTestId("public-home")).toBeInTheDocument(); + expect(screen.queryByTestId("app-stub")).not.toBeInTheDocument(); + expect(screen.queryByTestId("login-stub")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(t("home.heroTitle")); + }); + + it("redirects authenticated members from `/` into the app", () => { + authState.member = { id: "m1", email: "alice@example.com" }; + render(); + + expect(screen.queryByTestId("public-home")).not.toBeInTheDocument(); + expect(screen.getByTestId("app-stub")).toBeInTheDocument(); + }); + + it("switches intro copy between zh-TW and en via the public locale control", () => { + render(); + + const home = screen.getByTestId("public-home"); + expect( + within(home).getByRole("heading", { level: 1, name: t("home.heroTitle") }), + ).toBeInTheDocument(); + + const localeGroup = within(home).getByRole("group", { name: t("public.localeLabel") }); + fireEvent.click(within(localeGroup).getByRole("button", { name: translate("zh-TW", "locale.en") })); + + expect( + within(home).getByRole("heading", { + level: 1, + name: translate("en", "home.heroTitle"), + }), + ).toBeInTheDocument(); + expect(within(home).getByText(translate("en", "home.heroLead"))).toBeInTheDocument(); + expect( + within(home).getAllByText(translate("en", "home.privacyLink")).length, + ).toBeGreaterThanOrEqual(1); + + fireEvent.click(within(localeGroup).getByRole("button", { name: translate("en", "locale.zh-TW") })); + expect( + within(home).getByRole("heading", { level: 1, name: t("home.heroTitle") }), + ).toBeInTheDocument(); + }); +}); + +describe("public privacy policy page", () => { + beforeEach(() => { + authState.member = null; + authState.loading = false; + forceZhTwLocale(); + }); + + it("renders multi-section Meta-ready privacy content without auth", () => { + render(); + + const page = screen.getByTestId("public-privacy"); + expect(page).toBeInTheDocument(); + + expect( + within(page).getByRole("heading", { + level: 1, + name: t("privacy.title"), + }), + ).toBeInTheDocument(); + expect(within(page).getByText(t("privacy.intro"))).toBeInTheDocument(); + + for (const id of PRIVACY_SECTION_IDS) { + const section = page.querySelector(`[data-privacy-section="${id}"]`); + expect(section, `missing privacy section ${id}`).toBeTruthy(); + const body = section?.textContent ?? ""; + expect(body.length, `section ${id} should have substantive text`).toBeGreaterThan(40); + } + + // Meta App Review minimums: what / why / how to delete + expect(within(page).getByText(t("privacy.section.collect.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("privacy.section.use.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("privacy.section.threads.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("privacy.section.deletion.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("privacy.section.contact.title"))).toBeInTheDocument(); + const contact = page.querySelector('[data-privacy-section="contact"]'); + expect(contact?.textContent).toMatch(/Privacy|Data Request|隱私/); + + // Explicit deletion path for User Data Deletion Instructions cross-link + const deletionLinks = within(page).getAllByRole("link", { + name: t("home.dataDeletionLink"), + }); + expect(deletionLinks.length).toBeGreaterThanOrEqual(1); + expect(deletionLinks[0]).toHaveAttribute("href", "/data-deletion"); + }); + + it("switches privacy policy copy between zh-TW and en via the public locale control", () => { + render(); + + const page = screen.getByTestId("public-privacy"); + const localeGroup = within(page).getByRole("group", { name: t("public.localeLabel") }); + fireEvent.click(within(localeGroup).getByRole("button", { name: translate("zh-TW", "locale.en") })); + + expect( + within(page).getByRole("heading", { + level: 1, + name: translate("en", "privacy.title"), + }), + ).toBeInTheDocument(); + expect(within(page).getByText(translate("en", "privacy.section.deletion.title"))).toBeInTheDocument(); + expect(within(page).getByText(translate("en", "privacy.section.threads.title"))).toBeInTheDocument(); + + for (const id of PRIVACY_SECTION_IDS) { + const section = page.querySelector(`[data-privacy-section="${id}"]`); + expect(section, `missing privacy section ${id} after en switch`).toBeTruthy(); + expect((section?.textContent ?? "").length).toBeGreaterThan(40); + } + }); +}); + +describe("public terms and data deletion pages", () => { + beforeEach(() => { + authState.member = null; + authState.loading = false; + forceZhTwLocale(); + }); + + it("renders terms of service with substantive sections without auth", () => { + render(); + + const page = screen.getByTestId("public-terms"); + expect( + within(page).getByRole("heading", { level: 1, name: t("terms.title") }), + ).toBeInTheDocument(); + expect(within(page).getByText(t("terms.section.threads.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("terms.section.acceptable.title"))).toBeInTheDocument(); + + for (const id of [ + "acceptance", + "service", + "accounts", + "threads", + "content", + "acceptable", + "billing", + "disclaimer", + "termination", + "contact", + ]) { + const section = page.querySelector(`[data-terms-section="${id}"]`); + expect(section, `missing terms section ${id}`).toBeTruthy(); + expect((section?.textContent ?? "").length).toBeGreaterThan(40); + } + }); + + it("renders data deletion instructions suitable for Meta dashboard URL", () => { + render(); + + const page = screen.getByTestId("public-data-deletion"); + expect( + within(page).getByRole("heading", { level: 1, name: t("deletion.title") }), + ).toBeInTheDocument(); + expect(within(page).getByText(t("deletion.section.steps.title"))).toBeInTheDocument(); + expect(within(page).getByText(t("deletion.section.threads.title"))).toBeInTheDocument(); + + for (const id of ["summary", "steps", "threads", "scope", "timeline", "contact"]) { + const section = page.querySelector(`[data-deletion-section="${id}"]`); + expect(section, `missing deletion section ${id}`).toBeTruthy(); + expect((section?.textContent ?? "").length).toBeGreaterThan(40); + } + + // Steps must mention how to email / request (Meta requires clear deletion path) + const steps = page.querySelector('[data-deletion-section="steps"]'); + expect(steps?.textContent).toMatch(/Data Deletion Request|資料刪除請求/); + }); +}); + +describe("App routing structure (public outside RequireAuth)", () => { + it("keeps legal routes as public siblings of `/login`, not under `/app`", async () => { + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const path = await import("node:path"); + const appPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../App.tsx"); + const src = readFileSync(appPath, "utf8"); + + expect(src).toMatch(/path=["']\/["']/); + expect(src).toMatch(/path=["']\/privacy["']/); + expect(src).toMatch(/path=["']\/terms["']/); + expect(src).toMatch(/path=["']\/data-deletion["']/); + expect(src).toMatch(/HomePage/); + expect(src).toMatch(/PrivacyPage/); + expect(src).toMatch(/TermsPage/); + expect(src).toMatch(/DataDeletionPage/); + + const appStart = src.indexOf('path="/app"'); + const appEnd = src.indexOf('path="*"'); + expect(appStart).toBeGreaterThan(-1); + expect(appEnd).toBeGreaterThan(appStart); + const appBlock = src.slice(appStart, appEnd); + expect(appBlock).not.toMatch(/HomePage|PrivacyPage|TermsPage|DataDeletionPage/); + expect(appBlock).toMatch(/RequireAuth/); + + expect(src).not.toMatch(/path=["']\/["']\s+element=\{<\s*Navigate\s+to=["']\/app/); + }); +}); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 4d25e48..d3b8687 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -145,6 +145,658 @@ svg { color: var(--hb-brand-deep); } +/* —— Public intro / legal pages (pre AppShell, no auth) —— */ +.hb-public { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + padding: var(--hb-space-5) var(--hb-space-6) var(--hb-space-8); + background: + radial-gradient(circle at 28px 28px, color-mix(in srgb, var(--hb-brand) 20%, transparent) 0 1px, transparent 1.5px) 0 0 / 64px 64px, + radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-brand-dim) 18%, transparent), transparent 55%), + radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-brand) 13%, transparent), transparent 50%), + radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-brand-dim) 10%, transparent), transparent 50%), + var(--hb-bg); +} + +.hb-public__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--hb-space-4); + max-width: 64rem; + width: 100%; + margin: 0 auto var(--hb-space-8); +} + +.hb-public__brand { + display: flex; + align-items: center; + gap: var(--hb-space-3); +} + +.hb-public__brand-link { + text-decoration: none; + color: inherit; +} + +.hb-public__brand .hb-brand-mark { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; + object-fit: cover; + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent), + 0 0 24px var(--hb-magic-glow), + 0 8px 20px color-mix(in srgb, var(--hb-ink) 22%, transparent); +} + +.hb-public__brand-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + line-height: 1.2; +} + +.hb-public__brand-text strong { + font-size: var(--hb-text-lg); + letter-spacing: -0.02em; +} + +.hb-public__brand-text span { + font-size: var(--hb-text-2xs); + font-weight: 600; +} + +.hb-public__nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hb-space-3); +} + +.hb-public-locale { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--hb-line-strong); + border-radius: var(--hb-radius-pill); + overflow: hidden; + background: var(--hb-surface-solid); +} + +.hb-public-locale__btn { + appearance: none; + border: 0; + margin: 0; + padding: 0.35rem 0.7rem; + font: inherit; + font-size: var(--hb-text-xs); + font-weight: 650; + line-height: 1.2; + color: var(--hb-muted); + background: transparent; + cursor: pointer; +} + +.hb-public-locale__btn + .hb-public-locale__btn { + border-left: 1px solid var(--hb-line-strong); +} + +.hb-public-locale__btn:hover { + color: var(--hb-ink); + background: var(--hb-surface-muted); +} + +.hb-public-locale__btn.is-active { + color: var(--hb-brand-on); + background: var(--hb-brand); +} + +.hb-public-locale__btn:focus-visible { + outline: 2px solid var(--hb-focus); + outline-offset: 1px; + z-index: 1; +} + +.hb-public__nav-link { + font-size: var(--hb-text-sm); + font-weight: 600; + color: var(--hb-brand-deep); + text-decoration: none; +} + +.hb-public__nav-link:hover { + text-decoration: underline; +} + +.hb-public__nav-cta { + text-decoration: none; +} + +.hb-public__main { + flex: 1; + max-width: 64rem; + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--hb-space-8); +} + +.hb-public__main--narrow { + max-width: 42rem; +} + +.hb-public__hero-band { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: var(--hb-space-8); + align-items: center; +} + +@media (max-width: 900px) { + .hb-public__hero-band { + grid-template-columns: 1fr; + } + + .hb-public__hero-visual { + order: -1; + max-width: 28rem; + } +} + +.hb-public__hero { + display: flex; + flex-direction: column; + gap: var(--hb-space-4); + max-width: 40rem; +} + +.hb-public__hero-points { + margin: var(--hb-space-2) 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.45rem; + font-size: var(--hb-text-sm); + color: var(--hb-ink-secondary); +} + +.hb-public__hero-points li { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.hb-public__hero-points li::before { + content: ""; + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + margin-top: 0.45rem; + border-radius: 999px; + background: var(--hb-brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--hb-brand) 22%, transparent); +} + +.hb-public__hero-visual { + width: 100%; +} + +.hb-public__feature-grid--wide { + grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr)); +} + +.hb-public__free-band { + padding: var(--hb-space-6); + border-radius: var(--hb-radius-lg); + border: 1px solid color-mix(in srgb, var(--hb-brand) 35%, var(--hb-line)); + background: + radial-gradient(ellipse 70% 80% at 0% 0%, color-mix(in srgb, var(--hb-brand-soft) 70%, transparent), transparent 55%), + var(--hb-surface); + display: flex; + flex-direction: column; + gap: var(--hb-space-5); +} + +.hb-public__free-band-head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--hb-space-3); + max-width: 40rem; +} + +.hb-public__free-band-head .hb-public__section-title { + margin: 0; +} + +.hb-public__free-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--hb-space-4); +} + +.hb-public__check-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.55rem; + font-size: var(--hb-text-sm); + line-height: 1.55; + color: var(--hb-ink-secondary); +} + +.hb-public__check-list li { + padding-left: 1.25rem; + position: relative; +} + +.hb-public__check-list li::before { + content: ""; + position: absolute; + left: 0; + top: 0.45rem; + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--hb-success); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--hb-success) 25%, transparent); +} + +.hb-public__free-credits { + margin: 0 0 var(--hb-space-2); + font-size: var(--hb-text-lg); +} + +.hb-public__free-note { + margin: var(--hb-space-3) 0; + font-size: var(--hb-text-sm); + color: var(--hb-ink-secondary); + line-height: 1.6; +} + +.hb-public__free-cta { + text-decoration: none; + display: inline-block; + margin-top: var(--hb-space-2); +} + +.hb-public__pricing-footnote { + margin: var(--hb-space-4) 0 0; + font-size: var(--hb-text-sm); + max-width: 40rem; + line-height: 1.6; +} + +/* Product UI mock frames (homepage) */ +.hb-public-previews { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--hb-space-5); +} + +.hb-public-preview { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--hb-space-3); +} + +.hb-public-preview__caption { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: var(--hb-text-sm); +} + +.hb-public-preview__caption strong { + font-size: var(--hb-text-base); + letter-spacing: -0.02em; +} + +.hb-public-preview__frame { + border-radius: var(--hb-radius-lg); + border: 1px solid var(--hb-line-strong); + background: var(--hb-surface-solid); + box-shadow: var(--hb-shadow-card); + overflow: hidden; + min-height: 12.5rem; +} + +.hb-public-preview__frame--hero { + min-height: 16rem; + transform: rotate(-1.2deg); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--hb-brand) 18%, transparent), + 0 18px 40px color-mix(in srgb, var(--hb-ink) 14%, transparent); +} + +.hb-public-preview__chrome { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--hb-line); + background: color-mix(in srgb, var(--hb-surface-muted) 80%, var(--hb-surface-solid)); +} + +.hb-public-preview__chrome > span { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--hb-muted) 35%, var(--hb-line-strong)); +} + +.hb-public-preview__chrome > span:nth-child(1) { + background: color-mix(in srgb, var(--hb-danger) 55%, white); +} + +.hb-public-preview__chrome > span:nth-child(2) { + background: color-mix(in srgb, var(--hb-warning) 55%, white); +} + +.hb-public-preview__chrome > span:nth-child(3) { + background: color-mix(in srgb, var(--hb-success) 55%, white); +} + +.hb-public-preview__chrome-bar { + flex: 1; + height: 0.45rem; + margin-left: 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--hb-line-strong) 80%, transparent); +} + +.hb-public-preview__body { + padding: 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + background: + linear-gradient(180deg, color-mix(in srgb, var(--hb-brand-soft) 35%, transparent), transparent 60%), + var(--hb-bg); +} + +.hb-mock-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.hb-mock-row--head { + justify-content: space-between; +} + +.hb-mock-row--list { + padding: 0.45rem 0.55rem; + border-radius: var(--hb-radius); + border: 1px solid var(--hb-line); + background: var(--hb-surface-solid); +} + +.hb-mock-stack { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} + +.hb-mock-card { + padding: 0.7rem 0.75rem; + border-radius: var(--hb-radius); + border: 1px solid var(--hb-line); + background: var(--hb-surface-solid); + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.hb-mock-card--dim { + opacity: 0.72; +} + +.hb-mock-editor { + min-height: 5.5rem; + padding: 0.75rem; + border-radius: var(--hb-radius); + border: 1px solid var(--hb-line); + background: var(--hb-surface-solid); + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.hb-mock-line { + display: block; + height: 0.45rem; + width: 100%; + border-radius: 999px; + background: color-mix(in srgb, var(--hb-ink) 10%, var(--hb-line-strong)); +} + +.hb-mock-line--med { + width: 72%; +} + +.hb-mock-line--short { + width: 42%; +} + +.hb-mock-pill { + display: block; + width: 4.5rem; + height: 1.15rem; + border-radius: 999px; + background: color-mix(in srgb, var(--hb-brand) 45%, var(--hb-surface-muted)); +} + +.hb-mock-chip { + display: block; + width: 2.8rem; + height: 0.9rem; + border-radius: 999px; + background: var(--hb-surface-muted); + border: 1px solid var(--hb-line); +} + +.hb-mock-chip--active { + background: color-mix(in srgb, var(--hb-brand) 35%, white); + border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line)); +} + +.hb-mock-actions { + display: flex; + gap: 0.4rem; + margin-top: 0.25rem; +} + +.hb-mock-btn { + display: block; + width: 3.8rem; + height: 1.15rem; + border-radius: 0.45rem; + background: var(--hb-brand); +} + +.hb-mock-btn--ghost { + background: transparent; + border: 1px solid var(--hb-line-strong); +} + +.hb-mock-btn--wide { + width: 5.5rem; +} + +.hb-mock-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--hb-brand-deep); + flex: 0 0 auto; +} + +.hb-mock-status { + width: 2.2rem; + height: 0.55rem; + border-radius: 999px; + background: var(--hb-line-strong); + flex: 0 0 auto; +} + +.hb-mock-status.is-ready { + background: color-mix(in srgb, var(--hb-success) 55%, white); +} + +.hb-pricing__card.is-free { + border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line)); +} + +.hb-pricing__cta a { + text-decoration: none; + display: block; +} + +.hb-pricing__cta a .hb-btn { + width: 100%; +} + +.hb-public__eyebrow { + margin: 0; + font-size: var(--hb-text-sm); + font-weight: 650; + color: var(--hb-brand-deep); + letter-spacing: 0.02em; +} + +.hb-public__title { + margin: 0; + font-size: clamp(1.75rem, 4vw, 2.35rem); + line-height: 1.2; + letter-spacing: -0.03em; + font-weight: 700; +} + +.hb-public__lead { + margin: 0; + font-size: var(--hb-text-md, 1.05rem); + line-height: 1.65; + color: var(--hb-ink-secondary); +} + +.hb-public__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hb-space-4); + margin-top: var(--hb-space-2); +} + +.hb-public__actions a { + text-decoration: none; +} + +.hb-public__text-link { + font-size: var(--hb-text-sm); + font-weight: 600; + color: var(--hb-brand-deep); + text-decoration: none; +} + +.hb-public__text-link:hover { + text-decoration: underline; +} + +.hb-public__section-title { + margin: 0 0 var(--hb-space-3); + font-size: var(--hb-text-xl, 1.25rem); + letter-spacing: -0.02em; +} + +.hb-public__section-lead { + margin: 0; + max-width: 40rem; + line-height: 1.65; +} + +.hb-public__feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--hb-space-4); +} + +.hb-public__feature-grid .hb-card { + height: 100%; + box-shadow: var(--hb-shadow-card); +} + +.hb-public__cta-band { + padding: var(--hb-space-6); + border-radius: var(--hb-radius-lg); + border: 1px solid var(--hb-line); + background: color-mix(in srgb, var(--hb-brand-soft) 55%, var(--hb-surface)); + display: flex; + flex-direction: column; + gap: var(--hb-space-3); + align-items: flex-start; +} + +.hb-public__cta-band a { + text-decoration: none; +} + +.hb-public__footer { + max-width: 64rem; + width: 100%; + margin: var(--hb-space-8) auto 0; + padding-top: var(--hb-space-5); + border-top: 1px solid var(--hb-line); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--hb-space-3); + font-size: var(--hb-text-sm); +} + +.hb-public__footer-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--hb-space-3) var(--hb-space-4); +} + +.hb-public__meta-note { + margin: 0; + font-size: var(--hb-text-sm); + line-height: 1.6; +} + +.hb-public__legal-hero { + display: flex; + flex-direction: column; + gap: var(--hb-space-3); +} + +.hb-public__legal-sections { + display: flex; + flex-direction: column; + gap: var(--hb-space-4); +} + +.hb-public__legal-body { + margin: 0; + line-height: 1.7; + white-space: pre-line; + color: var(--hb-ink-secondary); +} + /* Auth / login shell (pre AppShell) */ .hb-login { min-height: 100vh; @@ -238,6 +890,14 @@ svg { text-decoration: underline; } +.hb-login-public-links { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--hb-space-3) var(--hb-space-4); + margin-top: var(--hb-space-1); +} + .hb-login-mock-mail { display: flex; flex-direction: column; diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index f149f27..e262193 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1,7 @@ import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +});