一個信任宣示頁,怎麼一路長成 v0.13.x 的雙向確認儀式

~6 min #futari#devlog#release#feature

說起來其實有點不好意思——我之前在 Settings 裡面有一個叫「使用條款」的連結,點下去是 dead link。對,沒錯,一個記帳 app 的法律入口 404。要不是 doc-keeper 那輪 sweep 把這顆地雷翻出來,我可能還會繼續讓它躺在那裡,等某個認真的伴侶點下去然後決定把這個 app 從手機刪掉。

更尷尬的是,Futari 整個產品 pitch 是「兩個人的信任」,但 onboarding 從 sign-in 直接丟去 /setup 填名字+邀請對方——沒有任何一刻在問「你們確定嗎」。然後我自己對外的競品分析還寫著「Honeydue 的衰退讓使用者焦慮 → 信任宣示是 short-term 急迫」(#48)——結果自己的 setup flow 連一句承諾都沒有。被自己 backlog 抓包。

所以這趟一口氣推到 v0.13.1,主軸只有一個:把陪伴跟信任,從文案層做到 server action 層

Phase A/B:先有個地方讓人知道資料怎麼被守著

最直接的就是新增 /settings/trust 這頁——三個 section:加密、可攜性、備份。不過這次刻意不用工程語言,描述全部換成「我們替你們守著」這種陪伴口吻。我自己一邊寫一邊覺得肉麻,但這個產品的目標族群就吃這一套,寫得像 AWS whitepaper 反而會嚇跑使用者

i18n 開了一個 trust.* namespace、四語(zh-TW / zh-CN / en / ja)通通補齊。Settings index 加了「資料」section 把這頁掛上去——順手把那個 dead link 換成 terms + privacy 兩個真的會動的入口。

Phase B 把 trust 內容抽 3 行短版塞進 /setup 的邀請步驟,分享按鈕底下。這裡踩到一個 server component 的小細節:/setup 不在 dashboard layout 底下,所以它沒有 <TranslationsProvider>——i18n 字串拿不到。解法是直接在 server component 用 getTranslations() server-side fetch,把翻譯好的字串當 prop 傳進去。不要 client-side 撈,因為 setup 階段網路品質還不知道,能 SSR 就 SSR 完。

Phase E:邀請流程升級成雙向確認儀式

這條是這版最有戲的——把單向的「A 邀請 B」改成「A 承諾 → B 也承諾」的雙向 ritual。

流程長這樣:A 走 sign-in → name 填好 → 看 3 個承諾頁(「我會看見對方的付出」之類的)→ 按「這是我希望的」→ 拿到 invite link 分享出去。B 點連結進來 → 看到「○○ 已經承諾了這些」+ 一樣的 3 個承諾 → 按「我也是」→ 進 dashboard。

技術上比看起來細。新增了一個 previewInvite server action——B 確認之前先驗 token、撈邀請者名字,但 不 commit。這個區分很重要:原本 acceptInvite 是「驗證 + join group」綁在一起的 atomic 操作,現在拆成兩階段,因為我需要在 B 還沒按下「我也是」之前就把 A 的名字 render 出來。token 在 preview 階段做的檢查(過期、已用、group 有效)跟 commit 階段一樣嚴,只是少了 mutate state 那一步——validator 共用,單一 source of truth,不會兩條 path 漂移

TrustCommitments 元件抽出來在三個地方重用:/settings/trust/setup/trust/invite/[token]/confirm——同一份承諾文案,在三個 context 都長得一樣。這種共用是值得的,因為哪天文案要改(一定會改,產品文案永遠在改),只動一個地方就好。

Phase C:第一筆紀錄的儀式感

新增一個 getActiveTransactionCount query——SELECT COUNT(*) WHERE deleted_at IS NULL,沒什麼特別的,但它驅動了一張 FirstRecordCard:當使用者剛記下第一筆,dashboard 會跳一張 slide-up 卡片寫一句溫暖的話,按 dismiss 才消失。

Session-local 不寫 DB——因為這只是一個 first-time moment,不值得多開一張 user_milestones 表去 track。localStorage flag 防它在同一個 session 重複跳。哪天 #100(更多里程碑)要做的時候,這個 query 可以直接重用,scaffolding 已經在那。

Onboarding philosophy cards:5 張可跳過的卡

v0.13.1 補一個鋪陳——sign-in 進來不是直接去 /setup,而是先過 /onboarding,5 張哲學卡(信任、共有、儀式收入、保險作為承諾),可以跳過。localStorage flag 第二次就不再出現。Dashboard layout 的 redirect 也改了:沒 group → 不再丟去 /setup,而是丟去 /onboarding

這個改動超小(~400 行 client component)但決策上很大——把產品的價值觀放在使用者輸入名字之前。你進這個 app 看到的第一件事是「我們相信什麼」,不是「請輸入你的暱稱」。Honeydue 衰退的時間窗口,這種 framing 可以多賺幾秒鐘讓使用者決定要不要留下來。

順便清掉的小地雷

CSV export(#37)也順手做掉了——RFC 4180 escaping、UTF-8 BOM、CRLF 行尾,Excel 打開不亂碼這件事踩過的人都懂。route handler 透過 OikosGroups membership check 確保只能匯出自己 group 的資料,不會有人塞別人的 group_id 拿到別家帳本。

og-image 從 768 KB 壓到 95 KB(ImageMagick 256-color palette quantize)——Twitter / Facebook fetch 上限大概 300 KB,超過直接 fallback。社群分享預覽圖跑不出來這種 bug 不會炸 server,但它是那種「使用者不會 report、但會默默不分享」的死亡螺旋。

定期支出整條 v0.13.0 也在這天 ship——server actions、queries、Settings subpage、Dashboard pending stack、AddSheet 「改一下」inline edit 通通到位。recurring expense / income 的共用邏輯後來 refactor 了一輪 dedupe,畢竟兩條 path 的差異只有 paid_bysplit_type 那兩欄,cron/validator/schema 結構幾乎一致。

收尾

45 個 commit,v0.12 / v0.13 / v0.13.1 三版同一天落地。最後在 /onboarding 看到那 5 張卡片自己 fade-in 的時候,我突然意識到——Futari 終於有「第一印象」了。之前那個直接把使用者丟進 /setup 的版本,就像走進一間餐廳被服務生第一句話問「你叫什麼名字?」。現在至少有人先說了句「歡迎」。

這段 code 寫於 2026 年 5 月,文章整理於 2026 年 5 月。