首页加上启程清单和一次性提示(#97)。
右上角「启程 n/N」按真实使用打勾,收费项读计价表。八条提示一次一条,看过写入已有的 onboarding_state。没有新表、新接口,也没有增加 Home 的状态。 自测:tsc --noEmit 通过;frontend 下 npm run lint 是 0 error、121 warning;定向测试 94 项全过,清单测试在最后一次改动后再跑 12 项全过;npm run build 后 / 仍是 Static。首屏 JS gzip-9 是 617860 字节,相对 592895 增加 4.21%,原因写在进度里。没有跑全量 npm test,没有跑 Python 快速门,没有宣称 test:db。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
4d63475caf
commit
8f7c0f7db4
@@ -1,5 +1,12 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-24 — 右上角出现启程清单,用过的功能会打勾
|
||||
|
||||
- 建完档之后,首页右上角是「启程 n/7」。七项按真实使用打勾。合盘和个人报告写出计价表上的价格;对应开关关着时,这一行不出现。
|
||||
- 这一次把七项都做完,会再打开一次,写「都走过一遍了」。点「先不看了」之后不再出现。进站时已经都做过的账户不显示。
|
||||
- 八条提示一次只出一条。点「知道了」、点页面别处或按 Esc 之后不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。
|
||||
- Skill 版本不变(BUG-1432)。
|
||||
|
||||
## 2026-10-24 — 建档先问看谁的盘,空白首页有三个起步问题
|
||||
|
||||
- 建档从三步变成四步:称呼、看谁的盘、出生日期与时间、出生地。
|
||||
|
||||
@@ -76,6 +76,14 @@ _避免使用_:当前星盘、关系、默认星盘
|
||||
这个人相对户主的家里关系:伴侣、孩子、父母、朋友或其他。只记在别人的档案上,本人不记。没填过的显示「其他」。它不是人物的别名。
|
||||
_避免使用_:用「关系」称呼这个人、家人(笼统代替这五项)、客户
|
||||
|
||||
**启程清单**:
|
||||
首页顶栏右侧的「启程 n/N」。七项对应已经能用的功能,勾从真实使用推导,不另存一份进度。点「先不看了」之后不再出现。收费项的价格跟计价表走。
|
||||
_避免使用_:新手任务、引导任务、单独把进度环叫进度条
|
||||
|
||||
**一次性提示**:
|
||||
第一次用到某处功能时,指着那个位置说的一句。一共八条,同一时刻只出一条。看过就记下来,不再出现。天空过场、兑换窗、弹窗和回答还在生成时先不出。
|
||||
_避免使用_:新手引导、教程、教学气泡
|
||||
|
||||
**星盘档案**:
|
||||
侧栏页面 `/people` 里保存的人物列表。最多 5 人,含本人。本人不可删除。
|
||||
_避免使用_:星盘库、星盘资料(设置里的旧面板)
|
||||
|
||||
@@ -19398,3 +19398,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/onboarding-2-intake-20261022`。
|
||||
|
||||
## BUG-1432 | 首页启程清单和一次性提示
|
||||
|
||||
- 状态:enhancement
|
||||
- 首次发现:2026-10-24
|
||||
- 最近更新:2026-10-24
|
||||
- 影响面:首页顶栏、侧栏星盘入口、星盘四个页签、星盘档案里的「和我合盘」、星历入口。
|
||||
- 用户现象:建完档之后,顶栏右侧出现「启程 n/N」。七项按真实使用打勾,收费项写出计价表上的价格。八条提示一次只出一条,看过就不再出现。
|
||||
- 触发条件:档案里至少有一份完整资料,并且没有点过「先不看了」。进站时七项已经都做过的账户不显示。提示在对应的第一次使用时出现,并且当时没有天空过场、兑换窗、弹窗或正在生成的回答。
|
||||
- 根因:不是缺陷。产品在新用户启程里定了清单和一次性提示(任务书 T5、T6)。勾和「看过」用已有的档案、会话、报告和 `onboarding_state`,不另存进度。
|
||||
- 修复:清单和提示放在首页外壳的子组件里,不增加 `Home()` 的状态。价格和开关读现有的产品接口,补上报告中心是否开启和报告价格。人物列表补上已有的建档时间,用来决定帮别人看时写谁的名字。没有新表,没有新接口。
|
||||
- 验证:见 `docs/tasks/PROGRESS-onboarding-3-checklist-20261022.md`。没有浏览器登录态,不把清单走查写成通过。
|
||||
- 防复发:`frontend/tests/onboarding-checklist-20261022.test.ts` 锁住两条路径的句子、打勾规则、开关造成的行数、提示出现条件和互斥。
|
||||
- 相关记录:issue #97。任务书 `docs/tasks/TASK-onboarding-journey-20261022.md` 的 T5、T6。前置 #95 的 `onboarding_state`,#96 的建档。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/onboarding-3-checklist-20261022`。
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# PROGRESS · 新用户启程(三)启程清单与一次性提示(2026-10-24)
|
||||
|
||||
工单 #97。分支 `codex/onboarding-3-checklist-20261022`。基线 `origin/staging` `4d63475cafe6112ca08432c758676e45e2808c16`(#96 已在这个提交里)。只做任务书 T5、T6。T1–T4 用已合入的字段和页面,没有新迁移。T7 动效不做。
|
||||
|
||||
## 结论
|
||||
|
||||
建完档之后,首页右上角出现「启程 n/N」。七项按真实使用打勾,合盘和个人报告写出计价表上的价格。八条提示一次只出一条,看过就不再出现。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 胶囊在首页顶栏右侧、点数按钮左边。手机是底部抽屉,平板和桌面是弹出层。视口还没分清之前,胶囊不可打开,避免先弹出错误的一层。
|
||||
- 本人路径和帮别人看的路径,句子、顺序、「做过」的判定和「去做」去哪里,按任务书的表。本人资料完整就用本人的句子;只有别人完整时才用帮别人看的句子。名字用最早建档的那一位完整他人;没有建档时间时用列表里的第一位,再没有就写 TA。
|
||||
- 「先不看了」写入已有的 `checklistDismissedAt`。七项在这一次使用里刚满时,再打开一次「都走过一遍了」,关掉后同样写入。进站时已经满的账户,胶囊和这一屏都不出现,也不写入。
|
||||
- 合盘行在 `product.compatibility` 开启时出现,价格读 `synastry.full`。个人报告行在 `product.report_center` 和 `PERSONAL_REPORT_ENABLED` 都开启时出现,有 `report.full` 的价格才写「· N 点」。读不到开关时,这两行不出现,也不把账户当成已经做完。
|
||||
- 八条提示用同一个 `useOneTimeHint`。点「知道了」、点页面别处或按 Esc 写入已有的 `hintsSeen`。被天空过场、兑换窗、弹窗、抽屉或正在生成的回答挡住时先不出,条件过去再出。提示不抢焦点。
|
||||
- 问过之后、还没打开星盘时,侧栏「星盘」和手机菜单按钮带一个 8px 小圆点。星盘四个页签没看过时右上角也有小点,各自记住。
|
||||
- 打开星盘且盘已经出来,写入已有的 `chartVisitedAt`。当前人物不是本人时,本命盘那句的「你的」换成「{名字}的」。
|
||||
- `Home()` 的 `useState` / `useRef` 没有增加。白名单没有加长。新界面用已有的按钮、抽屉和弹出层。没有改 `.gitea/`,没有改生产部署配置。
|
||||
|
||||
## 没有新迁移
|
||||
|
||||
读写的是 #95 已有的 `onboarding_state`,以及已有的档案、会话、报告和产品开关。人物列表多返回已有的 `created_at`。产品开关的返回里多了报告中心是否开启和报告价格,原来的合盘字段还在。没有新表,没有新接口,没有改查询写法,没有跑、也没有宣称 `npm run test:db`。
|
||||
|
||||
## 断言改动(原值 / 新值 / 原因)
|
||||
|
||||
没有改既有断言。新增 `frontend/tests/onboarding-checklist-20261022.test.ts`。星盘页、侧栏和首页增长合同仍按原断言通过。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0。中间曾有档案列表的 `role` 被放宽成 `string`,补上原来的 `PersonRow` 后通过 |
|
||||
| 检查 | 在 `frontend` 下 `npm run lint` | 退出码 0。0 error,121 warning。与 #96 记下的 121 条相同,没有新增。没有去修无关警告 |
|
||||
| 定向测试 | 在 `frontend` 下 `node node_modules/tsx/dist/cli.mjs --test` 下面 5 个文件 | 94 项,pass 94,fail 0,cancelled 0,skipped 0,退出码 0。清单测试在最后一次改档案页之后又单独跑过,12 项全过 |
|
||||
| 构建 | 在 `frontend` 下 `npm run build` | 退出码 0。`/` 是 `○` Static。79 页。skill-package-registry 的 5 条 Turbopack 警告是改前就有的 |
|
||||
| 首屏体积 | 首页 HTML 引用的 `/_next/static/**.js`,gzip-9 相加 | 基线 `4d63475`:33 个文件,592895 字节。这一张:32 个文件,缺失 0,617860 字节。+24965 字节,+4.21%。±2% 的上限是 604753,超出约 13107 字节 |
|
||||
|
||||
定向测试文件:`onboarding-checklist-20261022`、`home-shell-growth-contract`、`sidebar-contract`、`chart-page-view`、`profile-gender-20260927`。
|
||||
|
||||
首屏脚本少了一个文件,总体积变大。清单胶囊、八条提示和手机底部抽屉都挂在首页外壳上,首页一打开就要看见胶囊,所以它们进了首屏脚本,没有拆成之后再加载。没有为了凑进 ±2% 去删无关页面。
|
||||
|
||||
没有跑前端全量 `npm test`,没有跑 Python 快速门。Python 入口和 `scripts/jyotish_api_server.py` 没有改。
|
||||
|
||||
## 限制(验收时会碰到)
|
||||
|
||||
- 「问过」只看侧栏已经加载的会话:当前人物,默认每页 40 条。更早的页和其他人的对话不会为了打勾再去翻。标题里有「今日节奏」的算打开过今日星语;消息还没展开时,只要标题对得上也算。
|
||||
- 星历那条提示的「打开三次」,除了上面这些对话,也会把点「今日星语」按钮算进去。点了但没写成一条对话,清单上的「打开今日星语」仍不算做过。
|
||||
- 合盘「做过」看已经写好的合盘报告,加上旧的合盘列表里有没有记录。生成中或失败不算。新列表最多看三页、每页 10 条。旧列表只看返回的最多 10 条;只要这 10 条里有一条,就算做过。
|
||||
- 个人报告「做过」先看本人,没有再按别人逐个看,每人最多三页。更早的一份可能暂时没被数到,这时这一行先不打勾,也不会把账户当成已经做完。
|
||||
- 合盘或报告的数量读失败时,这一行先显示为没做,胶囊仍在,但不会出现「都走过一遍了」。
|
||||
- 跟进提示只在这一次回答从生成中变成写完、并且这是第一条正式回答时出现。打开页面时历史上已经有回答,不补这一条。寒暄不算。
|
||||
- 星历提示要等第三次今日星语之后的下一次打开侧栏。当时侧栏已经开着,要先关上再打开。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 没有登录态,也没有用浏览器走完清单和提示。不能写成「已经在真机上看过」。可照做的步骤在 `docs/testing/onboarding-journey-20261022.md` 的「启程清单」和「一次性提示」。手机 375 宽不横向滚动,由页面 `overflow: clip`、顶栏 `minmax(0, 1fr)` 和清单宽度 `min(22rem, calc(100vw - 2rem))` 约束,测试锁住这些样式,没有在浏览器里量。
|
||||
- 没有 Docker 上的数据库测试。这一张不改数据库访问层。
|
||||
|
||||
## 记录
|
||||
|
||||
- BUG-1432,状态 `enhancement`。不是缺陷。
|
||||
- `CHANGELOG.md`、`frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CONTEXT.md`、`docs/testing/onboarding-journey-20261022.md`。
|
||||
- 动效留给后面的单,这一张没有写 650 毫秒、勾飞进环、星点或闪烁。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 新用户启程 · 真机清单(2026-10-22)
|
||||
|
||||
后面的 issue 往这份清单末尾追加。数据层(issue #95)和建档、起步三问(issue #96)按下面的条目验收。启程清单、一次性提示和动效还没做,不要在这一轮里验收它们。
|
||||
后面的 issue 往这份清单末尾追加。数据层(issue #95)、建档和起步三问(issue #96)、启程清单和一次性提示(issue #97)按下面的条目验收。动效还没做,不要在这一轮里验收它。
|
||||
|
||||
用新注册的邮箱。不要把真实出生资料写进记录。
|
||||
|
||||
@@ -38,7 +38,31 @@
|
||||
3. 回到空白首页(新开一条空对话),三个问题不再出现。
|
||||
4. 另开一个还没提问的账户。点第三个问题:输入框亮一下并且可以打字,不会自动把问题发出去。
|
||||
|
||||
## 启程清单
|
||||
|
||||
用建完档的账户。出生日期用虚构日子。合盘和个人报告都开着时,总数是 7;关掉一项变成 6,都关掉变成 5。价格跟当时的计价表,不要写成写死的数。现在合盘是 19 点。
|
||||
|
||||
1. 新账户建完档、还没正式提问。首页右上角是「启程 1/7」。点开后,本人路径按这个顺序:排好自己的盘(已勾,没有「去做」)、问第一个问题、打开今日星语、看看自己的星盘、加一位家人或朋友、和 TA 合一次盘 · N 点、生成个人报告。报告有价格才出现「· N 点」。
|
||||
2. 另开一个只帮别人看、自己资料还没齐的账户。句子换成:排好{名字}的盘、问第一个问题、打开今日星语、看看星盘、加上你自己、和{名字}合一次盘 · N 点、生成个人报告。今日星语的「去做」去星盘档案。再加上第二位别人,不算做完「加上你自己」。
|
||||
3. 本人和别人的资料都齐时,仍用本人那一套句子。
|
||||
4. 只寒暄,不算问过。只有自己一个人,不算加过家人或朋友。
|
||||
5. 老账户如果只差看星盘和今日星语,胶囊是「启程 5/7」(七项都开着时)。
|
||||
6. 进站时七项已经都做过的账户,胶囊不出现,也不出现「都走过一遍了」。
|
||||
7. 在这一次使用里做完最后一项:清单再打开一次。标题是「都走过一遍了」,正文是「以后有新东西,我会在用到的时候再告诉你。」关掉之后胶囊消失。
|
||||
8. 点「先不看了」,刷新后胶囊不再出现。
|
||||
9. 手机宽 375:清单从底部拉出,句子换行,页面不横向滚动。平板和桌面是顶栏旁边的弹出层。
|
||||
10. 合盘开关关闭时没有合盘那一行。个人报告开关关闭时没有报告那一行。
|
||||
|
||||
## 一次性提示
|
||||
|
||||
1. 第一次正式回答刚写完,追问下面出现「想接着聊,点下面任一个就直接问。」寒暄不算。以前已经问过的账户,打开页面时不补这一条。
|
||||
2. 看过上一句、还没打开过星盘、侧栏开着时,星盘菜单旁出现「你的盘、大运和分盘都在这里。」侧栏「星盘」和手机上的菜单按钮有一个小圆点。打开过星盘后圆点消失。
|
||||
3. 星盘页四个页签,没看过的右上角有小点。四句分别是:本命盘那句、原始资料那句、大运那句、西洋盘那句。当前人物不是本人时,本命盘那句的「你的」换成「{名字}的」。换页签不把上一页记成看过。
|
||||
4. 档案里有两份完整资料、还没有合盘、并且合盘开着时,指着「和我合盘」说「有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。」合盘关着不出现。
|
||||
5. 打开今日星语满三次之后,下一次打开侧栏,星历旁出现「每天的天象和行运在这里,可以按日期翻。」第三次发生时侧栏已经开着,要先关上再打开才出现。
|
||||
6. 同一时刻只有一条。天空过场、兑换窗、任何弹窗或抽屉、回答还在生成时,提示不出现。这些过去之后再出现,不丢。
|
||||
7. 点「知道了」、点页面别处或按 Esc,这一条消失。刷新后不再出现。提示出现时,输入框里的光标不被抢走。
|
||||
|
||||
## 以后追加
|
||||
|
||||
- (三)启程清单、一次性提示
|
||||
- (四)动效,以及打开「减少动态效果」再走一遍
|
||||
|
||||
+11
-1
@@ -1,5 +1,15 @@
|
||||
# Jyotisha Web Design System
|
||||
|
||||
## 启程清单与一次性提示(2026-10-24)
|
||||
|
||||
首页顶栏右侧、点数按钮左边,是一枚胶囊「启程 n/N」(`.onboarding-checklist-trigger`)。它沿用顶栏按钮的 32px 高度,圆角 999px,字和图标间距 6px。左边是 18×18 的进度环,描边按已完成的项画满。视口还没分出手机或桌面时,这枚胶囊不可打开。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。
|
||||
|
||||
七项在这一次使用里刚满时,抽屉或弹出层再打开一次:标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」关掉后胶囊消失。不另做星点。进站时已经满的账户,胶囊和这一屏都不出现。
|
||||
|
||||
一次性提示用现有弹出层,宽 `min(18rem, calc(100vw - 2rem))`,不抢焦点。侧栏两项从锚点右侧伸出,其余从上方伸出,间距 8px。按钮是「知道了」。同一时刻最多一条。天空过场、兑换窗、任何弹窗或抽屉、启程清单正打开、回答还在生成时,提示不出;挡着的那一条等条件过去再出。侧栏「星盘」、手机菜单和星盘四个页签上的小圆点是 8px,贴在右上角 4px,不是字符。
|
||||
|
||||
不新增动效时长。弹出层和抽屉仍走现有的 120ms / 180ms。
|
||||
|
||||
## 婚恋合盘报告(2026-10-19)
|
||||
|
||||
选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。
|
||||
@@ -949,7 +959,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
|
||||
|
||||
**今日星语改为固定句库,并随次级页预取(2026-09-30,BUG-1109 / BUG-1110)。** 首页问候下面那句不再由模型写:`lib/daily-starlanguage-library.ts` 按「今日月亮落在本命第几宫(1–12,只在出生时间已确认时用)× 月相(新月前后 / 渐盈 / 满月前后 / 渐亏,按平均朔望月推算,误差约 ±1 天)× 按日期轮换的变体」拼出 trend,action / caution 按宫各两条交替;没有宫位时按月亮所在星座写一句,再没有就用原来的证据句。服务端不再在后台让模型改写并覆盖缓存,所以同一天同一个人看到的永远是同一句。次级页空闲时的预取(BUG-1104)同时取本人今天这句,写进同一个暖快照槽位与本地存储;从星盘等页点「新建对话」进首页,首帧就是真句子,不再先显示「今天的星语还没写出来。」再换掉。他人档案作为当前人物时暂不预取(其资料在人物目录里),首帧仍按原规则。
|
||||
|
||||
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),内容到达后静默替换。登录后的空首页是问候语、今日星语和输入框,没有主题卡,也没有生时校正卡(2026-10-07,BUG-1247)。本人还没有出生资料时,今日星语的按钮和问候下那一行都不出现(2026-10-24)。建档是四步:称呼、看谁的盘、出生日期与时间、出生地,进度条四格。选「帮别人看」时出生资料记在那一位名下,本人可以后补。首问还没用掉、并且当前是空白首页时,问候和输入框之间有一行引导、一枚「这一次不扣点」和三个起步问题;帮别人看再加一枚「正在看:名字 · 关系」。用掉首问或离开空白首页后,这三问不再出现。已经建完档、还没点过「先不看了」、并且不是进站时就已经七项都做过时,顶栏右侧有「启程 n/N」。尺寸、抽屉和提示互斥见文首「启程清单与一次性提示」。过场结束仍不弹浮层。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
### 首屏死屏兜底
|
||||
|
||||
|
||||
@@ -23,6 +23,31 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
**起步三问例外(2026-10-24)。** 只在已经建完档、首问还没用掉、并且当前是空白首页时,问候下面可以多一行「盘排好了。先问一个问题吧。」(帮别人看则是「{名字}的盘排好了。先问一个关于 TA 的问题吧。」),再加一枚「这一次不扣点」和三个起步问题。帮别人看时再加一枚「正在看:{名字} · {关系}」。这三问消失之后,首页回到上面这一行的规则。
|
||||
|
||||
**启程清单与一次性提示(2026-10-24)。** 顶栏胶囊只写「启程 n/N」。清单里的句子只用下面这些,收费项的价格从计价表读,没有价格就不写「· N 点」:
|
||||
|
||||
- 排好自己的盘 / 排好{名字}的盘
|
||||
- 问第一个问题
|
||||
- 打开今日星语
|
||||
- 看看自己的星盘 / 看看星盘
|
||||
- 加一位家人或朋友 / 加上你自己
|
||||
- 和 TA 合一次盘 / 和{名字}合一次盘
|
||||
- 生成个人报告
|
||||
- 去做
|
||||
- 先不看了
|
||||
- 都走过一遍了
|
||||
- 以后有新东西,我会在用到的时候再告诉你。
|
||||
|
||||
一次只出一条提示,按钮只写「知道了」:
|
||||
|
||||
- 想接着聊,点下面任一个就直接问。
|
||||
- 你的盘、大运和分盘都在这里。
|
||||
- 这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。当前人物不是本人时,这句里的「你的」改成「{名字}的」。
|
||||
- 排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。
|
||||
- 一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。
|
||||
- 同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。
|
||||
- 有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。
|
||||
- 每天的天象和行运在这里,可以按日期翻。
|
||||
|
||||
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。本人没有出生资料时,这一行和「今日星语」入口都不出现。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
|
||||
|
||||
## 婚恋合盘报告(2026-10-19)
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { AccountDeletionGate } from "@/components/account-deletion-gate";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { OnboardingHintLayer } from "@/components/onboarding-hint-layer";
|
||||
import { LegalConsentGate } from "@/components/legal/legal-consent-gate";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { OnboardingJourneyProvider } from "@/lib/onboarding-journey";
|
||||
import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list";
|
||||
import "../site-styles";
|
||||
|
||||
@@ -17,6 +19,7 @@ function AppShell({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
<OnboardingJourneyProvider>
|
||||
<main className="chat-app">
|
||||
<AppSidebar
|
||||
sessions={sessions}
|
||||
@@ -36,6 +39,8 @@ function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarInset>
|
||||
<LegalConsentGate signedIn={Boolean(account) && !list.signedOut} />
|
||||
</main>
|
||||
<OnboardingHintLayer />
|
||||
</OnboardingJourneyProvider>
|
||||
<AccountDeletionGate signedIn={Boolean(account)} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -40,9 +40,13 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { bindCurrentSubjectAccount, peekSubjectCatalog, readCurrentSubjectId, subscribeSubjectCatalog, useCurrentSubject, useCurrentSubjectProfile } from "@/lib/current-subject";
|
||||
import { setOnboardingFinishedListener } from "@/lib/onboarding-intake";
|
||||
import { notifyOnboardingDailyOpened } from "@/lib/onboarding-signals";
|
||||
import { personRelationLabel } from "@/lib/person-relation";
|
||||
import { readStarterSignMap, readStarterSigns, subscribeStarterSigns } from "@/lib/starter-questions";
|
||||
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
|
||||
import { OnboardingChecklistEntry } from "@/components/onboarding-checklist";
|
||||
import { OnboardingChartDot } from "@/components/onboarding-dot";
|
||||
import { OnboardingHomeBridge } from "@/components/onboarding-home-bridge";
|
||||
import { AccountDialogDeepLink, SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import { StarterQuestions } from "@/components/starter-questions";
|
||||
@@ -1010,6 +1014,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
function startDailyStarlanguageConsultation() {
|
||||
notifyOnboardingDailyOpened();
|
||||
void startSuggestedConsultation(
|
||||
dailyStarlanguageQuestion,
|
||||
"timing",
|
||||
@@ -1185,6 +1190,15 @@ export default function Home() {
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
setDailyStarlanguage={setDailyStarlanguage}
|
||||
/>
|
||||
<OnboardingHomeBridge
|
||||
inputRef={composerInput}
|
||||
startDaily={startDailyStarlanguageConsultation}
|
||||
streaming={consultationPhase === "streaming" || consultationPhase === "recovering"}
|
||||
streamingSessionId={activeSessionId}
|
||||
skyOpen={birthSkyReveal.cover != null}
|
||||
dialogOpen={activeAccountDialog !== null || onboardingPaywallOpen || Boolean(pendingSessionDeletion)}
|
||||
profile={profile}
|
||||
/>
|
||||
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
|
||||
<AccountDialogDeepLink ready={hydrated && Boolean(account)} open={openAccountDialog} />
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
@@ -1205,7 +1219,10 @@ export default function Home() {
|
||||
</div>
|
||||
) : null}
|
||||
<header className="chat-header">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<span className="onboarding-menu-anchor">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<OnboardingChartDot />
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{activeSession?.title || "新对话"}</strong>
|
||||
{activeSession?.sessionType === "consultation" || activeSession?.sessionType === "synastry_report" ? (
|
||||
@@ -1223,6 +1240,7 @@ export default function Home() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="chat-header-actions">
|
||||
<OnboardingChecklistEntry />
|
||||
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
|
||||
<button className="credit-button" type="button" onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<Coins className="credit-icon" aria-hidden="true" />
|
||||
|
||||
@@ -39,6 +39,7 @@ function toClientProfile(row: Record<string, unknown>) {
|
||||
role: "other",
|
||||
profile: libraryProfileFromSubjectRow(row),
|
||||
updated_at: row.updated_at,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
|
||||
import { isPersonalReportFeatureEnabled } from "@/lib/personal-report-entitlement";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { consumeRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
@@ -28,16 +29,34 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
const compatibility = await isProductEnabled("compatibility");
|
||||
const reportCenter = (await isProductEnabled("report_center")) && isPersonalReportFeatureEnabled(process.env);
|
||||
let synastryCreditCost: number | null = null;
|
||||
if (compatibility) {
|
||||
let reportCreditCost: number | null = null;
|
||||
if (compatibility || reportCenter) {
|
||||
try {
|
||||
const catalog = await loadLanguageModelCatalog();
|
||||
if (!catalog.defaultModelId) throw new FeaturePricingError("feature_pricing_invalid_response");
|
||||
const pricing = await resolveFeaturePricing(createAdminSupabaseClient(), "synastry.full", catalog.defaultModelId);
|
||||
synastryCreditCost = pricing.credit_cost;
|
||||
const client = createAdminSupabaseClient();
|
||||
if (compatibility) {
|
||||
try {
|
||||
synastryCreditCost = (await resolveFeaturePricing(client, "synastry.full", catalog.defaultModelId)).credit_cost;
|
||||
} catch (error) {
|
||||
if (!(error instanceof FeaturePricingError)) synastryCreditCost = null;
|
||||
}
|
||||
}
|
||||
if (reportCenter) {
|
||||
try {
|
||||
reportCreditCost = (await resolveFeaturePricing(client, "report.full", catalog.defaultModelId)).credit_cost;
|
||||
} catch (error) {
|
||||
if (!(error instanceof FeaturePricingError)) reportCreditCost = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof FeaturePricingError)) synastryCreditCost = null;
|
||||
if (!(error instanceof FeaturePricingError)) {
|
||||
synastryCreditCost = null;
|
||||
reportCreditCost = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ compatibility, synastryCreditCost });
|
||||
return NextResponse.json({ compatibility, synastryCreditCost, reportCenter, reportCreditCost });
|
||||
}
|
||||
|
||||
@@ -1077,6 +1077,60 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
content: "";
|
||||
}
|
||||
.chat-header-actions button > svg { width: 14px; height: 14px; }
|
||||
.onboarding-menu-anchor { position: relative; display: inline-flex; }
|
||||
.chat-header-actions button.onboarding-checklist-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-canvas-muted);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.chat-header-actions button.onboarding-checklist-trigger > svg { width: 18px; height: 18px; }
|
||||
.onboarding-checklist-popup,
|
||||
.onboarding-checklist-panel,
|
||||
.onboarding-checklist-sheet {
|
||||
width: min(22rem, calc(100vw - 2rem));
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.onboarding-checklist-sheet { width: 100%; }
|
||||
.onboarding-checklist-panel { display: grid; gap: 12px; padding: 4px 4px 8px; }
|
||||
.onboarding-checklist-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
.onboarding-checklist-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.onboarding-checklist-copy { min-width: 0; overflow-wrap: anywhere; }
|
||||
.onboarding-checklist-title { margin: 0; font-size: var(--type-body-md); font-weight: 500; }
|
||||
.onboarding-checklist-body { margin: 0; color: var(--color-ink-secondary); overflow-wrap: anywhere; }
|
||||
.onboarding-checklist-mark { width: 1rem; height: 1rem; }
|
||||
.onboarding-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-action);
|
||||
pointer-events: none;
|
||||
}
|
||||
.onboarding-menu-anchor .onboarding-dot,
|
||||
.chart-page-tab .onboarding-dot,
|
||||
[data-sidebar="menu-button"] .onboarding-dot {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
.chart-page-tab,
|
||||
[data-sidebar="menu-button"].report-nav-button { position: relative; }
|
||||
.onboarding-hint {
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.onboarding-hint p { margin: 0; }
|
||||
/* Header chips share one size. `.chat-header-actions button` is 32px and
|
||||
more specific than `.credit-button`, which is why the credit pill looked
|
||||
shorter than 「当前盘面」(BUG-921). */
|
||||
|
||||
@@ -22,8 +22,10 @@ import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { OnboardingDot } from "@/components/onboarding-dot";
|
||||
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
|
||||
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
|
||||
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
|
||||
import { prefetchPaymentPackages } from "@/lib/payment-packages-cache";
|
||||
@@ -125,6 +127,7 @@ export function AppSidebar({
|
||||
controls,
|
||||
}: AppSidebarProps) {
|
||||
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
|
||||
const showChartDot = useOnboardingJourneyOptional()?.showChartDot ?? false;
|
||||
/* Null outside an app-router context (and in unit tests that mount this
|
||||
component directly), even though the type says string. */
|
||||
const pathname = usePathname() ?? "";
|
||||
@@ -305,9 +308,12 @@ export function AppSidebar({
|
||||
onPointerEnter={() => prefetchSecondaryPage(href)}
|
||||
onPointerDown={() => prefetchSecondaryPage(href)}
|
||||
onClick={leaveChat}
|
||||
{...(href === "/chart" ? { "data-onboarding-anchor": "sidebar-chart" } : {})}
|
||||
{...(href === "/ephemeris" ? { "data-onboarding-anchor": "ephemeris" } : {})}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{label}</span> : null}
|
||||
{href === "/chart" && showChartDot ? <OnboardingDot /> : null}
|
||||
</SidebarMenuLink>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
|
||||
import { OnboardingDot } from "@/components/onboarding-dot";
|
||||
|
||||
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
|
||||
import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
} from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels";
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
import { CHART_SETTINGS_HREF } from "@/lib/settings-url";
|
||||
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
|
||||
import { ChartPageSkeleton } from "./chart-page-skeleton";
|
||||
@@ -93,10 +96,31 @@ export function ChartPageView({
|
||||
onBirthSaved?: () => Promise<void> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
const setJourneyTab = journey?.setChartTab;
|
||||
const markChartVisited = journey?.markChartVisited;
|
||||
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
|
||||
const [editingBirth, setEditingBirth] = useState(false);
|
||||
const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara");
|
||||
|
||||
useEffect(() => {
|
||||
if (!setJourneyTab) return;
|
||||
const name = view?.status === "ok" && view.subjectId && view.subjectId !== "self"
|
||||
? view.profile.name.trim() || null
|
||||
: null;
|
||||
const tabId = view?.status === "ok" ? tab : null;
|
||||
queueMicrotask(() => {
|
||||
if (view?.status === "ok") markChartVisited?.();
|
||||
setJourneyTab(tabId, name);
|
||||
});
|
||||
}, [markChartVisited, setJourneyTab, tab, view]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
queueMicrotask(() => setJourneyTab?.(null, null));
|
||||
};
|
||||
}, [setJourneyTab]);
|
||||
|
||||
function selectTab(id: ChartViewTabId) {
|
||||
setTab(id);
|
||||
if (id === "dasha") onNeedLayer?.("chara");
|
||||
@@ -178,10 +202,11 @@ export function ChartPageView({
|
||||
onClick={() => selectTab(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
{journey?.tabHasDot(item.id) ? <OnboardingDot /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<section className="chart-page-panel" role="tabpanel">
|
||||
<section className="chart-page-panel" role="tabpanel" data-onboarding-anchor="chart-tab">
|
||||
{view == null ? <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingChart} /> : <>
|
||||
{tab === "vedic" ? (
|
||||
<ChartVedicTab
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ConversationFollowUps({
|
||||
}>) {
|
||||
if (questions.length === 0) return null;
|
||||
return (
|
||||
<div className="conversation-follow-ups" aria-label="可以继续问">
|
||||
<div className="conversation-follow-ups" aria-label="可以继续问" data-onboarding-anchor="follow-up">
|
||||
{questions.map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { OnboardingProgressRing } from "@/components/onboarding-progress-ring";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import { Popover, PopoverContent, PopoverDescription, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
CHECKLIST_CELEBRATION_BODY,
|
||||
CHECKLIST_CELEBRATION_TITLE,
|
||||
CHECKLIST_DISMISS_LABEL,
|
||||
type ChecklistItem,
|
||||
} from "@/lib/onboarding-checklist";
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
|
||||
function ChecklistRows({ items, onGo }: { readonly items: readonly ChecklistItem[]; readonly onGo: (item: ChecklistItem) => void }) {
|
||||
return (
|
||||
<ul className="onboarding-checklist-rows">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="onboarding-checklist-row" data-done={item.done ? "true" : "false"}>
|
||||
{item.done ? <Check aria-hidden="true" /> : <span className="onboarding-checklist-mark" aria-hidden="true" />}
|
||||
<span className="onboarding-checklist-copy">{item.label}</span>
|
||||
{!item.done && item.go ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onGo(item)}>去做</Button>
|
||||
) : <span />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistBody({
|
||||
celebration,
|
||||
items,
|
||||
label,
|
||||
onDismiss,
|
||||
onGo,
|
||||
}: {
|
||||
readonly celebration: boolean;
|
||||
readonly items: readonly ChecklistItem[];
|
||||
readonly label: string;
|
||||
readonly onDismiss: () => void;
|
||||
readonly onGo: (item: ChecklistItem) => void;
|
||||
}) {
|
||||
if (celebration) {
|
||||
return (
|
||||
<div className="onboarding-checklist-panel">
|
||||
<h2 className="onboarding-checklist-title">{CHECKLIST_CELEBRATION_TITLE}</h2>
|
||||
<p className="onboarding-checklist-body">{CHECKLIST_CELEBRATION_BODY}</p>
|
||||
<Button variant="outline" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="onboarding-checklist-panel">
|
||||
<h2 className="sr-only">{label}</h2>
|
||||
<ChecklistRows items={items} onGo={onGo} />
|
||||
<Button variant="ghost" onClick={onDismiss}>{CHECKLIST_DISMISS_LABEL}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistFace({ label, done, total }: { readonly label: string; readonly done: number; readonly total: number }) {
|
||||
return (
|
||||
<>
|
||||
<OnboardingProgressRing done={done} total={total} />
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingChecklistEntry() {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
const { viewport, ready } = useSidebar();
|
||||
const [open, setOpen] = useState(false);
|
||||
const celebrated = useRef(false);
|
||||
const mobile = ready && viewport === "mobile";
|
||||
|
||||
useEffect(() => {
|
||||
if (!journey?.celebration || !ready || celebrated.current) return;
|
||||
celebrated.current = true;
|
||||
queueMicrotask(() => {
|
||||
setOpen(true);
|
||||
journey.setChecklistOpen(true);
|
||||
});
|
||||
}, [journey, ready]);
|
||||
|
||||
if (!journey?.showChecklist) return null;
|
||||
const done = journey.items.filter((item) => item.done).length;
|
||||
const total = journey.items.length;
|
||||
const face = <ChecklistFace label={journey.label} done={done} total={total} />;
|
||||
|
||||
function changeOpen(next: boolean) {
|
||||
setOpen(next);
|
||||
journey?.setChecklistOpen(next);
|
||||
if (!next && journey?.celebration) journey.dismissChecklist();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
setOpen(false);
|
||||
journey?.setChecklistOpen(false);
|
||||
journey?.dismissChecklist();
|
||||
}
|
||||
|
||||
function onGo(item: ChecklistItem) {
|
||||
setOpen(false);
|
||||
journey?.setChecklistOpen(false);
|
||||
journey?.go(item);
|
||||
}
|
||||
|
||||
const body = (
|
||||
<ChecklistBody
|
||||
celebration={journey.celebration}
|
||||
items={journey.items}
|
||||
label={journey.label}
|
||||
onDismiss={dismiss}
|
||||
onGo={onGo}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<Button variant="ghost" className="onboarding-checklist-trigger" disabled>
|
||||
{face}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={changeOpen} swipeDirection="down" showSwipeHandle>
|
||||
<DrawerTrigger className="onboarding-checklist-trigger">{face}</DrawerTrigger>
|
||||
<DrawerContent className="onboarding-checklist-sheet">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>{journey.celebration ? CHECKLIST_CELEBRATION_TITLE : journey.label}</DrawerTitle>
|
||||
{journey.celebration ? <DrawerDescription>{CHECKLIST_CELEBRATION_BODY}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<DrawerFooter />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={changeOpen}>
|
||||
<PopoverTrigger className="onboarding-checklist-trigger">{face}</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" sideOffset={8} className="onboarding-checklist-popup">
|
||||
{journey.celebration ? (
|
||||
<PopoverDescription className="sr-only">{CHECKLIST_CELEBRATION_BODY}</PopoverDescription>
|
||||
) : null}
|
||||
{body}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
|
||||
export function OnboardingDot() {
|
||||
return <span className="onboarding-dot" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function OnboardingChartDot() {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
if (!journey?.showChartDot) return null;
|
||||
return <OnboardingDot />;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent } from "@/components/ui/popover";
|
||||
import { HINT_ANCHORS, HINT_DISMISS_LABEL } from "@/lib/onboarding-hints";
|
||||
import { useOnboardingJourney } from "@/lib/onboarding-journey";
|
||||
import type { OnboardingHintId } from "@/lib/onboarding-state";
|
||||
|
||||
const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"] as const;
|
||||
|
||||
function userDismissReason(reason: string): boolean {
|
||||
return reason === "escape-key" || reason === "outside-press" || reason === "close-press";
|
||||
}
|
||||
|
||||
export function OnboardingHintLayer() {
|
||||
const journey = useOnboardingJourney();
|
||||
const hint = journey.activeHint;
|
||||
const setLive = journey.setLive;
|
||||
const dismissHint = journey.dismissHint;
|
||||
const [anchor, setAnchor] = useState<Element | null>(null);
|
||||
const dismissed = useRef<string | null>(null);
|
||||
const dismiss = useCallback((id: OnboardingHintId) => {
|
||||
if (dismissed.current === id) return;
|
||||
dismissed.current = id;
|
||||
void dismissHint(id).then((ok) => {
|
||||
if (!ok && dismissed.current === id) dismissed.current = null;
|
||||
});
|
||||
}, [dismissHint]);
|
||||
|
||||
useEffect(() => {
|
||||
const scan = () => {
|
||||
const anchors: Record<string, boolean> = {};
|
||||
for (const name of ANCHOR_NAMES) {
|
||||
anchors[name] = document.querySelector(`[data-onboarding-anchor="${name}"]`) != null;
|
||||
}
|
||||
let foreignDialog = false;
|
||||
document.querySelectorAll('[role="dialog"],[role="alertdialog"],[data-slot="drawer-popup"]').forEach((node) => {
|
||||
if (!(node instanceof Element) || node.closest("[data-onboarding-hint]")) return;
|
||||
foreignDialog = true;
|
||||
});
|
||||
setLive({ anchors, foreignDialog });
|
||||
const next = hint ? document.querySelector(`[data-onboarding-anchor="${HINT_ANCHORS[hint]}"]`) : null;
|
||||
setAnchor((current) => current === next ? current : next);
|
||||
};
|
||||
const timer = window.setTimeout(scan, 0);
|
||||
const observer = new MutationObserver(scan);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["role", "data-slot", "hidden", "open", "data-onboarding-anchor", "data-onboarding-hint"] });
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hint, setLive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hint || !anchor) return;
|
||||
const onPointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (document.querySelector("[data-onboarding-hint]")?.contains(target)) return;
|
||||
dismiss(hint);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") dismiss(hint);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointer, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointer, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [hint, anchor, dismiss]);
|
||||
|
||||
if (!hint || !anchor) return null;
|
||||
const side = HINT_ANCHORS[hint] === "sidebar-chart" || HINT_ANCHORS[hint] === "ephemeris" ? "right" : "top";
|
||||
return (
|
||||
<Popover
|
||||
open
|
||||
modal={false}
|
||||
onOpenChange={(next, details) => {
|
||||
if (!next && userDismissReason(details.reason)) dismiss(hint);
|
||||
}}
|
||||
>
|
||||
<PopoverContent
|
||||
anchor={anchor}
|
||||
initialFocus={false}
|
||||
finalFocus={false}
|
||||
side={side}
|
||||
sideOffset={8}
|
||||
className="onboarding-hint"
|
||||
data-onboarding-hint=""
|
||||
>
|
||||
<p>{journey.hintText}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => dismiss(hint)}>{HINT_DISMISS_LABEL}</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
|
||||
import { selfChartComplete } from "@/lib/home-profile";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
|
||||
export function OnboardingHomeBridge({
|
||||
inputRef,
|
||||
startDaily,
|
||||
streaming,
|
||||
streamingSessionId,
|
||||
skyOpen,
|
||||
dialogOpen,
|
||||
profile,
|
||||
}: {
|
||||
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
|
||||
readonly startDaily: () => void;
|
||||
readonly streaming: boolean;
|
||||
readonly streamingSessionId: string | null;
|
||||
readonly skyOpen: boolean;
|
||||
readonly dialogOpen: boolean;
|
||||
readonly profile: Profile;
|
||||
}) {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
const startDailyRef = useRef(startDaily);
|
||||
const consumedQuery = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
startDailyRef.current = startDaily;
|
||||
}, [startDaily]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!journey) return;
|
||||
journey.registerHome({
|
||||
focusComposer() {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
},
|
||||
startDaily() {
|
||||
startDailyRef.current();
|
||||
},
|
||||
});
|
||||
return () => journey.registerHome(null);
|
||||
}, [journey, inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
journey?.setLive({
|
||||
streaming,
|
||||
streamingSessionId: streaming ? streamingSessionId : null,
|
||||
skyOpen,
|
||||
dialogOpen,
|
||||
selfProfile: profile,
|
||||
});
|
||||
}, [journey, streaming, streamingSessionId, skyOpen, dialogOpen, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!journey || consumedQuery.current) return;
|
||||
const url = new URL(window.location.href);
|
||||
const compose = url.searchParams.get("compose") === "1";
|
||||
const daily = url.searchParams.get("daily") === "1";
|
||||
if (!compose && !daily) return;
|
||||
consumedQuery.current = true;
|
||||
url.searchParams.delete("compose");
|
||||
url.searchParams.delete("daily");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
if (compose) inputRef.current?.focus({ preventScroll: true });
|
||||
if (!daily) return;
|
||||
if (!selfChartComplete(profile)) {
|
||||
window.location.assign("/people");
|
||||
return;
|
||||
}
|
||||
startDailyRef.current();
|
||||
}, [journey, profile, inputRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function OnboardingProgressRing({ done, total }: { readonly done: number; readonly total: number }) {
|
||||
const radius = 7;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = total > 0 ? Math.min(1, Math.max(0, done / total)) : 0;
|
||||
return (
|
||||
<svg className="onboarding-progress-ring" viewBox="0 0 18 18" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r={radius} fill="none" stroke="currentColor" strokeOpacity="0.28" strokeWidth="2" />
|
||||
<circle
|
||||
cx="9"
|
||||
cy="9"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={circumference * (1 - ratio)}
|
||||
transform="rotate(-90 9 9)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -106,10 +106,35 @@ export function PeoplePage() {
|
||||
});
|
||||
accountIdRef.current = account.user.id;
|
||||
bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id));
|
||||
setPeople([
|
||||
const nextPeople: PersonRow[] = [
|
||||
{ id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) },
|
||||
...others,
|
||||
]);
|
||||
];
|
||||
setPeople(nextPeople);
|
||||
const url = new URL(window.location.href);
|
||||
const add = url.searchParams.get("add") === "1";
|
||||
const openSelf = url.searchParams.get("self") === "1";
|
||||
if (add || openSelf) {
|
||||
url.searchParams.delete("add");
|
||||
url.searchParams.delete("self");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
if (openSelf) {
|
||||
const selfRow = nextPeople.find((person) => person.role === "self");
|
||||
setSelectedId("self");
|
||||
setDraft(selfRow?.profile ?? emptyProfile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setView("detail");
|
||||
} else if (nextPeople.filter((person) => person.role === "other").length >= MAX_OTHER_SUBJECTS) {
|
||||
notifyInfo(LIMIT_COPY);
|
||||
} else {
|
||||
setDraft(emptyProfile);
|
||||
setEditing(true);
|
||||
setPendingDelete(null);
|
||||
setSelectedId("");
|
||||
setView("detail");
|
||||
}
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("failed");
|
||||
@@ -490,7 +515,7 @@ export function PeopleArchiveView({
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
people.some((person) => person.role === "self" && !person.incomplete)
|
||||
? <button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
|
||||
? <button type="button" className="people-archive-secondary" data-onboarding-anchor="syn-ready" onClick={() => window.location.assign(`/synastry?with=${selected.id}`)}>和我合盘</button>
|
||||
: <p className="people-archive-synastry-later">加上你自己后可合盘</p>
|
||||
) : null}
|
||||
{/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingChartDot } from "@/components/onboarding-dot";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
/**
|
||||
@@ -35,7 +36,10 @@ export function SecondaryHeader({ title, actions, note, besideTitle, reserveNote
|
||||
const showNote = Boolean(note) || reserveNote;
|
||||
return (
|
||||
<header className="chat-header has-title">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<span className="onboarding-menu-anchor">
|
||||
<SidebarTrigger placement="inset" />
|
||||
<OnboardingChartDot />
|
||||
</span>
|
||||
<div className="chat-header-title">
|
||||
<strong>{title}</strong>
|
||||
{besideTitle}
|
||||
|
||||
@@ -19,15 +19,17 @@ function PopoverContent({
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
anchor,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
anchor={anchor}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
|
||||
|
||||
export const CHECKLIST_ITEM_IDS = ["self", "ask", "daily", "chart", "person", "syn", "report"] as const;
|
||||
|
||||
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
|
||||
|
||||
export type ChecklistPath = "self" | "other";
|
||||
|
||||
export type ChecklistPerson = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly name: string;
|
||||
readonly complete: boolean;
|
||||
readonly createdAt: number | null;
|
||||
};
|
||||
|
||||
export type ChecklistFlags = {
|
||||
readonly compatibility: boolean | null;
|
||||
readonly reportCenter: boolean | null;
|
||||
readonly synastryCreditCost: number | null;
|
||||
readonly reportCreditCost: number | null;
|
||||
};
|
||||
|
||||
export type ChecklistGo =
|
||||
| { readonly kind: "compose" }
|
||||
| { readonly kind: "daily" }
|
||||
| { readonly kind: "href"; readonly href: string };
|
||||
|
||||
export type ChecklistItem = {
|
||||
readonly id: ChecklistItemId;
|
||||
readonly label: string;
|
||||
readonly done: boolean;
|
||||
readonly go: ChecklistGo | null;
|
||||
};
|
||||
|
||||
export type ChecklistFacts = {
|
||||
readonly people: readonly ChecklistPerson[];
|
||||
readonly profileComplete: boolean;
|
||||
readonly hasNonSmalltalkAnswer: boolean;
|
||||
readonly dailyCount: number;
|
||||
readonly chartVisited: boolean;
|
||||
readonly synastryReportCount: number;
|
||||
readonly personalReportCount: number;
|
||||
readonly flags: ChecklistFlags;
|
||||
};
|
||||
|
||||
export type AnswerTurn = {
|
||||
readonly role: string;
|
||||
readonly text?: string;
|
||||
readonly responseKind?: string;
|
||||
readonly rectificationReturn?: unknown;
|
||||
};
|
||||
|
||||
export type DailySession = {
|
||||
readonly id?: string;
|
||||
readonly title?: string;
|
||||
readonly messagesHydrated?: boolean;
|
||||
readonly messages?: readonly AnswerTurn[];
|
||||
};
|
||||
|
||||
export const CHECKLIST_CELEBRATION_TITLE = "都走过一遍了";
|
||||
export const CHECKLIST_CELEBRATION_BODY = "以后有新东西,我会在用到的时候再告诉你。";
|
||||
export const CHECKLIST_DISMISS_LABEL = "先不看了";
|
||||
|
||||
const SELF_ORDER: readonly ChecklistItemId[] = ["self", "ask", "daily", "chart", "person", "syn", "report"];
|
||||
const OTHER_ORDER: readonly ChecklistItemId[] = ["self", "ask", "person", "daily", "chart", "syn", "report"];
|
||||
|
||||
export function isCompleteNonSmalltalkAnswer(turn: AnswerTurn): boolean {
|
||||
if (turn.rectificationReturn) return false;
|
||||
return turn.role === "assistant"
|
||||
&& typeof turn.text === "string"
|
||||
&& turn.text.trim().length > 0
|
||||
&& turn.responseKind !== "smalltalk";
|
||||
}
|
||||
|
||||
export function sessionCountsAsDaily(session: DailySession): boolean {
|
||||
const titled = Boolean(session.title?.includes("今日节奏"));
|
||||
const asked = (session.messages ?? []).some((message) => (
|
||||
message.role === "user" && isGeneralDailyFortuneQuestion(message.text ?? "")
|
||||
));
|
||||
if (!titled && !asked) return false;
|
||||
if (session.messagesHydrated === false) return titled;
|
||||
return (session.messages ?? []).some(isCompleteNonSmalltalkAnswer);
|
||||
}
|
||||
|
||||
export function countDailySessions(sessions: readonly DailySession[]): number {
|
||||
return sessions.filter(sessionCountsAsDaily).length;
|
||||
}
|
||||
|
||||
/** A still-streaming reply does not count. `firstConsultFree === false` counts only after the stream settles. */
|
||||
export function hasCompleteNonSmalltalkAnswer(input: {
|
||||
readonly sessions: readonly DailySession[];
|
||||
readonly streaming?: boolean;
|
||||
readonly streamingSessionId?: string | null;
|
||||
readonly firstConsultFree?: boolean | null;
|
||||
}): boolean {
|
||||
const streaming = Boolean(input.streaming);
|
||||
const fromMessages = input.sessions.some((session) => {
|
||||
if (session.messagesHydrated === false) return false;
|
||||
let messages = session.messages ?? [];
|
||||
if (streaming && input.streamingSessionId && session.id === input.streamingSessionId) {
|
||||
const last = messages[messages.length - 1];
|
||||
if (last?.role === "assistant") messages = messages.slice(0, -1);
|
||||
}
|
||||
return messages.some(isCompleteNonSmalltalkAnswer);
|
||||
});
|
||||
if (fromMessages) return true;
|
||||
if (streaming) return false;
|
||||
return input.firstConsultFree === false;
|
||||
}
|
||||
|
||||
/** Both people complete → self copy. Help-other only when self is still incomplete. */
|
||||
export function checklistPath(people: readonly ChecklistPerson[]): ChecklistPath {
|
||||
return people.some((person) => person.role === "self" && person.complete) ? "self" : "other";
|
||||
}
|
||||
|
||||
/** Earliest filed complete other person. Blank or missing timestamps fall back to "TA". */
|
||||
export function helpedName(people: readonly ChecklistPerson[]): string {
|
||||
const others = people.filter((person) => person.role === "other" && person.complete);
|
||||
if (others.length === 0) return "TA";
|
||||
const dated = others.filter((person) => person.createdAt != null);
|
||||
const pool = dated.length > 0
|
||||
? [...dated].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
|
||||
: others;
|
||||
const name = pool[0]?.name.trim();
|
||||
return name || "TA";
|
||||
}
|
||||
|
||||
function withPrice(base: string, price: number | null): string {
|
||||
return price == null ? base : `${base} · ${price} 点`;
|
||||
}
|
||||
|
||||
function personDone(path: ChecklistPath, people: readonly ChecklistPerson[]): boolean {
|
||||
if (path === "other") return people.some((person) => person.role === "self" && person.complete);
|
||||
return people.filter((person) => person.complete).length >= 2;
|
||||
}
|
||||
|
||||
export function buildChecklist(facts: ChecklistFacts): ChecklistItem[] {
|
||||
const path = checklistPath(facts.people);
|
||||
const name = helpedName(facts.people);
|
||||
const order = path === "other" ? OTHER_ORDER : SELF_ORDER;
|
||||
const synBase = path === "self" ? "和 TA 合一次盘" : `和${name}合一次盘`;
|
||||
const items: ChecklistItem[] = [];
|
||||
for (const id of order) {
|
||||
if (id === "syn" && facts.flags.compatibility !== true) continue;
|
||||
if (id === "report" && facts.flags.reportCenter !== true) continue;
|
||||
items.push(itemFor(id, path, name, synBase, facts));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function itemFor(
|
||||
id: ChecklistItemId,
|
||||
path: ChecklistPath,
|
||||
name: string,
|
||||
synBase: string,
|
||||
facts: ChecklistFacts,
|
||||
): ChecklistItem {
|
||||
switch (id) {
|
||||
case "self":
|
||||
return {
|
||||
id,
|
||||
label: path === "self" ? "排好自己的盘" : `排好${name}的盘`,
|
||||
done: facts.profileComplete,
|
||||
go: null,
|
||||
};
|
||||
case "ask":
|
||||
return { id, label: "问第一个问题", done: facts.hasNonSmalltalkAnswer, go: { kind: "compose" } };
|
||||
case "daily":
|
||||
return {
|
||||
id,
|
||||
label: "打开今日星语",
|
||||
done: facts.dailyCount >= 1,
|
||||
go: path === "self" ? { kind: "daily" } : { kind: "href", href: "/people" },
|
||||
};
|
||||
case "chart":
|
||||
return {
|
||||
id,
|
||||
label: path === "self" ? "看看自己的星盘" : "看看星盘",
|
||||
done: facts.chartVisited,
|
||||
go: { kind: "href", href: "/chart" },
|
||||
};
|
||||
case "person":
|
||||
return {
|
||||
id,
|
||||
label: path === "self" ? "加一位家人或朋友" : "加上你自己",
|
||||
done: personDone(path, facts.people),
|
||||
go: path === "self"
|
||||
? { kind: "href", href: "/people?add=1" }
|
||||
: { kind: "href", href: "/people?self=1" },
|
||||
};
|
||||
case "syn":
|
||||
return {
|
||||
id,
|
||||
label: withPrice(synBase, facts.flags.synastryCreditCost),
|
||||
done: facts.synastryReportCount >= 1,
|
||||
go: { kind: "href", href: "/people" },
|
||||
};
|
||||
case "report":
|
||||
return {
|
||||
id,
|
||||
label: withPrice("生成个人报告", facts.flags.reportCreditCost),
|
||||
done: facts.personalReportCount >= 1,
|
||||
go: { kind: "href", href: "/reports" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function checklistProgress(items: readonly ChecklistItem[]): { done: number; total: number; label: string } {
|
||||
const total = items.length;
|
||||
const done = items.filter((item) => item.done).length;
|
||||
return { done, total, label: `启程 ${done}/${total}` };
|
||||
}
|
||||
|
||||
export function checklistAllDone(items: readonly ChecklistItem[]): boolean {
|
||||
return items.length > 0 && items.every((item) => item.done);
|
||||
}
|
||||
|
||||
export function shouldShowChecklist(input: {
|
||||
readonly profileComplete: boolean;
|
||||
readonly dismissedAt: string | null | undefined;
|
||||
readonly arrivedComplete: boolean;
|
||||
}): boolean {
|
||||
return input.profileComplete && !input.dismissedAt && !input.arrivedComplete;
|
||||
}
|
||||
|
||||
export type ArrivalPhase = "pending" | "gap" | "arrived";
|
||||
|
||||
/** First settled snapshot that is already full never celebrates. A later full snapshot does, once. */
|
||||
export function nextArrival(input: {
|
||||
readonly decided: ArrivalPhase;
|
||||
readonly canDecide: boolean;
|
||||
readonly allDone: boolean;
|
||||
}): ArrivalPhase | "celebrate" {
|
||||
if (!input.canDecide) return input.decided;
|
||||
if (input.decided === "pending") return input.allDone ? "arrived" : "gap";
|
||||
if (input.decided === "gap" && input.allDone) return "celebrate";
|
||||
return input.decided;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ONBOARDING_HINT_IDS, type OnboardingHintId } from "@/lib/onboarding-state";
|
||||
|
||||
export const HINT_COPY: Record<OnboardingHintId, string> = {
|
||||
"follow-up": "想接着聊,点下面任一个就直接问。",
|
||||
"sidebar-chart": "你的盘、大运和分盘都在这里。",
|
||||
"tab-chart": "这是你的本命盘,北印度画法。最上面的菱形是第一宫,上升就在这里,往左逆时针依次是二宫到十二宫。",
|
||||
"tab-info": "排盘用的原始资料和每颗星的位置都在这里。出生时间不准时,这一页最上面会写明误差。",
|
||||
"tab-dasha": "一段大运管几年到十几年,主色那段是你现在走的。点开任意一段,能看到里面的小运。",
|
||||
"tab-west": "同一时刻的天空,换成西洋占星的算法。两种黄道相差约 24 度,所以星座可能和印度盘不一样。",
|
||||
"syn-ready": "有了你们两个人,就能看你们俩的关系画像。价格写在按钮上。",
|
||||
"ephemeris": "每天的天象和行运在这里,可以按日期翻。",
|
||||
};
|
||||
|
||||
export const HINT_ANCHORS: Record<OnboardingHintId, string> = {
|
||||
"follow-up": "follow-up",
|
||||
"sidebar-chart": "sidebar-chart",
|
||||
"tab-chart": "chart-tab",
|
||||
"tab-info": "chart-tab",
|
||||
"tab-dasha": "chart-tab",
|
||||
"tab-west": "chart-tab",
|
||||
"syn-ready": "syn-ready",
|
||||
"ephemeris": "ephemeris",
|
||||
};
|
||||
|
||||
export const TAB_HINT_IDS = {
|
||||
vedic: "tab-chart",
|
||||
basics: "tab-info",
|
||||
dasha: "tab-dasha",
|
||||
western: "tab-west",
|
||||
} as const;
|
||||
|
||||
export type ChartHintTab = keyof typeof TAB_HINT_IDS;
|
||||
|
||||
export const HINT_DISMISS_LABEL = "知道了";
|
||||
|
||||
const HINT_ORDER = ONBOARDING_HINT_IDS;
|
||||
|
||||
export function tabChartCopy(name: string | null): string {
|
||||
const text = HINT_COPY["tab-chart"];
|
||||
const trimmed = name?.trim() ?? "";
|
||||
return trimmed ? text.replace("你的", `${trimmed}的`) : text;
|
||||
}
|
||||
|
||||
export function hintCopy(id: OnboardingHintId, chartSubjectName: string | null): string {
|
||||
return id === "tab-chart" ? tabChartCopy(chartSubjectName) : HINT_COPY[id];
|
||||
}
|
||||
|
||||
export function followUpAfterStream(input: {
|
||||
readonly wasStreaming: boolean;
|
||||
readonly streaming: boolean;
|
||||
readonly hadAnswerBefore: boolean;
|
||||
readonly hasAnswerNow: boolean;
|
||||
}): boolean {
|
||||
return input.wasStreaming && !input.streaming && !input.hadAnswerBefore && input.hasAnswerNow;
|
||||
}
|
||||
|
||||
export function sidebarChartReady(input: {
|
||||
readonly followUpSeen: boolean;
|
||||
readonly chartVisited: boolean;
|
||||
readonly sidebarOpen: boolean;
|
||||
}): boolean {
|
||||
return input.followUpSeen && !input.chartVisited && input.sidebarOpen;
|
||||
}
|
||||
|
||||
export type EphemerisPhase = "watch" | "wait-close" | "ready";
|
||||
|
||||
/** The third daily open arms the *next* sidebar open. An already-open sidebar waits for a close first. */
|
||||
export function nextEphemerisPhase(input: {
|
||||
readonly phase: EphemerisPhase;
|
||||
readonly seen: boolean;
|
||||
readonly dailyOpens: number;
|
||||
readonly sidebarOpen: boolean;
|
||||
readonly viewportReady: boolean;
|
||||
}): { readonly phase: EphemerisPhase; readonly armed: boolean } {
|
||||
if (!input.viewportReady || input.seen || input.dailyOpens < 3) {
|
||||
return { phase: input.dailyOpens < 3 && !input.seen ? "watch" : input.phase, armed: false };
|
||||
}
|
||||
let phase = input.phase;
|
||||
if (phase === "watch") phase = input.sidebarOpen ? "wait-close" : "ready";
|
||||
else if (phase === "wait-close" && !input.sidebarOpen) phase = "ready";
|
||||
return { phase, armed: phase === "ready" && input.sidebarOpen };
|
||||
}
|
||||
|
||||
export function showChartMenuDot(input: { readonly askDone: boolean; readonly chartVisited: boolean }): boolean {
|
||||
return input.askDone && !input.chartVisited;
|
||||
}
|
||||
|
||||
export function tabHasDot(tabId: string, hintsSeen: readonly OnboardingHintId[]): boolean {
|
||||
const hintId = TAB_HINT_IDS[tabId as ChartHintTab];
|
||||
return Boolean(hintId) && !hintsSeen.includes(hintId);
|
||||
}
|
||||
|
||||
export function synReadyFact(input: {
|
||||
readonly compatibility: boolean | null;
|
||||
readonly selfComplete: boolean;
|
||||
readonly completeOthers: number;
|
||||
readonly synastryReportCount: number;
|
||||
}): boolean {
|
||||
return input.compatibility === true
|
||||
&& input.selfComplete
|
||||
&& input.completeOthers >= 1
|
||||
&& input.synastryReportCount === 0;
|
||||
}
|
||||
|
||||
export function hintsBlocked(input: {
|
||||
readonly sky: boolean;
|
||||
readonly dialog: boolean;
|
||||
readonly streaming: boolean;
|
||||
readonly checklistOpen: boolean;
|
||||
readonly foreignDialog: boolean;
|
||||
}): boolean {
|
||||
return input.sky || input.dialog || input.streaming || input.checklistOpen || input.foreignDialog;
|
||||
}
|
||||
|
||||
export type HintFacts = {
|
||||
readonly hintsSeen: readonly OnboardingHintId[];
|
||||
readonly followUpArmed: boolean;
|
||||
readonly chartVisited: boolean;
|
||||
readonly sidebarOpen: boolean;
|
||||
readonly activeChartTab: ChartHintTab | null;
|
||||
readonly synReady: boolean;
|
||||
readonly ephemerisArmed: boolean;
|
||||
readonly blocked: boolean;
|
||||
readonly anchors: Readonly<Record<string, boolean>>;
|
||||
};
|
||||
|
||||
function eligible(facts: HintFacts): OnboardingHintId | null {
|
||||
const seen = new Set(facts.hintsSeen);
|
||||
const ready: Record<OnboardingHintId, boolean> = {
|
||||
"follow-up": facts.followUpArmed,
|
||||
"sidebar-chart": sidebarChartReady({
|
||||
followUpSeen: facts.hintsSeen.includes("follow-up"),
|
||||
chartVisited: facts.chartVisited,
|
||||
sidebarOpen: facts.sidebarOpen,
|
||||
}),
|
||||
"tab-chart": facts.activeChartTab === "vedic",
|
||||
"tab-info": facts.activeChartTab === "basics",
|
||||
"tab-dasha": facts.activeChartTab === "dasha",
|
||||
"tab-west": facts.activeChartTab === "western",
|
||||
"syn-ready": facts.synReady,
|
||||
"ephemeris": facts.ephemerisArmed && facts.sidebarOpen,
|
||||
};
|
||||
for (const id of HINT_ORDER) {
|
||||
if (ready[id] && !seen.has(id)) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The hint that should show once its anchor exists and nothing is covering the page. */
|
||||
export function selectHint(facts: HintFacts): OnboardingHintId | null {
|
||||
if (facts.blocked) return null;
|
||||
const id = eligible(facts);
|
||||
if (!id) return null;
|
||||
return facts.anchors[HINT_ANCHORS[id]] ? id : null;
|
||||
}
|
||||
|
||||
/** Same order, ignoring blockers and missing anchors, so a covered hint is not dropped. */
|
||||
export function queuedHint(facts: HintFacts): OnboardingHintId | null {
|
||||
return eligible({ ...facts, blocked: false, anchors: {} });
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useSessionList } from "@/hooks/use-session-list";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistAllDone,
|
||||
checklistProgress,
|
||||
countDailySessions,
|
||||
hasCompleteNonSmalltalkAnswer,
|
||||
nextArrival,
|
||||
shouldShowChecklist,
|
||||
type ArrivalPhase,
|
||||
type ChecklistItem,
|
||||
type ChecklistPerson,
|
||||
} from "@/lib/onboarding-checklist";
|
||||
import {
|
||||
hintCopy,
|
||||
hintsBlocked,
|
||||
nextEphemerisPhase,
|
||||
selectHint,
|
||||
showChartMenuDot,
|
||||
synReadyFact,
|
||||
tabHasDot,
|
||||
type ChartHintTab,
|
||||
type EphemerisPhase,
|
||||
type HintFacts,
|
||||
} from "@/lib/onboarding-hints";
|
||||
import { missingOtherProfileStep, readProfile, selfChartComplete } from "@/lib/home-profile";
|
||||
import type { Profile } from "@/lib/home-types";
|
||||
import { parseOnboardingState, type OnboardingHintId, type OnboardingState } from "@/lib/onboarding-state";
|
||||
import { setOnboardingDailyOpenedListener } from "@/lib/onboarding-signals";
|
||||
|
||||
const REPORT_PAGE_CAP = 3;
|
||||
const ANCHOR_NAMES = ["follow-up", "sidebar-chart", "chart-tab", "syn-ready", "ephemeris"];
|
||||
const EMPTY_HINTS: readonly OnboardingHintId[] = [];
|
||||
|
||||
type RemoteOther = { readonly id: string; readonly profile: unknown; readonly createdAt: number | null };
|
||||
|
||||
type RemoteSnapshot = {
|
||||
readonly status: "loading" | "ready" | "hidden";
|
||||
readonly userId: string;
|
||||
readonly state: OnboardingState;
|
||||
readonly others: readonly RemoteOther[];
|
||||
readonly profilesKnown: boolean;
|
||||
readonly accessKnown: boolean;
|
||||
readonly compatibility: boolean;
|
||||
readonly reportCenter: boolean;
|
||||
readonly synastryCreditCost: number | null;
|
||||
readonly reportCreditCost: number | null;
|
||||
readonly synCount: number | null;
|
||||
readonly personalCount: number | null;
|
||||
};
|
||||
|
||||
type ReportRow = { readonly status?: string; readonly reportType?: string };
|
||||
|
||||
type LiveState = {
|
||||
readonly streaming: boolean;
|
||||
readonly streamingSessionId: string | null;
|
||||
readonly skyOpen: boolean;
|
||||
readonly dialogOpen: boolean;
|
||||
readonly foreignDialog: boolean;
|
||||
readonly checklistOpen: boolean;
|
||||
readonly anchors: Readonly<Record<string, boolean>>;
|
||||
readonly selfProfile: Profile | null;
|
||||
readonly activeChartTab: ChartHintTab | null;
|
||||
readonly chartSubjectName: string | null;
|
||||
};
|
||||
|
||||
type HomeActions = {
|
||||
readonly focusComposer: () => void;
|
||||
readonly startDaily: () => void;
|
||||
};
|
||||
|
||||
export type OnboardingJourney = {
|
||||
readonly showChecklist: boolean;
|
||||
readonly items: readonly ChecklistItem[];
|
||||
readonly label: string;
|
||||
readonly celebration: boolean;
|
||||
readonly activeHint: OnboardingHintId | null;
|
||||
readonly hintText: string;
|
||||
readonly showChartDot: boolean;
|
||||
readonly tabHasDot: (tabId: string) => boolean;
|
||||
readonly dismissChecklist: () => void;
|
||||
readonly dismissHint: (id: OnboardingHintId) => Promise<boolean>;
|
||||
readonly go: (item: ChecklistItem) => void;
|
||||
readonly setChartTab: (tab: string | null, name: string | null) => void;
|
||||
readonly markChartVisited: () => void;
|
||||
readonly setChecklistOpen: (open: boolean) => void;
|
||||
readonly setLive: (patch: Partial<LiveState>) => void;
|
||||
readonly registerHome: (actions: HomeActions | null) => void;
|
||||
};
|
||||
|
||||
const OnboardingJourneyContext = createContext<OnboardingJourney | null>(null);
|
||||
|
||||
const INITIAL_REMOTE: RemoteSnapshot = {
|
||||
status: "loading",
|
||||
userId: "",
|
||||
state: {},
|
||||
others: [],
|
||||
profilesKnown: false,
|
||||
accessKnown: false,
|
||||
compatibility: false,
|
||||
reportCenter: false,
|
||||
synastryCreditCost: null,
|
||||
reportCreditCost: null,
|
||||
synCount: null,
|
||||
personalCount: null,
|
||||
};
|
||||
|
||||
const EMPTY_LIVE: LiveState = {
|
||||
streaming: false,
|
||||
streamingSessionId: null,
|
||||
skyOpen: false,
|
||||
dialogOpen: false,
|
||||
foreignDialog: false,
|
||||
checklistOpen: false,
|
||||
anchors: {},
|
||||
selfProfile: null,
|
||||
activeChartTab: null,
|
||||
chartSubjectName: null,
|
||||
};
|
||||
|
||||
function createdAtMs(value: unknown): number | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
function sameAnchors(left: Readonly<Record<string, boolean>>, right: Readonly<Record<string, boolean>>): boolean {
|
||||
return ANCHOR_NAMES.every((name) => Boolean(left[name]) === Boolean(right[name]));
|
||||
}
|
||||
|
||||
function isReadyPersonal(row: ReportRow): boolean {
|
||||
return row.status === "ready" && row.reportType !== "synastry_full";
|
||||
}
|
||||
|
||||
async function countReports(path: string, accept: (row: ReportRow) => boolean): Promise<number | null> {
|
||||
let count = 0;
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < REPORT_PAGE_CAP; page += 1) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
if (cursor) url.searchParams.set("before", cursor);
|
||||
const response = await fetch(`${url.pathname}${url.search}`, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
const body = await response.json() as { reports?: ReportRow[]; nextCursor?: unknown };
|
||||
count += (body.reports ?? []).filter(accept).length;
|
||||
cursor = typeof body.nextCursor === "string" && body.nextCursor ? body.nextCursor : null;
|
||||
if (!cursor) break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function countLegacySynastry(): Promise<number | null> {
|
||||
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
|
||||
if (response.status === 404) return 0;
|
||||
if (!response.ok) return null;
|
||||
const body = await response.json() as { reports?: unknown };
|
||||
return Array.isArray(body.reports) ? body.reports.length : 0;
|
||||
}
|
||||
|
||||
async function countPersonal(others: readonly RemoteOther[]): Promise<number | null> {
|
||||
const selfCount = await countReports("/api/reports?subject=self", isReadyPersonal);
|
||||
if (selfCount === null) return null;
|
||||
if (selfCount > 0) return selfCount;
|
||||
for (const other of others) {
|
||||
const count = await countReports(`/api/reports?subject=${encodeURIComponent(other.id)}`, isReadyPersonal);
|
||||
if (count === null) return null;
|
||||
if (count > 0) return count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function patchOnboarding(patch: OnboardingState): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding-state", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemote(userId: string): Promise<RemoteSnapshot> {
|
||||
const [stateRes, profilesRes, accessRes] = await Promise.all([
|
||||
fetch("/api/onboarding-state", { cache: "no-store" }),
|
||||
fetch("/api/chart-profiles", { cache: "no-store" }),
|
||||
fetch("/api/product-access", { cache: "no-store" }),
|
||||
]);
|
||||
if (stateRes.status === 401 || profilesRes.status === 401 || !stateRes.ok) {
|
||||
return { ...INITIAL_REMOTE, status: "hidden", userId };
|
||||
}
|
||||
const stateBody = await stateRes.json() as { state?: unknown };
|
||||
const state = parseOnboardingState(stateBody.state);
|
||||
let others: RemoteOther[] = [];
|
||||
let profilesKnown = false;
|
||||
if (profilesRes.ok) {
|
||||
profilesKnown = true;
|
||||
const body = await profilesRes.json() as { profiles?: Array<{ id?: unknown; profile?: unknown; created_at?: unknown }> };
|
||||
others = (body.profiles ?? []).flatMap((row) => (
|
||||
typeof row.id === "string" && row.id
|
||||
? [{ id: row.id, profile: row.profile, createdAt: createdAtMs(row.created_at) }]
|
||||
: []
|
||||
));
|
||||
}
|
||||
let accessKnown = false;
|
||||
let compatibility = false;
|
||||
let reportCenter = false;
|
||||
let synastryCreditCost: number | null = null;
|
||||
let reportCreditCost: number | null = null;
|
||||
if (accessRes.ok) {
|
||||
const body = await accessRes.json() as Record<string, unknown>;
|
||||
if (typeof body.compatibility === "boolean" && typeof body.reportCenter === "boolean") {
|
||||
accessKnown = true;
|
||||
compatibility = body.compatibility;
|
||||
reportCenter = body.reportCenter;
|
||||
synastryCreditCost = typeof body.synastryCreditCost === "number" ? body.synastryCreditCost : null;
|
||||
reportCreditCost = typeof body.reportCreditCost === "number" ? body.reportCreditCost : null;
|
||||
}
|
||||
}
|
||||
// Session titles only cover the current person and the loaded page. Report
|
||||
// lists stop after three pages. A failed price or flag read hides that row
|
||||
// and refuses to treat the account as already finished.
|
||||
const synCount = accessKnown && compatibility
|
||||
? await (async () => {
|
||||
const modern = await countReports("/api/reports?reportType=synastry_full", (row) => row.status === "ready");
|
||||
const legacy = await countLegacySynastry();
|
||||
return modern == null || legacy == null ? null : modern + legacy;
|
||||
})()
|
||||
: null;
|
||||
const personalCount = accessKnown && reportCenter ? await countPersonal(others) : null;
|
||||
return {
|
||||
status: "ready",
|
||||
userId,
|
||||
state,
|
||||
others,
|
||||
profilesKnown,
|
||||
accessKnown,
|
||||
compatibility,
|
||||
reportCenter,
|
||||
synastryCreditCost,
|
||||
reportCreditCost,
|
||||
synCount,
|
||||
personalCount,
|
||||
};
|
||||
}
|
||||
|
||||
function toPeople(selfProfile: Profile, others: readonly RemoteOther[]): ChecklistPerson[] {
|
||||
return [
|
||||
{
|
||||
id: "self",
|
||||
role: "self",
|
||||
name: selfProfile.name.trim() || "我",
|
||||
complete: selfChartComplete(selfProfile),
|
||||
createdAt: null,
|
||||
},
|
||||
...others.map((row) => {
|
||||
const profile = readProfile(row.profile);
|
||||
return {
|
||||
id: row.id,
|
||||
role: "other" as const,
|
||||
name: profile.name.trim(),
|
||||
complete: missingOtherProfileStep(profile) === null && Boolean(profile.name.trim()),
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function chartTab(value: string | null): ChartHintTab | null {
|
||||
return value === "vedic" || value === "basics" || value === "dasha" || value === "western" ? value : null;
|
||||
}
|
||||
|
||||
export function useOnboardingJourneyOptional(): OnboardingJourney | null {
|
||||
return useContext(OnboardingJourneyContext);
|
||||
}
|
||||
|
||||
export function useOnboardingJourney(): OnboardingJourney {
|
||||
const value = useOnboardingJourneyOptional();
|
||||
if (!value) throw new Error("Onboarding journey is only available inside the app shell");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The one hint selector. `condition` is the caller's extra gate; the provider still queues the rest. */
|
||||
export function useOneTimeHint(id: OnboardingHintId, condition: boolean): { show: boolean; dismiss: () => void } {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
const show = Boolean(condition && journey?.activeHint === id);
|
||||
const dismiss = useCallback(() => {
|
||||
journey?.dismissHint(id);
|
||||
}, [journey, id]);
|
||||
return { show, dismiss };
|
||||
}
|
||||
|
||||
export function OnboardingJourneyProvider({ children }: { readonly children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const { open, openMobile, viewport, ready } = useSidebar();
|
||||
const sidebarOpen = ready && (viewport === "mobile" ? openMobile : open);
|
||||
const [remote, setRemote] = useState<RemoteSnapshot>(INITIAL_REMOTE);
|
||||
const [overlay, setOverlay] = useState<OnboardingState>({});
|
||||
const [live, setLiveState] = useState<LiveState>(EMPTY_LIVE);
|
||||
const [dailyFloor, setDailyFloor] = useState(0);
|
||||
const [followUpArmed, setFollowUpArmed] = useState(false);
|
||||
const [ephemerisArmed, setEphemerisArmed] = useState(false);
|
||||
const [arrivedComplete, setArrivedComplete] = useState(false);
|
||||
const [arrivalSettled, setArrivalSettled] = useState(false);
|
||||
const [celebration, setCelebration] = useState(false);
|
||||
const userId = list.account?.user.id ?? "";
|
||||
const remoteRef = useRef(remote);
|
||||
const overlayRef = useRef(overlay);
|
||||
const homeRef = useRef<HomeActions | null>(null);
|
||||
const wasStreaming = useRef(false);
|
||||
const hadAnswerBefore = useRef(false);
|
||||
const ephemerisPhase = useRef<EphemerisPhase>("watch");
|
||||
const arrivalPhase = useRef<ArrivalPhase>("pending");
|
||||
const visitedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
remoteRef.current = remote;
|
||||
overlayRef.current = overlay;
|
||||
}, [remote, overlay]);
|
||||
|
||||
const setLive = useCallback((patch: Partial<LiveState>) => {
|
||||
setLiveState((current) => {
|
||||
const anchors = patch.anchors ?? current.anchors;
|
||||
const next: LiveState = {
|
||||
streaming: patch.streaming ?? current.streaming,
|
||||
streamingSessionId: patch.streamingSessionId !== undefined ? patch.streamingSessionId : current.streamingSessionId,
|
||||
skyOpen: patch.skyOpen ?? current.skyOpen,
|
||||
dialogOpen: patch.dialogOpen ?? current.dialogOpen,
|
||||
foreignDialog: patch.foreignDialog ?? current.foreignDialog,
|
||||
checklistOpen: patch.checklistOpen ?? current.checklistOpen,
|
||||
anchors,
|
||||
selfProfile: patch.selfProfile !== undefined ? patch.selfProfile : current.selfProfile,
|
||||
activeChartTab: patch.activeChartTab !== undefined ? patch.activeChartTab : current.activeChartTab,
|
||||
chartSubjectName: patch.chartSubjectName !== undefined ? patch.chartSubjectName : current.chartSubjectName,
|
||||
};
|
||||
if (
|
||||
next.streaming === current.streaming
|
||||
&& next.streamingSessionId === current.streamingSessionId
|
||||
&& next.skyOpen === current.skyOpen
|
||||
&& next.dialogOpen === current.dialogOpen
|
||||
&& next.foreignDialog === current.foreignDialog
|
||||
&& next.checklistOpen === current.checklistOpen
|
||||
&& next.selfProfile === current.selfProfile
|
||||
&& next.activeChartTab === current.activeChartTab
|
||||
&& next.chartSubjectName === current.chartSubjectName
|
||||
&& sameAnchors(next.anchors, current.anchors)
|
||||
) return current;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setOnboardingDailyOpenedListener(() => {
|
||||
setDailyFloor((count) => count + 1);
|
||||
});
|
||||
return () => setOnboardingDailyOpenedListener(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
wasStreaming.current = false;
|
||||
hadAnswerBefore.current = false;
|
||||
ephemerisPhase.current = "watch";
|
||||
arrivalPhase.current = "pending";
|
||||
visitedRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
setFollowUpArmed(false);
|
||||
setEphemerisArmed(false);
|
||||
setArrivedComplete(false);
|
||||
setArrivalSettled(false);
|
||||
setCelebration(false);
|
||||
setOverlay({});
|
||||
setDailyFloor(0);
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.signedOut || !userId) {
|
||||
queueMicrotask(() => setRemote({ ...INITIAL_REMOTE, status: "hidden" }));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadRemote(userId).then((snapshot) => {
|
||||
if (!cancelled) setRemote(snapshot);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setRemote({ ...INITIAL_REMOTE, status: "hidden", userId });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [list.signedOut, userId]);
|
||||
|
||||
const selfProfile = live.selfProfile ?? readProfile(list.account?.profile);
|
||||
const people = useMemo(() => toPeople(selfProfile, remote.others), [selfProfile, remote.others]);
|
||||
const profileComplete = people.some((person) => person.complete);
|
||||
const merged = useMemo<OnboardingState>(() => ({
|
||||
checklistDismissedAt: overlay.checklistDismissedAt !== undefined ? overlay.checklistDismissedAt : remote.state.checklistDismissedAt,
|
||||
chartVisitedAt: overlay.chartVisitedAt !== undefined ? overlay.chartVisitedAt : remote.state.chartVisitedAt,
|
||||
hintsSeen: overlay.hintsSeen ?? remote.state.hintsSeen,
|
||||
}), [overlay, remote.state]);
|
||||
const hintsSeen = merged.hintsSeen ?? EMPTY_HINTS;
|
||||
const followUpSeen = hintsSeen.includes("follow-up");
|
||||
const ephemerisSeen = hintsSeen.includes("ephemeris");
|
||||
const chartVisited = Boolean(merged.chartVisitedAt);
|
||||
const dismissed = Boolean(merged.checklistDismissedAt);
|
||||
const dailySessions = countDailySessions(list.sessions);
|
||||
const dailyOpens = Math.max(dailySessions, dailyFloor);
|
||||
const hasAnswer = hasCompleteNonSmalltalkAnswer({
|
||||
sessions: list.sessions,
|
||||
streaming: live.streaming,
|
||||
streamingSessionId: live.streamingSessionId,
|
||||
firstConsultFree: list.account?.firstConsultFree,
|
||||
});
|
||||
const items = useMemo(() => buildChecklist({
|
||||
people,
|
||||
profileComplete,
|
||||
hasNonSmalltalkAnswer: hasAnswer,
|
||||
dailyCount: dailySessions,
|
||||
chartVisited,
|
||||
synastryReportCount: remote.synCount ?? 0,
|
||||
personalReportCount: remote.personalCount ?? 0,
|
||||
flags: {
|
||||
compatibility: remote.accessKnown ? remote.compatibility : null,
|
||||
reportCenter: remote.accessKnown ? remote.reportCenter : null,
|
||||
synastryCreditCost: remote.synastryCreditCost,
|
||||
reportCreditCost: remote.reportCreditCost,
|
||||
},
|
||||
}), [
|
||||
people,
|
||||
profileComplete,
|
||||
hasAnswer,
|
||||
dailySessions,
|
||||
chartVisited,
|
||||
remote.synCount,
|
||||
remote.personalCount,
|
||||
remote.accessKnown,
|
||||
remote.compatibility,
|
||||
remote.reportCenter,
|
||||
remote.synastryCreditCost,
|
||||
remote.reportCreditCost,
|
||||
]);
|
||||
const progress = checklistProgress(items);
|
||||
const allDone = checklistAllDone(items);
|
||||
const countsReady = (!remote.accessKnown || !remote.compatibility || remote.synCount !== null)
|
||||
&& (!remote.accessKnown || !remote.reportCenter || remote.personalCount !== null);
|
||||
const canDecide = remote.status === "ready"
|
||||
&& remote.userId === userId
|
||||
&& userId !== ""
|
||||
&& remote.accessKnown
|
||||
&& remote.profilesKnown
|
||||
&& countsReady
|
||||
&& list.settled
|
||||
&& profileComplete
|
||||
&& !dismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (merged.chartVisitedAt) visitedRef.current = merged.chartVisitedAt;
|
||||
}, [merged.chartVisitedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canDecide) return;
|
||||
const next = nextArrival({ decided: arrivalPhase.current, canDecide: true, allDone });
|
||||
if (next === "arrived") arrivalPhase.current = "arrived";
|
||||
else if (next === "gap" || next === "celebrate") arrivalPhase.current = "gap";
|
||||
queueMicrotask(() => {
|
||||
if (next === "arrived") setArrivedComplete(true);
|
||||
if (next === "celebrate") setCelebration(true);
|
||||
setArrivalSettled(true);
|
||||
});
|
||||
}, [canDecide, allDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (followUpSeen) {
|
||||
wasStreaming.current = live.streaming;
|
||||
return;
|
||||
}
|
||||
const was = wasStreaming.current;
|
||||
const had = hadAnswerBefore.current;
|
||||
if (!was && live.streaming) hadAnswerBefore.current = hasAnswer;
|
||||
wasStreaming.current = live.streaming;
|
||||
if (!followUpAfterStreamSafe(was, live.streaming, had, hasAnswer)) return;
|
||||
queueMicrotask(() => setFollowUpArmed(true));
|
||||
}, [live.streaming, hasAnswer, followUpSeen]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = nextEphemerisPhase({
|
||||
phase: ephemerisPhase.current,
|
||||
seen: ephemerisSeen,
|
||||
dailyOpens,
|
||||
sidebarOpen,
|
||||
viewportReady: ready,
|
||||
});
|
||||
ephemerisPhase.current = next.phase;
|
||||
queueMicrotask(() => setEphemerisArmed((current) => current === next.armed ? current : next.armed));
|
||||
}, [ephemerisSeen, dailyOpens, sidebarOpen, ready]);
|
||||
|
||||
const selfComplete = people.some((person) => person.role === "self" && person.complete);
|
||||
const completeOthers = people.filter((person) => person.role === "other" && person.complete).length;
|
||||
const synReady = remote.accessKnown && remote.compatibility && remote.synCount !== null && synReadyFact({
|
||||
compatibility: true,
|
||||
selfComplete,
|
||||
completeOthers,
|
||||
synastryReportCount: remote.synCount,
|
||||
});
|
||||
const blocked = hintsBlocked({
|
||||
sky: live.skyOpen,
|
||||
dialog: live.dialogOpen,
|
||||
streaming: live.streaming,
|
||||
checklistOpen: live.checklistOpen,
|
||||
foreignDialog: live.foreignDialog,
|
||||
});
|
||||
const hintFacts: HintFacts = {
|
||||
hintsSeen,
|
||||
followUpArmed,
|
||||
chartVisited,
|
||||
sidebarOpen,
|
||||
activeChartTab: live.activeChartTab,
|
||||
synReady,
|
||||
ephemerisArmed,
|
||||
blocked,
|
||||
anchors: live.anchors,
|
||||
};
|
||||
const activeHint = selectHint(hintFacts);
|
||||
const hintText = activeHint ? hintCopy(activeHint, live.chartSubjectName) : "";
|
||||
const showChecklist = remote.status === "ready"
|
||||
&& shouldShowChecklist({
|
||||
profileComplete,
|
||||
dismissedAt: merged.checklistDismissedAt,
|
||||
arrivedComplete,
|
||||
})
|
||||
&& (!canDecide || arrivalSettled);
|
||||
|
||||
const dismissChecklist = useCallback(() => {
|
||||
const stamp = new Date().toISOString();
|
||||
setOverlay((current) => ({ ...current, checklistDismissedAt: stamp }));
|
||||
setCelebration(false);
|
||||
void patchOnboarding({ checklistDismissedAt: stamp }).then((ok) => {
|
||||
if (!ok) setOverlay((current) => ({ ...current, checklistDismissedAt: remoteRef.current.state.checklistDismissedAt ?? null }));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dismissHint = useCallback((id: OnboardingHintId) => {
|
||||
const seen = overlayRef.current.hintsSeen ?? remoteRef.current.state.hintsSeen ?? [];
|
||||
if (seen.includes(id)) return Promise.resolve(true);
|
||||
const hintsSeenNext = [...seen, id];
|
||||
setOverlay((current) => ({ ...current, hintsSeen: hintsSeenNext }));
|
||||
if (id === "follow-up") setFollowUpArmed(false);
|
||||
return patchOnboarding({ hintsSeen: hintsSeenNext }).then((ok) => {
|
||||
if (!ok) {
|
||||
setOverlay((current) => ({ ...current, hintsSeen: seen }));
|
||||
if (id === "follow-up") setFollowUpArmed(true);
|
||||
}
|
||||
return ok;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markChartVisited = useCallback(() => {
|
||||
if (visitedRef.current) return;
|
||||
const stamp = new Date().toISOString();
|
||||
visitedRef.current = stamp;
|
||||
setOverlay((current) => ({ ...current, chartVisitedAt: stamp }));
|
||||
void patchOnboarding({ chartVisitedAt: stamp }).then((ok) => {
|
||||
if (!ok) {
|
||||
visitedRef.current = remoteRef.current.state.chartVisitedAt ?? null;
|
||||
setOverlay((current) => ({ ...current, chartVisitedAt: remoteRef.current.state.chartVisitedAt ?? null }));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setChartTab = useCallback((tab: string | null, name: string | null) => {
|
||||
setLive({ activeChartTab: chartTab(tab), chartSubjectName: name });
|
||||
}, [setLive]);
|
||||
|
||||
const setChecklistOpen = useCallback((next: boolean) => {
|
||||
setLive({ checklistOpen: next });
|
||||
}, [setLive]);
|
||||
|
||||
const registerHome = useCallback((actions: HomeActions | null) => {
|
||||
homeRef.current = actions;
|
||||
}, []);
|
||||
|
||||
const go = useCallback((item: ChecklistItem) => {
|
||||
setLive({ checklistOpen: false });
|
||||
const target = item.go;
|
||||
if (!target) return;
|
||||
if (target.kind === "compose") {
|
||||
if (homeRef.current) homeRef.current.focusComposer();
|
||||
else window.location.assign("/?compose=1");
|
||||
return;
|
||||
}
|
||||
if (target.kind === "daily") {
|
||||
if (homeRef.current) homeRef.current.startDaily();
|
||||
else window.location.assign("/?daily=1");
|
||||
return;
|
||||
}
|
||||
window.location.assign(target.href);
|
||||
}, [setLive]);
|
||||
|
||||
const dotVisible = showChartMenuDot({ askDone: hasAnswer, chartVisited });
|
||||
const tabDot = useCallback((tabId: string) => tabHasDot(tabId, hintsSeen), [hintsSeen]);
|
||||
|
||||
const value = useMemo<OnboardingJourney>(() => ({
|
||||
showChecklist,
|
||||
items,
|
||||
label: progress.label,
|
||||
celebration,
|
||||
activeHint,
|
||||
hintText,
|
||||
showChartDot: dotVisible,
|
||||
tabHasDot: tabDot,
|
||||
dismissChecklist,
|
||||
dismissHint,
|
||||
go,
|
||||
setChartTab,
|
||||
markChartVisited,
|
||||
setChecklistOpen,
|
||||
setLive,
|
||||
registerHome,
|
||||
}), [
|
||||
showChecklist,
|
||||
items,
|
||||
progress.label,
|
||||
celebration,
|
||||
activeHint,
|
||||
hintText,
|
||||
dotVisible,
|
||||
tabDot,
|
||||
dismissChecklist,
|
||||
dismissHint,
|
||||
go,
|
||||
setChartTab,
|
||||
markChartVisited,
|
||||
setChecklistOpen,
|
||||
setLive,
|
||||
registerHome,
|
||||
]);
|
||||
|
||||
return <OnboardingJourneyContext.Provider value={value}>{children}</OnboardingJourneyContext.Provider>;
|
||||
}
|
||||
|
||||
function followUpAfterStreamSafe(was: boolean, streaming: boolean, had: boolean, has: boolean): boolean {
|
||||
return was && !streaming && !had && has;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** One listener: the home page fires it when 今日星语 actually starts. */
|
||||
|
||||
type DailyOpenedListener = () => void;
|
||||
|
||||
let dailyOpenedListener: DailyOpenedListener | null = null;
|
||||
|
||||
export function setOnboardingDailyOpenedListener(listener: DailyOpenedListener | null): void {
|
||||
dailyOpenedListener = listener;
|
||||
}
|
||||
|
||||
export function notifyOnboardingDailyOpened(): void {
|
||||
dailyOpenedListener?.();
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export const SELF_SUBJECT_ID = "self";
|
||||
|
||||
export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation";
|
||||
|
||||
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at";
|
||||
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,relation,profile,updated_at,created_at";
|
||||
|
||||
export const MAX_OTHER_SUBJECTS = 19;
|
||||
export const MAX_SAVED_SUBJECTS = 20;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistAllDone,
|
||||
checklistProgress,
|
||||
CHECKLIST_CELEBRATION_BODY,
|
||||
CHECKLIST_CELEBRATION_TITLE,
|
||||
countDailySessions,
|
||||
hasCompleteNonSmalltalkAnswer,
|
||||
helpedName,
|
||||
isCompleteNonSmalltalkAnswer,
|
||||
nextArrival,
|
||||
sessionCountsAsDaily,
|
||||
shouldShowChecklist,
|
||||
type ChecklistFacts,
|
||||
type ChecklistPerson,
|
||||
} from "../src/lib/onboarding-checklist.ts";
|
||||
import {
|
||||
followUpAfterStream,
|
||||
HINT_COPY,
|
||||
hintCopy,
|
||||
nextEphemerisPhase,
|
||||
queuedHint,
|
||||
selectHint,
|
||||
showChartMenuDot,
|
||||
sidebarChartReady,
|
||||
tabChartCopy,
|
||||
tabHasDot,
|
||||
type EphemerisPhase,
|
||||
type HintFacts,
|
||||
} from "../src/lib/onboarding-hints.ts";
|
||||
|
||||
function person(role: "self" | "other", name: string, complete: boolean, createdAt: number | null = null): ChecklistPerson {
|
||||
return { id: role === "self" ? "self" : name, role, name, complete, createdAt };
|
||||
}
|
||||
|
||||
function facts(patch: Partial<ChecklistFacts> = {}): ChecklistFacts {
|
||||
return {
|
||||
people: [person("self", "我", true)],
|
||||
profileComplete: true,
|
||||
hasNonSmalltalkAnswer: false,
|
||||
dailyCount: 0,
|
||||
chartVisited: false,
|
||||
synastryReportCount: 0,
|
||||
personalReportCount: 0,
|
||||
flags: { compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
|
||||
test("self path keeps the seven labels, order, and done rules", () => {
|
||||
const open = buildChecklist(facts());
|
||||
assert.deepEqual(open.map((item) => item.id), ["self", "ask", "daily", "chart", "person", "syn", "report"]);
|
||||
assert.equal(open[0]?.label, "排好自己的盘");
|
||||
assert.equal(open[0]?.go, null);
|
||||
assert.equal(open[1]?.label, "问第一个问题");
|
||||
assert.deepEqual(open[1]?.go, { kind: "compose" });
|
||||
assert.equal(open[2]?.label, "打开今日星语");
|
||||
assert.deepEqual(open[2]?.go, { kind: "daily" });
|
||||
assert.equal(open[3]?.label, "看看自己的星盘");
|
||||
assert.deepEqual(open[3]?.go, { kind: "href", href: "/chart" });
|
||||
assert.equal(open[4]?.label, "加一位家人或朋友");
|
||||
assert.equal(open[4]?.done, false);
|
||||
assert.deepEqual(open[4]?.go, { kind: "href", href: "/people?add=1" });
|
||||
assert.equal(open[5]?.label, "和 TA 合一次盘 · 19 点");
|
||||
assert.deepEqual(open[5]?.go, { kind: "href", href: "/people" });
|
||||
assert.equal(open[6]?.label, "生成个人报告 · 29 点");
|
||||
assert.deepEqual(open[6]?.go, { kind: "href", href: "/reports" });
|
||||
assert.equal(checklistProgress(open).label, "启程 1/7");
|
||||
});
|
||||
|
||||
test("smalltalk is not ask, one self is not person, and a second other does not finish the help-other person row", () => {
|
||||
assert.equal(isCompleteNonSmalltalkAnswer({ role: "assistant", text: "在的", responseKind: "smalltalk" }), false);
|
||||
assert.equal(hasCompleteNonSmalltalkAnswer({
|
||||
sessions: [{ messagesHydrated: true, messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }] }],
|
||||
firstConsultFree: true,
|
||||
}), false);
|
||||
assert.equal(hasCompleteNonSmalltalkAnswer({
|
||||
sessions: [{ id: "s", messagesHydrated: true, messages: [{ role: "assistant", text: "完整回答" }] }],
|
||||
streaming: true,
|
||||
streamingSessionId: "s",
|
||||
firstConsultFree: false,
|
||||
}), false);
|
||||
const selfOnly = buildChecklist(facts());
|
||||
assert.equal(selfOnly.find((item) => item.id === "person")?.done, false);
|
||||
const helped = facts({
|
||||
people: [person("self", "我", false), person("other", "小周", true, 2), person("other", "小陈", true, 3)],
|
||||
profileComplete: true,
|
||||
});
|
||||
const rows = buildChecklist(helped);
|
||||
assert.deepEqual(rows.map((item) => item.id), ["self", "ask", "person", "daily", "chart", "syn", "report"]);
|
||||
assert.equal(rows[0]?.label, "排好小周的盘");
|
||||
assert.equal(rows.find((item) => item.id === "person")?.label, "加上你自己");
|
||||
assert.equal(rows.find((item) => item.id === "person")?.done, false);
|
||||
assert.deepEqual(rows.find((item) => item.id === "person")?.go, { kind: "href", href: "/people?self=1" });
|
||||
assert.deepEqual(rows.find((item) => item.id === "daily")?.go, { kind: "href", href: "/people" });
|
||||
assert.equal(rows.find((item) => item.id === "chart")?.label, "看看星盘");
|
||||
assert.equal(rows.find((item) => item.id === "syn")?.label, "和小周合一次盘 · 19 点");
|
||||
});
|
||||
|
||||
test("both people complete uses the self copy, and a missing price omits the price clause", () => {
|
||||
const both = facts({
|
||||
people: [person("self", "我", true, 5), person("other", "小周", true, 1)],
|
||||
});
|
||||
assert.equal(helpedName(both.people), "小周");
|
||||
assert.equal(buildChecklist(both)[0]?.label, "排好自己的盘");
|
||||
const unpriced = buildChecklist(facts({
|
||||
flags: { compatibility: true, reportCenter: true, synastryCreditCost: null, reportCreditCost: null },
|
||||
}));
|
||||
assert.equal(unpriced.find((item) => item.id === "syn")?.label, "和 TA 合一次盘");
|
||||
assert.equal(unpriced.find((item) => item.id === "report")?.label, "生成个人报告");
|
||||
});
|
||||
|
||||
test("closed switches drop rows, and progress labels follow the remaining rows", () => {
|
||||
assert.equal(buildChecklist(facts({ flags: { compatibility: false, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 } })).length, 6);
|
||||
assert.equal(buildChecklist(facts({ flags: { compatibility: true, reportCenter: false, synastryCreditCost: 19, reportCreditCost: null } })).some((item) => item.id === "report"), false);
|
||||
assert.equal(buildChecklist(facts({ flags: { compatibility: null, reportCenter: null, synastryCreditCost: null, reportCreditCost: null } })).length, 5);
|
||||
const fresh = checklistProgress(buildChecklist(facts()));
|
||||
assert.equal(fresh.label, "启程 1/7");
|
||||
const older = buildChecklist(facts({
|
||||
people: [person("self", "我", true), person("other", "小周", true)],
|
||||
hasNonSmalltalkAnswer: true,
|
||||
dailyCount: 0,
|
||||
chartVisited: false,
|
||||
synastryReportCount: 1,
|
||||
personalReportCount: 1,
|
||||
}));
|
||||
assert.equal(checklistProgress(older).label, "启程 5/7");
|
||||
assert.equal(older.find((item) => item.id === "daily")?.done, false);
|
||||
assert.equal(older.find((item) => item.id === "chart")?.done, false);
|
||||
});
|
||||
|
||||
test("a full account that already arrived stays hidden, and dismiss stays hidden", () => {
|
||||
const full = buildChecklist(facts({
|
||||
people: [person("self", "我", true), person("other", "小周", true)],
|
||||
hasNonSmalltalkAnswer: true,
|
||||
dailyCount: 1,
|
||||
chartVisited: true,
|
||||
synastryReportCount: 1,
|
||||
personalReportCount: 1,
|
||||
}));
|
||||
assert.equal(checklistAllDone(full), true);
|
||||
assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: null, arrivedComplete: true }), false);
|
||||
assert.equal(shouldShowChecklist({ profileComplete: true, dismissedAt: "2026-10-24T00:00:00.000Z", arrivedComplete: false }), false);
|
||||
assert.equal(shouldShowChecklist({ profileComplete: false, dismissedAt: null, arrivedComplete: false }), false);
|
||||
assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: true }), "arrived");
|
||||
assert.equal(nextArrival({ decided: "pending", canDecide: true, allDone: false }), "gap");
|
||||
assert.equal(nextArrival({ decided: "gap", canDecide: true, allDone: true }), "celebrate");
|
||||
assert.equal(nextArrival({ decided: "pending", canDecide: false, allDone: true }), "pending");
|
||||
assert.equal(CHECKLIST_CELEBRATION_TITLE, "都走过一遍了");
|
||||
assert.equal(CHECKLIST_CELEBRATION_BODY, "以后有新东西,我会在用到的时候再告诉你。");
|
||||
});
|
||||
|
||||
test("daily counts titled sessions and complete daily questions, not smalltalk", () => {
|
||||
assert.equal(sessionCountsAsDaily({ title: "今日节奏 · 10月24日", messagesHydrated: false }), true);
|
||||
assert.equal(sessionCountsAsDaily({
|
||||
title: "今日节奏 · 10月24日",
|
||||
messagesHydrated: true,
|
||||
messages: [{ role: "assistant", text: "在的", responseKind: "smalltalk" }],
|
||||
}), false);
|
||||
assert.equal(sessionCountsAsDaily({
|
||||
messagesHydrated: true,
|
||||
messages: [
|
||||
{ role: "user", text: "深入看今日" },
|
||||
{ role: "assistant", text: "今天宜缓。" },
|
||||
],
|
||||
}), true);
|
||||
assert.equal(countDailySessions([
|
||||
{ title: "新对话", messagesHydrated: false },
|
||||
{ title: "10月2日 · 今日节奏", messagesHydrated: false },
|
||||
]), 1);
|
||||
});
|
||||
|
||||
function hintFacts(patch: Partial<HintFacts> = {}): HintFacts {
|
||||
return {
|
||||
hintsSeen: [],
|
||||
followUpArmed: false,
|
||||
chartVisited: false,
|
||||
sidebarOpen: false,
|
||||
activeChartTab: null,
|
||||
synReady: false,
|
||||
ephemerisArmed: false,
|
||||
blocked: false,
|
||||
anchors: { "follow-up": true, "sidebar-chart": true, "chart-tab": true, "syn-ready": true, ephemeris: true },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test("each hint appears on its condition and stays gone once seen", () => {
|
||||
assert.equal(selectHint(hintFacts({ followUpArmed: true })), "follow-up");
|
||||
assert.equal(selectHint(hintFacts({ followUpArmed: true, hintsSeen: ["follow-up"] })), null);
|
||||
assert.equal(sidebarChartReady({ followUpSeen: false, chartVisited: false, sidebarOpen: true }), false);
|
||||
assert.equal(selectHint(hintFacts({
|
||||
hintsSeen: ["follow-up"],
|
||||
sidebarOpen: true,
|
||||
})), "sidebar-chart");
|
||||
assert.equal(selectHint(hintFacts({ hintsSeen: ["follow-up"], sidebarOpen: true, chartVisited: true, activeChartTab: "vedic" })), "tab-chart");
|
||||
assert.equal(selectHint(hintFacts({ activeChartTab: "basics" })), "tab-info");
|
||||
assert.equal(selectHint(hintFacts({ activeChartTab: "dasha" })), "tab-dasha");
|
||||
assert.equal(selectHint(hintFacts({ activeChartTab: "western" })), "tab-west");
|
||||
assert.equal(selectHint(hintFacts({ synReady: true })), "syn-ready");
|
||||
assert.equal(selectHint(hintFacts({ ephemerisArmed: true, sidebarOpen: true })), "ephemeris");
|
||||
assert.equal(selectHint(hintFacts({
|
||||
hintsSeen: ["follow-up", "sidebar-chart", "tab-chart", "tab-info", "tab-dasha", "tab-west", "syn-ready", "ephemeris"],
|
||||
followUpArmed: true,
|
||||
sidebarOpen: true,
|
||||
activeChartTab: "vedic",
|
||||
synReady: true,
|
||||
ephemerisArmed: true,
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("blocked hints and missing anchors do not show and are not dropped", () => {
|
||||
const blocked = hintFacts({ followUpArmed: true, blocked: true });
|
||||
assert.equal(selectHint(blocked), null);
|
||||
assert.equal(queuedHint(blocked), "follow-up");
|
||||
const streaming = hintFacts({ synReady: true, blocked: true, activeChartTab: "vedic" });
|
||||
assert.equal(selectHint(streaming), null);
|
||||
assert.equal(queuedHint(streaming), "tab-chart");
|
||||
const missing = hintFacts({ followUpArmed: true, anchors: {} });
|
||||
assert.equal(selectHint(missing), null);
|
||||
assert.equal(queuedHint(missing), "follow-up");
|
||||
});
|
||||
|
||||
test("follow-up only arms on the falling edge of the first real answer", () => {
|
||||
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), true);
|
||||
assert.equal(followUpAfterStream({ wasStreaming: false, streaming: false, hadAnswerBefore: false, hasAnswerNow: true }), false);
|
||||
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: true, hasAnswerNow: true }), false);
|
||||
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: false, hadAnswerBefore: false, hasAnswerNow: false }), false);
|
||||
assert.equal(followUpAfterStream({ wasStreaming: true, streaming: true, hadAnswerBefore: false, hasAnswerNow: false }), false);
|
||||
});
|
||||
|
||||
test("ephemeris waits for the next sidebar open after the third daily", () => {
|
||||
let phase: EphemerisPhase = "watch";
|
||||
let step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 2, sidebarOpen: true, viewportReady: true });
|
||||
phase = step.phase;
|
||||
assert.equal(step.armed, false);
|
||||
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
|
||||
phase = step.phase;
|
||||
assert.equal(phase, "wait-close");
|
||||
assert.equal(step.armed, false);
|
||||
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
|
||||
phase = step.phase;
|
||||
assert.equal(phase, "ready");
|
||||
assert.equal(step.armed, false);
|
||||
step = nextEphemerisPhase({ phase, seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: true });
|
||||
assert.equal(step.armed, true);
|
||||
const closed = nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: false, viewportReady: true });
|
||||
assert.equal(closed.phase, "ready");
|
||||
assert.equal(closed.armed, false);
|
||||
assert.equal(nextEphemerisPhase({ phase: "watch", seen: false, dailyOpens: 3, sidebarOpen: true, viewportReady: false }).armed, false);
|
||||
});
|
||||
|
||||
test("chart tab dots are recorded separately, and another person's chart renames one phrase", () => {
|
||||
assert.equal(tabHasDot("vedic", []), true);
|
||||
assert.equal(tabHasDot("basics", ["tab-chart"]), true);
|
||||
assert.equal(tabHasDot("dasha", ["tab-dasha"]), false);
|
||||
assert.equal(tabHasDot("western", ["tab-west"]), false);
|
||||
assert.equal(tabHasDot("vedic", ["tab-chart"]), false);
|
||||
assert.equal(tabChartCopy(null), HINT_COPY["tab-chart"]);
|
||||
assert.equal(hintCopy("tab-chart", "小周"), HINT_COPY["tab-chart"].replace("你的", "小周的"));
|
||||
assert.equal(hintCopy("tab-chart", "小周").includes("你的"), false);
|
||||
assert.equal(hintCopy("tab-info", "小周"), HINT_COPY["tab-info"]);
|
||||
assert.equal(showChartMenuDot({ askDone: true, chartVisited: false }), true);
|
||||
assert.equal(showChartMenuDot({ askDone: true, chartVisited: true }), false);
|
||||
assert.equal(showChartMenuDot({ askDone: false, chartVisited: false }), false);
|
||||
});
|
||||
|
||||
test("checklist and hint wiring stay inside the existing shell", () => {
|
||||
const page = read("../src/app/(app)/page.tsx");
|
||||
const layout = read("../src/app/(app)/layout.tsx");
|
||||
const sidebar = read("../src/components/app-sidebar.tsx");
|
||||
const hint = read("../src/components/onboarding-hint-layer.tsx");
|
||||
const css = read("../src/app/globals.css");
|
||||
const people = read("../src/components/people/people-page.tsx");
|
||||
const journey = read("../src/lib/onboarding-journey.tsx");
|
||||
const home = page.slice(page.indexOf("export default function Home("));
|
||||
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
|
||||
assert.match(page, /<OnboardingChecklistEntry \/>/);
|
||||
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("composer-wrap"));
|
||||
assert.match(header, /<OnboardingChecklistEntry \/>/);
|
||||
assert.equal(header.includes("composer-wrap"), false);
|
||||
assert.match(layout, /list\.account\?\.compatibility === true/);
|
||||
assert.doesNotMatch(layout, /\/api\/product-access/);
|
||||
assert.doesNotMatch(sidebar, /supabase|fetch\(|\/api\//i);
|
||||
assert.match(people, /searchParams\.get\("add"\) === "1"/);
|
||||
assert.match(people, /searchParams\.get\("self"\) === "1"/);
|
||||
assert.match(people, /data-onboarding-anchor="syn-ready"/);
|
||||
assert.match(hint, /initialFocus=\{false\}/);
|
||||
assert.match(hint, /finalFocus=\{false\}/);
|
||||
assert.match(hint, /event\.key === "Escape"/);
|
||||
assert.match(journey, /export function useOneTimeHint\(/);
|
||||
assert.match(css, /\.chat-app \{[^}]*overflow:\s*clip/);
|
||||
assert.match(css, /\.chat-header \{[^}]*grid-template-columns:\s*auto minmax\(0,\s*1fr\) auto/);
|
||||
assert.match(css, /\.onboarding-checklist-panel,\s*\n\.onboarding-checklist-sheet \{\s*\n\s*width: min\(22rem, calc\(100vw - 2rem\)\);/);
|
||||
assert.match(css, /grid-template-columns:\s*1\.25rem minmax\(0,\s*1fr\) auto/);
|
||||
assert.equal((home.match(/\buseState[<(]/g) ?? []).length <= 36, true);
|
||||
assert.equal((home.match(/\buseRef[<(]/g) ?? []).length <= 37, true);
|
||||
});
|
||||
Reference in New Issue
Block a user