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"`
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,15 @@ func Watch(w *domain.RadarWatch) *types.RadarWatchPublic {
|
||||||
regions = []string{}
|
regions = []string{}
|
||||||
}
|
}
|
||||||
return &types.RadarWatchPublic{
|
return &types.RadarWatchPublic{
|
||||||
Id: w.ID,
|
Id: w.ID,
|
||||||
Terms: terms,
|
Terms: terms,
|
||||||
ExcludeTerms: excludes,
|
ExcludeTerms: excludes,
|
||||||
Regions: regions,
|
Regions: regions,
|
||||||
Status: w.Status,
|
Status: w.Status,
|
||||||
LastSweptAt: w.LastSweptAt,
|
LastSweptAt: w.LastSweptAt,
|
||||||
CreatedAt: w.CreatedAt,
|
CreatedAt: w.CreatedAt,
|
||||||
UpdatedAt: w.UpdatedAt,
|
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"`
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -1666,14 +1666,15 @@ type RadarTodayStats struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RadarWatchPublic struct {
|
type RadarWatchPublic struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Terms []string `json:"terms"`
|
Terms []string `json:"terms"`
|
||||||
ExcludeTerms []string `json:"exclude_terms"`
|
ExcludeTerms []string `json:"exclude_terms"`
|
||||||
Regions []string `json:"regions"`
|
Regions []string `json:"regions"`
|
||||||
Status string `json:"status"` // active | paused | archived
|
Status string `json:"status"` // active | paused | archived
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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 { 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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,30 +150,36 @@ 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, {
|
||||||
terms,
|
terms,
|
||||||
exclude_terms: excludeTerms,
|
exclude_terms: excludeTerms,
|
||||||
regions: draft.regions,
|
regions: draft.regions,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
t("radar.watches.updated"),
|
t("radar.watches.updated"),
|
||||||
)
|
);
|
||||||
: await run(
|
if (ok) setFormOpen(false);
|
||||||
"save",
|
return;
|
||||||
async () => {
|
}
|
||||||
await repos.radar.createWatch({
|
let firstSweepTriggered = false;
|
||||||
terms,
|
const ok = await run(
|
||||||
exclude_terms: excludeTerms,
|
"save",
|
||||||
regions: draft.regions,
|
async () => {
|
||||||
enabled: draft.enabled,
|
const created = await repos.radar.createWatch({
|
||||||
});
|
terms,
|
||||||
},
|
exclude_terms: excludeTerms,
|
||||||
t("radar.watches.created"),
|
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);
|
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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue