From 7938b2efa808b34eb1b0c4bcf7c165546b8d352c Mon Sep 17 00:00:00 2001
From: Daniel
Date: Thu, 6 Aug 2026 14:50:36 +0000
Subject: [PATCH] add today onboarding checklist, fix service profile entry,
trigger first sweep on signup
Co-authored-by: Cursor
---
apps/backend/generate/api/radar.api | 2 +
apps/backend/internal/handler/routes.go | 11 ++-
apps/backend/internal/logic/radarmap/map.go | 17 ++--
.../internal/module/radar/domain/watch.go | 4 +
.../internal/module/radar/usecase/watch.go | 12 +++
.../module/radar/usecase/watch_test.go | 67 +++++++++++++++
apps/backend/internal/types/types.go | 17 ++--
apps/web/src/data/live/radarRepos.ts | 1 +
apps/web/src/data/mock/keys.ts | 2 +
apps/web/src/domain/types.ts | 2 +
apps/web/src/lib/i18n/messages.ts | 24 ++++++
apps/web/src/lib/radarOnboarding.ts | 14 +++
apps/web/src/pages/BrandsPage.tsx | 20 ++++-
apps/web/src/pages/RadarTodayPage.tsx | 2 +-
apps/web/src/pages/RadarWatchesPage.test.tsx | 2 +-
apps/web/src/pages/RadarWatchesPage.tsx | 71 +++++++++------
apps/web/src/pages/TodayPage.tsx | 86 ++++++++++++++++++-
17 files changed, 301 insertions(+), 53 deletions(-)
create mode 100644 apps/web/src/lib/radarOnboarding.ts
diff --git a/apps/backend/generate/api/radar.api b/apps/backend/generate/api/radar.api
index d3d2ce8..e35d454 100644
--- a/apps/backend/generate/api/radar.api
+++ b/apps/backend/generate/api/radar.api
@@ -58,6 +58,8 @@ type (
LastSweptAt int64 `json:"last_swept_at,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
+ // 只在建立當下這是使用者第一組訂閱、且已排入首巡時為 true;不是持久狀態,僅供前端顯示一次性提示。
+ FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
}
ListWatchesReq {
diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go
index 01213ad..db6543a 100644
--- a/apps/backend/internal/handler/routes.go
+++ b/apps/backend/internal/handler/routes.go
@@ -1101,12 +1101,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches",
Handler: radar.CreateWatchHandler(serverCtx),
},
- // 靜態路徑必須在 /watches/:id 之前,否則 suggest 會被當成 id。
- {
- Method: http.MethodPost,
- Path: "/watches/suggest",
- Handler: radar.SuggestWatchTermsHandler(serverCtx),
- },
{
Method: http.MethodGet,
Path: "/watches/:id",
@@ -1137,6 +1131,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches/:id/sweep",
Handler: radar.TriggerWatchSweepHandler(serverCtx),
},
+ {
+ Method: http.MethodPost,
+ Path: "/watches/suggest",
+ Handler: radar.SuggestWatchTermsHandler(serverCtx),
+ },
}...,
),
rest.WithPrefix("/api/v1/radar"),
diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go
index 380252e..ca43d38 100644
--- a/apps/backend/internal/logic/radarmap/map.go
+++ b/apps/backend/internal/logic/radarmap/map.go
@@ -76,14 +76,15 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
regions = []string{}
}
return &types.RadarWatchPublic{
- Id: w.ID,
- Terms: terms,
- ExcludeTerms: excludes,
- Regions: regions,
- Status: w.Status,
- LastSweptAt: w.LastSweptAt,
- CreatedAt: w.CreatedAt,
- UpdatedAt: w.UpdatedAt,
+ Id: w.ID,
+ Terms: terms,
+ ExcludeTerms: excludes,
+ Regions: regions,
+ Status: w.Status,
+ LastSweptAt: w.LastSweptAt,
+ CreatedAt: w.CreatedAt,
+ UpdatedAt: w.UpdatedAt,
+ FirstSweepTriggered: w.FirstSweepTriggered,
}
}
diff --git a/apps/backend/internal/module/radar/domain/watch.go b/apps/backend/internal/module/radar/domain/watch.go
index 6acfd32..d077046 100644
--- a/apps/backend/internal/module/radar/domain/watch.go
+++ b/apps/backend/internal/module/radar/domain/watch.go
@@ -32,6 +32,10 @@ type RadarWatch struct {
LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
+
+ // FirstSweepTriggered 是建立當下的一次性狀態,不落庫:只用來讓前端在
+ // 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
+ FirstSweepTriggered bool `bson:"-" json:"-"`
}
type WatchListFilter struct {
diff --git a/apps/backend/internal/module/radar/usecase/watch.go b/apps/backend/internal/module/radar/usecase/watch.go
index 71b8c2c..62f9c08 100644
--- a/apps/backend/internal/module/radar/usecase/watch.go
+++ b/apps/backend/internal/module/radar/usecase/watch.go
@@ -43,14 +43,26 @@ func (s *Service) CreateWatch(ctx context.Context, ownerUID int64, in WatchInput
if err := w.Normalize(); err != nil {
return nil, err
}
+ firstEverWatch := false
if status == domain.WatchActive {
if err := s.assertCanActivate(ctx, ownerUID, ""); err != nil {
return nil, err
}
+ // 只在使用者從未建過任何訂閱時才判定「首巡」,避免每次新增都額外燒一次巡檢成本。
+ if _, total, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 1}); err == nil {
+ firstEverWatch = total == 0
+ }
}
if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err
}
+ if firstEverWatch && s.SweepJobs != nil {
+ // 不等每日排程:讓第一組訂閱的使用者最快隔一小段時間就看到結果,而不是等到隔天。
+ // 失敗不阻斷建立,每日排程仍會補上。
+ if _, sweepErr := s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, w.ID, domain.NowNano()); sweepErr == nil {
+ w.FirstSweepTriggered = true
+ }
+ }
return w, nil
}
diff --git a/apps/backend/internal/module/radar/usecase/watch_test.go b/apps/backend/internal/module/radar/usecase/watch_test.go
index aeb9db2..02f02da 100644
--- a/apps/backend/internal/module/radar/usecase/watch_test.go
+++ b/apps/backend/internal/module/radar/usecase/watch_test.go
@@ -339,6 +339,73 @@ func TestMarkWatchSweptAt(t *testing.T) {
}
}
+// 新用戶建第一組 active 訂閱應立即排入首巡,不用等每日排程;
+// 第二組訂閱不該再重複觸發,避免每次新增都多燒一次巡檢成本。
+func TestCreateWatch_FirstActiveWatchTriggersImmediateSweep(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ var scheduled []string
+ svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, ownerUID int64, watchID string, _ int64) (string, error) {
+ scheduled = append(scheduled, watchID)
+ return "job-" + watchID, nil
+ })
+
+ first, err := svc.CreateWatch(ctx, 42, watchInput())
+ if err != nil {
+ t.Fatalf("create first: %v", err)
+ }
+ if !first.FirstSweepTriggered {
+ t.Fatalf("first watch should report FirstSweepTriggered=true")
+ }
+ if len(scheduled) != 1 || scheduled[0] != first.ID {
+ t.Fatalf("scheduled = %v, want exactly [%s]", scheduled, first.ID)
+ }
+
+ second, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true})
+ if err != nil {
+ t.Fatalf("create second: %v", err)
+ }
+ if second.FirstSweepTriggered {
+ t.Fatalf("second watch should not trigger another immediate sweep")
+ }
+ if len(scheduled) != 1 {
+ t.Fatalf("scheduled = %v, want no additional sweep for the second watch", scheduled)
+ }
+}
+
+// SweepJobs 未配置(例如測試環境)時不該讓建立失敗,也不該回報已觸發首巡。
+func TestCreateWatch_FirstWatchWithoutSweepJobsDoesNotFail(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ w, err := svc.CreateWatch(ctx, 42, watchInput())
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if w.FirstSweepTriggered {
+ t.Fatalf("FirstSweepTriggered should be false when SweepJobs is nil")
+ }
+}
+
+// 第一組訂閱先以 paused 建立時不算「第一組 active」,之後才建的 active 訂閱
+// 不會觸發首巡:規則只看「使用者是否曾建過任何訂閱」,不特別追第一個 active。
+func TestCreateWatch_FirstWatchPausedThenActiveSecondSkipsImmediateSweep(t *testing.T) {
+ svc, ctx := serviceWithProfile(t, 5)
+ var scheduled []string
+ svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, _ int64, watchID string, _ int64) (string, error) {
+ scheduled = append(scheduled, watchID)
+ return "job-" + watchID, nil
+ })
+
+ if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}, Enabled: false}); err != nil {
+ t.Fatalf("create paused: %v", err)
+ }
+ active, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true})
+ if err != nil {
+ t.Fatalf("create active: %v", err)
+ }
+ if active.FirstSweepTriggered || len(scheduled) != 0 {
+ t.Fatalf("expected no immediate sweep after an earlier paused watch already existed, scheduled=%v", scheduled)
+ }
+}
+
func TestWatchTransitionMatrix(t *testing.T) {
allowed := map[string][]string{
domain.WatchActive: {domain.WatchPaused, domain.WatchArchived},
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index ac929ec..01dd828 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -1666,14 +1666,15 @@ type RadarTodayStats struct {
}
type RadarWatchPublic struct {
- Id string `json:"id"`
- Terms []string `json:"terms"`
- ExcludeTerms []string `json:"exclude_terms"`
- Regions []string `json:"regions"`
- Status string `json:"status"` // active | paused | archived
- LastSweptAt int64 `json:"last_swept_at,optional"`
- CreatedAt int64 `json:"created_at"`
- UpdatedAt int64 `json:"updated_at"`
+ Id string `json:"id"`
+ Terms []string `json:"terms"`
+ ExcludeTerms []string `json:"exclude_terms"`
+ Regions []string `json:"regions"`
+ Status string `json:"status"` // active | paused | archived
+ LastSweptAt int64 `json:"last_swept_at,optional"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+ FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
}
type RemoveWorkspaceMemberReq struct {
diff --git a/apps/web/src/data/live/radarRepos.ts b/apps/web/src/data/live/radarRepos.ts
index 75646f2..f494ed2 100644
--- a/apps/web/src/data/live/radarRepos.ts
+++ b/apps/web/src/data/live/radarRepos.ts
@@ -99,6 +99,7 @@ function mapWatch(raw: Raw): RadarWatch {
last_swept_at: optNum(raw.last_swept_at),
created_at: num(raw.created_at),
updated_at: num(raw.updated_at),
+ first_sweep_triggered: Boolean(raw.first_sweep_triggered),
};
}
diff --git a/apps/web/src/data/mock/keys.ts b/apps/web/src/data/mock/keys.ts
index eb65769..680098f 100644
--- a/apps/web/src/data/mock/keys.ts
+++ b/apps/web/src/data/mock/keys.ts
@@ -63,4 +63,6 @@ export const KEYS = {
ownPostsSyncMeta: "harbor.own_posts.sync_meta",
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */
accountInsightHistory: "harbor.account.insight_history",
+ /** 使用者手動關掉 Today 頁「上手三步」引導後不再顯示 */
+ radarOnboardingDismissed: "harbor.radar.onboarding_dismissed",
} as const;
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
index a3e542b..5a3ad97 100644
--- a/apps/web/src/domain/types.ts
+++ b/apps/web/src/domain/types.ts
@@ -845,6 +845,8 @@ export type RadarWatch = {
last_swept_at?: number;
created_at: number;
updated_at: number;
+ /** 只在建立當下是使用者第一組訂閱且已排入首巡時為 true;非持久狀態。 */
+ first_sweep_triggered?: boolean;
};
export type WatchTermSuggestion = {
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
index 2133d12..504c347 100644
--- a/apps/web/src/lib/i18n/messages.ts
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -2168,6 +2168,7 @@ export const zhTW: MessageDict = {
"radar.watches.archive": "封存",
"radar.watches.confirmArchive": "封存後不會再巡,也不能恢復。要繼續嗎?",
"radar.watches.created": "訂閱已建立",
+ "radar.watches.createdFirstSweep": "訂閱已建立,首巡已排入,約幾分鐘後回今日商機頁看結果(之後每天自動巡,不用再等)",
"radar.watches.updated": "訂閱已更新",
"radar.watches.paused": "訂閱已暫停",
"radar.watches.resumed": "訂閱已恢復",
@@ -2191,6 +2192,17 @@ export const zhTW: MessageDict = {
"today.radar.goProfile": "先填服務檔案",
"today.radar.goWatches": "去訂閱關鍵字",
+ "today.onboarding.title": "上手三步",
+ "today.onboarding.dismiss": "不再顯示",
+ "today.onboarding.step.profile": "填服務檔案",
+ "today.onboarding.step.profileHint": "服務項目、地區、不能說的內容",
+ "today.onboarding.step.watch": "建立雷達訂閱",
+ "today.onboarding.step.watchHint": "設定關鍵字,之後每天自動巡",
+ "today.onboarding.step.opportunity": "看今日商機",
+ "today.onboarding.step.opportunityHint": "訂閱建立後,最快次日就有結果",
+ "today.onboarding.step.done": "完成",
+ "today.onboarding.step.go": "去完成",
+
"radar.suggest.title": "關鍵字建議",
"radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
"radar.suggest.ask": "取得建議",
@@ -4512,6 +4524,7 @@ export const en: MessageDict = {
"radar.watches.archive": "Archive",
"radar.watches.confirmArchive": "Archived watches stop sweeping and cannot be restored. Continue?",
"radar.watches.created": "Watch created",
+ "radar.watches.createdFirstSweep": "Watch created — first sweep queued. Check today's demand in a few minutes (it runs automatically every day after this).",
"radar.watches.updated": "Watch updated",
"radar.watches.paused": "Watch paused",
"radar.watches.resumed": "Watch resumed",
@@ -4536,6 +4549,17 @@ export const en: MessageDict = {
"today.radar.goProfile": "Fill service profile",
"today.radar.goWatches": "Add keyword watches",
+ "today.onboarding.title": "3 steps to get started",
+ "today.onboarding.dismiss": "Don't show again",
+ "today.onboarding.step.profile": "Fill service profile",
+ "today.onboarding.step.profileHint": "Services, region, what not to say",
+ "today.onboarding.step.watch": "Add a radar watch",
+ "today.onboarding.step.watchHint": "Set keywords; runs automatically every day after",
+ "today.onboarding.step.opportunity": "See today's demand",
+ "today.onboarding.step.opportunityHint": "Results can show as early as the next day",
+ "today.onboarding.step.done": "Done",
+ "today.onboarding.step.go": "Go",
+
"radar.suggest.title": "Term suggestions",
"radar.suggest.hint":
"Terms drawn from your service profile. Adopt them one by one or all at once; you still need to save to create the watch.",
diff --git a/apps/web/src/lib/radarOnboarding.ts b/apps/web/src/lib/radarOnboarding.ts
new file mode 100644
index 0000000..fdd3e10
--- /dev/null
+++ b/apps/web/src/lib/radarOnboarding.ts
@@ -0,0 +1,14 @@
+import { readJson, writeJson } from "./storage";
+import { KEYS } from "../data/mock/keys";
+
+/**
+ * Today 頁「上手三步」引導的關閉狀態。純前端記憶(per browser),
+ * 三步全部完成或使用者手動關閉後就不再出現。
+ */
+export function isRadarOnboardingDismissed(): boolean {
+ return readJson(KEYS.radarOnboardingDismissed, false);
+}
+
+export function dismissRadarOnboarding(): void {
+ writeJson(KEYS.radarOnboardingDismissed, true);
+}
diff --git a/apps/web/src/pages/BrandsPage.tsx b/apps/web/src/pages/BrandsPage.tsx
index 25d100d..f25300f 100644
--- a/apps/web/src/pages/BrandsPage.tsx
+++ b/apps/web/src/pages/BrandsPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, EmptyState, Input, Pager, Textarea } from "../components/ui";
import { ServiceProfileForm } from "../components/radar/ServiceProfileForm";
@@ -32,12 +33,29 @@ export function BrandsPage() {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
+ const [searchParams, setSearchParams] = useSearchParams();
const [brands, setBrands] = useState([]);
const [products, setProducts] = useState([]);
const [brandId, setBrandId] = useState("");
const [detailTab, setDetailTab] = useState("brand");
- const [pageTab, setPageTab] = useState("brands");
+ const [pageTab, setPageTab] = useState(
+ searchParams.get("tab") === "serviceProfile" ? "serviceProfile" : "brands",
+ );
+
+ // 讓「去填服務檔案」CTA(?tab=serviceProfile)可以直接落在對的分頁,
+ // 之後使用者手動點分頁時不再受這個參數牽動。
+ useEffect(() => {
+ if (searchParams.get("tab") === "serviceProfile") {
+ setPageTab("serviceProfile");
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ next.delete("tab");
+ return next;
+ }, { replace: true });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const [showCreate, setShowCreate] = useState(false);
const [showProductForm, setShowProductForm] = useState(false);
diff --git a/apps/web/src/pages/RadarTodayPage.tsx b/apps/web/src/pages/RadarTodayPage.tsx
index b018315..6ccdc71 100644
--- a/apps/web/src/pages/RadarTodayPage.tsx
+++ b/apps/web/src/pages/RadarTodayPage.tsx
@@ -44,7 +44,7 @@ function statusTone(status: string): BadgeTone {
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey: string } | null {
switch (reason) {
case "no_profile":
- return { to: "/app/brands", labelKey: "radar.today.empty.goProfile" };
+ return { to: "/app/brands?tab=serviceProfile", labelKey: "radar.today.empty.goProfile" };
case "no_watch":
case "all_watches_paused":
case "not_swept_yet":
diff --git a/apps/web/src/pages/RadarWatchesPage.test.tsx b/apps/web/src/pages/RadarWatchesPage.test.tsx
index 0f20288..8cf5f78 100644
--- a/apps/web/src/pages/RadarWatchesPage.test.tsx
+++ b/apps/web/src/pages/RadarWatchesPage.test.tsx
@@ -151,7 +151,7 @@ describe("RadarWatchesPage", () => {
expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled();
expect(
screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"),
- ).toBe("/app/brands");
+ ).toBe("/app/brands?tab=serviceProfile");
});
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {
diff --git a/apps/web/src/pages/RadarWatchesPage.tsx b/apps/web/src/pages/RadarWatchesPage.tsx
index 0ac6e64..d02f8b7 100644
--- a/apps/web/src/pages/RadarWatchesPage.tsx
+++ b/apps/web/src/pages/RadarWatchesPage.tsx
@@ -59,6 +59,7 @@ export function RadarWatchesPage() {
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
+ const [justTriggeredFirstSweep, setJustTriggeredFirstSweep] = useState(false);
const load = useCallback(async () => {
const res = await repos.radar.listWatches(page, PAGE_SIZE, statusFilter || undefined);
@@ -124,14 +125,18 @@ export function RadarWatchesPage() {
}
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
- async function run(key: string, action: () => Promise, okMessage: string): Promise {
+ async function run(
+ key: string,
+ action: () => Promise,
+ okMessage: string | (() => string),
+ ): Promise {
setBusy(key);
setError("");
setMessage("");
try {
await action();
await load();
- setMessage(okMessage);
+ setMessage(typeof okMessage === "function" ? okMessage() : okMessage);
return true;
} catch (e) {
// 配額、缺服務檔案都由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
@@ -145,30 +150,36 @@ export function RadarWatchesPage() {
async function save() {
const terms = splitTerms(draft.terms);
const excludeTerms = splitTerms(draft.excludeTerms);
- const ok = draft.id
- ? await run(
- "save",
- async () => {
- await repos.radar.updateWatch(draft.id, {
- terms,
- exclude_terms: excludeTerms,
- regions: draft.regions,
- });
- },
- t("radar.watches.updated"),
- )
- : await run(
- "save",
- async () => {
- await repos.radar.createWatch({
- terms,
- exclude_terms: excludeTerms,
- regions: draft.regions,
- enabled: draft.enabled,
- });
- },
- t("radar.watches.created"),
- );
+ if (draft.id) {
+ const ok = await run(
+ "save",
+ async () => {
+ await repos.radar.updateWatch(draft.id, {
+ terms,
+ exclude_terms: excludeTerms,
+ regions: draft.regions,
+ });
+ },
+ t("radar.watches.updated"),
+ );
+ if (ok) setFormOpen(false);
+ return;
+ }
+ let firstSweepTriggered = false;
+ const ok = await run(
+ "save",
+ async () => {
+ const created = await repos.radar.createWatch({
+ terms,
+ exclude_terms: excludeTerms,
+ regions: draft.regions,
+ enabled: draft.enabled,
+ });
+ firstSweepTriggered = Boolean(created.first_sweep_triggered);
+ },
+ () => (firstSweepTriggered ? t("radar.watches.createdFirstSweep") : t("radar.watches.created")),
+ );
+ setJustTriggeredFirstSweep(ok && firstSweepTriggered);
// 失敗就留著表單,讓使用者改完再送(例如關鍵字太短、配額滿了要先暫停別的)。
if (ok) setFormOpen(false);
}
@@ -181,7 +192,7 @@ export function RadarWatchesPage() {
{t("radar.watches.needProfile")}
{t("radar.watches.needProfileHint")}
-
+
{t("radar.watches.goProfile")}
@@ -195,6 +206,12 @@ export function RadarWatchesPage() {
{message ? (
{message}
+ {justTriggeredFirstSweep ? (
+ <>
+ {" "}
+ {t("today.radar.open")}
+ >
+ ) : null}
) : null}
diff --git a/apps/web/src/pages/TodayPage.tsx b/apps/web/src/pages/TodayPage.tsx
index 9ef3adc..7b2e6ae 100644
--- a/apps/web/src/pages/TodayPage.tsx
+++ b/apps/web/src/pages/TodayPage.tsx
@@ -16,6 +16,7 @@ import type {
} from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
+import { dismissRadarOnboarding, isRadarOnboardingDismissed } from "../lib/radarOnboarding";
import { loadScoutToday } from "../lib/scoutToday";
function isPendingScout(p: ScoutPost): boolean {
@@ -64,6 +65,7 @@ export function TodayPage() {
const [outcomeSummary, setOutcomeSummary] = useState(null);
const [checkup, setCheckup] = useState(null);
const [radarToday, setRadarToday] = useState(null);
+ const [onboardingDismissed, setOnboardingDismissed] = useState(() => isRadarOnboardingDismissed());
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
@@ -242,6 +244,30 @@ export function TodayPage() {
outcomeSummary.conversions),
);
+ // 三步引導只從 radarToday.empty_reason 推斷,不額外打 API:
+ // 有結果或曾巡過(含暫停/失敗/沒命中)代表訂閱已建立。
+ const onboardingProfileDone = Boolean(
+ radarToday && radarToday.empty_reason !== "no_profile",
+ );
+ const onboardingWatchDone = Boolean(
+ radarToday &&
+ (radarToday.stats.total > 0 ||
+ ["not_swept_yet", "sweep_failed", "no_hit", "all_watches_paused"].includes(
+ radarToday.empty_reason || "",
+ )),
+ );
+ const onboardingOpportunityDone = Boolean(radarToday && radarToday.stats.total > 0);
+ const onboardingAllDone =
+ onboardingProfileDone && onboardingWatchDone && onboardingOpportunityDone;
+ const showOnboarding = Boolean(radarToday) && !onboardingAllDone && !onboardingDismissed;
+
+ useEffect(() => {
+ if (onboardingAllDone && !onboardingDismissed) {
+ dismissRadarOnboarding();
+ setOnboardingDismissed(true);
+ }
+ }, [onboardingAllDone, onboardingDismissed]);
+
async function onRefreshTrends() {
setRefreshingTrends(true);
setError("");
@@ -309,6 +335,58 @@ export function TodayPage() {
) : null}
+ {showOnboarding ? (
+
+
+ {[
+ {
+ key: "profile",
+ done: onboardingProfileDone,
+ to: "/app/brands?tab=serviceProfile",
+ label: t("today.onboarding.step.profile"),
+ hint: t("today.onboarding.step.profileHint"),
+ },
+ {
+ key: "watch",
+ done: onboardingWatchDone,
+ to: "/app/radar/watches",
+ label: t("today.onboarding.step.watch"),
+ hint: t("today.onboarding.step.watchHint"),
+ },
+ {
+ key: "opportunity",
+ done: onboardingOpportunityDone,
+ to: "/app/radar/today",
+ label: t("today.onboarding.step.opportunity"),
+ hint: t("today.onboarding.step.opportunityHint"),
+ },
+ ].map((step) => (
+ -
+
+
+
+ {step.done ? t("today.onboarding.step.done") : t("today.onboarding.step.go")}
+ {" "}
+ {step.label}
+
+ {step.hint}
+
+
+ ))}
+
+
+
+ ) : null}
+
{radarToday && radarToday.stats.total > 0 ? (
<>
@@ -337,7 +415,13 @@ export function TodayPage() {
) : (
{radarToday?.empty_hint || t("today.radar.empty")}{" "}
-
+
{radarToday?.empty_reason === "no_profile"
? t("today.radar.goProfile")
: t("today.radar.goWatches")}