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:
parent
ef8e1c7528
commit
7938b2efa8
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<Brand[]>([]);
|
||||
const [products, setProducts] = useState<BrandProduct[]>([]);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
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 [showProductForm, setShowProductForm] = useState(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void>, okMessage: string): Promise<boolean> {
|
||||
async function run(
|
||||
key: string,
|
||||
action: () => Promise<void>,
|
||||
okMessage: string | (() => string),
|
||||
): Promise<boolean> {
|
||||
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() {
|
|||
<div className="hb-radar-empty" role="status">
|
||||
<strong>{t("radar.watches.needProfile")}</strong>
|
||||
<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")}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -195,6 +206,12 @@ export function RadarWatchesPage() {
|
|||
{message ? (
|
||||
<p className="hb-banner-ok" role="status">
|
||||
{message}
|
||||
{justTriggeredFirstSweep ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link to="/app/radar/today">{t("today.radar.open")}</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OutcomeSummary | null>(null);
|
||||
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
|
||||
const [radarToday, setRadarToday] = useState<RadarToday | null>(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() {
|
|||
</p>
|
||||
) : 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" }}>
|
||||
{radarToday && radarToday.stats.total > 0 ? (
|
||||
<>
|
||||
|
|
@ -337,7 +415,13 @@ export function TodayPage() {
|
|||
) : (
|
||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
||||
{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"
|
||||
? t("today.radar.goProfile")
|
||||
: t("today.radar.goWatches")}
|
||||
|
|
|
|||
Loading…
Reference in New Issue