add today onboarding checklist, fix service profile entry, trigger first sweep on signup

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-08-06 14:50:36 +00:00
parent ef8e1c7528
commit 7938b2efa8
17 changed files with 301 additions and 53 deletions

View File

@ -58,6 +58,8 @@ type (
LastSweptAt int64 `json:"last_swept_at,optional"` LastSweptAt int64 `json:"last_swept_at,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
// 只在建立當下這是使用者第一組訂閱、且已排入首巡時為 true不是持久狀態僅供前端顯示一次性提示。
FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
} }
ListWatchesReq { ListWatchesReq {

View File

@ -1101,12 +1101,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches", Path: "/watches",
Handler: radar.CreateWatchHandler(serverCtx), Handler: radar.CreateWatchHandler(serverCtx),
}, },
// 靜態路徑必須在 /watches/:id 之前,否則 suggest 會被當成 id。
{
Method: http.MethodPost,
Path: "/watches/suggest",
Handler: radar.SuggestWatchTermsHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/watches/:id", Path: "/watches/:id",
@ -1137,6 +1131,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/watches/:id/sweep", Path: "/watches/:id/sweep",
Handler: radar.TriggerWatchSweepHandler(serverCtx), Handler: radar.TriggerWatchSweepHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/watches/suggest",
Handler: radar.SuggestWatchTermsHandler(serverCtx),
},
}..., }...,
), ),
rest.WithPrefix("/api/v1/radar"), rest.WithPrefix("/api/v1/radar"),

View File

@ -84,6 +84,7 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
LastSweptAt: w.LastSweptAt, LastSweptAt: w.LastSweptAt,
CreatedAt: w.CreatedAt, CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt, UpdatedAt: w.UpdatedAt,
FirstSweepTriggered: w.FirstSweepTriggered,
} }
} }

View File

@ -32,6 +32,10 @@ type RadarWatch struct {
LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"` LastSweptAt int64 `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"` CreatedAt int64 `bson:"created_at" json:"created_at"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"` UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
// FirstSweepTriggered 是建立當下的一次性狀態,不落庫:只用來讓前端在
// 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
FirstSweepTriggered bool `bson:"-" json:"-"`
} }
type WatchListFilter struct { type WatchListFilter struct {

View File

@ -43,14 +43,26 @@ func (s *Service) CreateWatch(ctx context.Context, ownerUID int64, in WatchInput
if err := w.Normalize(); err != nil { if err := w.Normalize(); err != nil {
return nil, err return nil, err
} }
firstEverWatch := false
if status == domain.WatchActive { if status == domain.WatchActive {
if err := s.assertCanActivate(ctx, ownerUID, ""); err != nil { if err := s.assertCanActivate(ctx, ownerUID, ""); err != nil {
return nil, err 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 { if err := s.Repo.SaveWatch(ctx, w); err != nil {
return nil, err 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 return w, nil
} }

View File

@ -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) { func TestWatchTransitionMatrix(t *testing.T) {
allowed := map[string][]string{ allowed := map[string][]string{
domain.WatchActive: {domain.WatchPaused, domain.WatchArchived}, domain.WatchActive: {domain.WatchPaused, domain.WatchArchived},

View File

@ -1674,6 +1674,7 @@ type RadarWatchPublic struct {
LastSweptAt int64 `json:"last_swept_at,optional"` LastSweptAt int64 `json:"last_swept_at,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
FirstSweepTriggered bool `json:"first_sweep_triggered,optional"`
} }
type RemoveWorkspaceMemberReq struct { type RemoveWorkspaceMemberReq struct {

View File

@ -99,6 +99,7 @@ function mapWatch(raw: Raw): RadarWatch {
last_swept_at: optNum(raw.last_swept_at), last_swept_at: optNum(raw.last_swept_at),
created_at: num(raw.created_at), created_at: num(raw.created_at),
updated_at: num(raw.updated_at), updated_at: num(raw.updated_at),
first_sweep_triggered: Boolean(raw.first_sweep_triggered),
}; };
} }

View File

@ -63,4 +63,6 @@ export const KEYS = {
ownPostsSyncMeta: "harbor.own_posts.sync_meta", ownPostsSyncMeta: "harbor.own_posts.sync_meta",
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */ /** 帳號月度分析歷史 AccountInsightsSnapshot[] */
accountInsightHistory: "harbor.account.insight_history", accountInsightHistory: "harbor.account.insight_history",
/** 使用者手動關掉 Today 頁「上手三步」引導後不再顯示 */
radarOnboardingDismissed: "harbor.radar.onboarding_dismissed",
} as const; } as const;

View File

@ -845,6 +845,8 @@ export type RadarWatch = {
last_swept_at?: number; last_swept_at?: number;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
/** 只在建立當下是使用者第一組訂閱且已排入首巡時為 true非持久狀態。 */
first_sweep_triggered?: boolean;
}; };
export type WatchTermSuggestion = { export type WatchTermSuggestion = {

View File

@ -2168,6 +2168,7 @@ export const zhTW: MessageDict = {
"radar.watches.archive": "封存", "radar.watches.archive": "封存",
"radar.watches.confirmArchive": "封存後不會再巡,也不能恢復。要繼續嗎?", "radar.watches.confirmArchive": "封存後不會再巡,也不能恢復。要繼續嗎?",
"radar.watches.created": "訂閱已建立", "radar.watches.created": "訂閱已建立",
"radar.watches.createdFirstSweep": "訂閱已建立,首巡已排入,約幾分鐘後回今日商機頁看結果(之後每天自動巡,不用再等)",
"radar.watches.updated": "訂閱已更新", "radar.watches.updated": "訂閱已更新",
"radar.watches.paused": "訂閱已暫停", "radar.watches.paused": "訂閱已暫停",
"radar.watches.resumed": "訂閱已恢復", "radar.watches.resumed": "訂閱已恢復",
@ -2191,6 +2192,17 @@ export const zhTW: MessageDict = {
"today.radar.goProfile": "先填服務檔案", "today.radar.goProfile": "先填服務檔案",
"today.radar.goWatches": "去訂閱關鍵字", "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.title": "關鍵字建議",
"radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。", "radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
"radar.suggest.ask": "取得建議", "radar.suggest.ask": "取得建議",
@ -4512,6 +4524,7 @@ export const en: MessageDict = {
"radar.watches.archive": "Archive", "radar.watches.archive": "Archive",
"radar.watches.confirmArchive": "Archived watches stop sweeping and cannot be restored. Continue?", "radar.watches.confirmArchive": "Archived watches stop sweeping and cannot be restored. Continue?",
"radar.watches.created": "Watch created", "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.updated": "Watch updated",
"radar.watches.paused": "Watch paused", "radar.watches.paused": "Watch paused",
"radar.watches.resumed": "Watch resumed", "radar.watches.resumed": "Watch resumed",
@ -4536,6 +4549,17 @@ export const en: MessageDict = {
"today.radar.goProfile": "Fill service profile", "today.radar.goProfile": "Fill service profile",
"today.radar.goWatches": "Add keyword watches", "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.title": "Term suggestions",
"radar.suggest.hint": "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.", "Terms drawn from your service profile. Adopt them one by one or all at once; you still need to save to create the watch.",

View File

@ -0,0 +1,14 @@
import { readJson, writeJson } from "./storage";
import { KEYS } from "../data/mock/keys";
/**
* Today per browser
* 使
*/
export function isRadarOnboardingDismissed(): boolean {
return readJson<boolean>(KEYS.radarOnboardingDismissed, false);
}
export function dismissRadarOnboarding(): void {
writeJson(KEYS.radarOnboardingDismissed, true);
}

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, EmptyState, Input, Pager, Textarea } from "../components/ui"; import { Badge, Button, EmptyState, Input, Pager, Textarea } from "../components/ui";
import { ServiceProfileForm } from "../components/radar/ServiceProfileForm"; import { ServiceProfileForm } from "../components/radar/ServiceProfileForm";
@ -32,12 +33,29 @@ export function BrandsPage() {
const repos = useRepos(); const repos = useRepos();
const { refresh, tick } = useData(); const { refresh, tick } = useData();
const { t } = useI18n(); const { t } = useI18n();
const [searchParams, setSearchParams] = useSearchParams();
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]); const [products, setProducts] = useState<BrandProduct[]>([]);
const [brandId, setBrandId] = useState(""); const [brandId, setBrandId] = useState("");
const [detailTab, setDetailTab] = useState<DetailTab>("brand"); const [detailTab, setDetailTab] = useState<DetailTab>("brand");
const [pageTab, setPageTab] = useState<PageTab>("brands"); const [pageTab, setPageTab] = useState<PageTab>(
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 [showCreate, setShowCreate] = useState(false);
const [showProductForm, setShowProductForm] = useState(false); const [showProductForm, setShowProductForm] = useState(false);

View File

@ -44,7 +44,7 @@ function statusTone(status: string): BadgeTone {
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey: string } | null { function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey: string } | null {
switch (reason) { switch (reason) {
case "no_profile": 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 "no_watch":
case "all_watches_paused": case "all_watches_paused":
case "not_swept_yet": case "not_swept_yet":

View File

@ -151,7 +151,7 @@ describe("RadarWatchesPage", () => {
expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled(); expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled();
expect( expect(
screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"), screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"),
).toBe("/app/brands"); ).toBe("/app/brands?tab=serviceProfile");
}); });
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => { it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {

View File

@ -59,6 +59,7 @@ export function RadarWatchesPage() {
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [justTriggeredFirstSweep, setJustTriggeredFirstSweep] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const res = await repos.radar.listWatches(page, PAGE_SIZE, statusFilter || undefined); const res = await repos.radar.listWatches(page, PAGE_SIZE, statusFilter || undefined);
@ -124,14 +125,18 @@ export function RadarWatchesPage() {
} }
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */ /** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
async function run(key: string, action: () => Promise<void>, okMessage: string): Promise<boolean> { async function run(
key: string,
action: () => Promise<void>,
okMessage: string | (() => string),
): Promise<boolean> {
setBusy(key); setBusy(key);
setError(""); setError("");
setMessage(""); setMessage("");
try { try {
await action(); await action();
await load(); await load();
setMessage(okMessage); setMessage(typeof okMessage === "function" ? okMessage() : okMessage);
return true; return true;
} catch (e) { } catch (e) {
// 配額、缺服務檔案都由後端回可讀原因(含上限與升級提示),前端不自行推測文案。 // 配額、缺服務檔案都由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
@ -145,8 +150,8 @@ export function RadarWatchesPage() {
async function save() { async function save() {
const terms = splitTerms(draft.terms); const terms = splitTerms(draft.terms);
const excludeTerms = splitTerms(draft.excludeTerms); const excludeTerms = splitTerms(draft.excludeTerms);
const ok = draft.id if (draft.id) {
? await run( const ok = await run(
"save", "save",
async () => { async () => {
await repos.radar.updateWatch(draft.id, { await repos.radar.updateWatch(draft.id, {
@ -156,19 +161,25 @@ export function RadarWatchesPage() {
}); });
}, },
t("radar.watches.updated"), t("radar.watches.updated"),
) );
: await run( if (ok) setFormOpen(false);
return;
}
let firstSweepTriggered = false;
const ok = await run(
"save", "save",
async () => { async () => {
await repos.radar.createWatch({ const created = await repos.radar.createWatch({
terms, terms,
exclude_terms: excludeTerms, exclude_terms: excludeTerms,
regions: draft.regions, regions: draft.regions,
enabled: draft.enabled, enabled: draft.enabled,
}); });
firstSweepTriggered = Boolean(created.first_sweep_triggered);
}, },
t("radar.watches.created"), () => (firstSweepTriggered ? t("radar.watches.createdFirstSweep") : t("radar.watches.created")),
); );
setJustTriggeredFirstSweep(ok && firstSweepTriggered);
// 失敗就留著表單,讓使用者改完再送(例如關鍵字太短、配額滿了要先暫停別的)。 // 失敗就留著表單,讓使用者改完再送(例如關鍵字太短、配額滿了要先暫停別的)。
if (ok) setFormOpen(false); if (ok) setFormOpen(false);
} }
@ -181,7 +192,7 @@ export function RadarWatchesPage() {
<div className="hb-radar-empty" role="status"> <div className="hb-radar-empty" role="status">
<strong>{t("radar.watches.needProfile")}</strong> <strong>{t("radar.watches.needProfile")}</strong>
<span>{t("radar.watches.needProfileHint")}</span> <span>{t("radar.watches.needProfileHint")}</span>
<Link className="hb-btn hb-btn--secondary" to="/app/brands"> <Link className="hb-btn hb-btn--secondary" to="/app/brands?tab=serviceProfile">
{t("radar.watches.goProfile")} {t("radar.watches.goProfile")}
</Link> </Link>
</div> </div>
@ -195,6 +206,12 @@ export function RadarWatchesPage() {
{message ? ( {message ? (
<p className="hb-banner-ok" role="status"> <p className="hb-banner-ok" role="status">
{message} {message}
{justTriggeredFirstSweep ? (
<>
{" "}
<Link to="/app/radar/today">{t("today.radar.open")}</Link>
</>
) : null}
</p> </p>
) : null} ) : null}

View File

@ -16,6 +16,7 @@ import type {
} from "../domain/types"; } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
import { dismissRadarOnboarding, isRadarOnboardingDismissed } from "../lib/radarOnboarding";
import { loadScoutToday } from "../lib/scoutToday"; import { loadScoutToday } from "../lib/scoutToday";
function isPendingScout(p: ScoutPost): boolean { function isPendingScout(p: ScoutPost): boolean {
@ -64,6 +65,7 @@ export function TodayPage() {
const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null); const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null);
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null); const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
const [radarToday, setRadarToday] = useState<RadarToday | null>(null); const [radarToday, setRadarToday] = useState<RadarToday | null>(null);
const [onboardingDismissed, setOnboardingDismissed] = useState(() => isRadarOnboardingDismissed());
const dateLocale = locale === "en" ? "en-US" : "zh-TW"; const dateLocale = locale === "en" ? "en-US" : "zh-TW";
@ -242,6 +244,30 @@ export function TodayPage() {
outcomeSummary.conversions), 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() { async function onRefreshTrends() {
setRefreshingTrends(true); setRefreshingTrends(true);
setError(""); setError("");
@ -309,6 +335,58 @@ export function TodayPage() {
</p> </p>
) : null} ) : null}
{showOnboarding ? (
<Card title={t("today.onboarding.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
<ul className="hb-today-list">
{[
{
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) => (
<li key={step.key}>
<Link to={step.to} className="hb-today-list__item">
<span className="hb-today-list__meta">
<Badge tone={step.done ? "success" : "neutral"}>
{step.done ? t("today.onboarding.step.done") : t("today.onboarding.step.go")}
</Badge>{" "}
{step.label}
</span>
<span className="hb-today-list__text">{step.hint}</span>
</Link>
</li>
))}
</ul>
<Button
type="button"
variant="ghost"
onClick={() => {
dismissRadarOnboarding();
setOnboardingDismissed(true);
}}
>
{t("today.onboarding.dismiss")}
</Button>
</Card>
) : null}
<Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}> <Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
{radarToday && radarToday.stats.total > 0 ? ( {radarToday && radarToday.stats.total > 0 ? (
<> <>
@ -337,7 +415,13 @@ export function TodayPage() {
) : ( ) : (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{radarToday?.empty_hint || t("today.radar.empty")}{" "} {radarToday?.empty_hint || t("today.radar.empty")}{" "}
<Link to={radarToday?.empty_reason === "no_profile" ? "/app/brands" : "/app/radar/watches"}> <Link
to={
radarToday?.empty_reason === "no_profile"
? "/app/brands?tab=serviceProfile"
: "/app/radar/watches"
}
>
{radarToday?.empty_reason === "no_profile" {radarToday?.empty_reason === "no_profile"
? t("today.radar.goProfile") ? t("today.radar.goProfile")
: t("today.radar.goWatches")} : t("today.radar.goWatches")}