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")}