fix all bug

This commit is contained in:
王性驊 2026-08-19 07:37:44 +00:00
parent 3b113feb3a
commit 2f1336b932
48 changed files with 1560 additions and 2248 deletions

View File

@ -43,7 +43,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W
if err != nil {
return nil, err
}
// profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒
// profile_exists 讓雷達頁顯示「服務檔案可之後再補」,不再當硬門檻
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
if err != nil {
return nil, err

View File

@ -129,30 +129,21 @@ func (e *m1Env) sweepCandidates(t *testing.T) []string {
return ids
}
// SP-01新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) {
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsAllowed(t *testing.T) {
env := newM1Env(t, 42, 5)
_, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
w, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("SP-01: active watch was created without a service profile")
if err != nil {
t.Fatalf("SP-01: active watch without profile: %v", err)
}
envelope := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(envelope.Message, "service-profile") {
t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message)
if w.Status != "active" {
t.Fatalf("SP-01: status = %q, want active", w.Status)
}
// 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。
if got := env.list(t, ""); got.Pagination.Total != 0 {
t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total)
}
// 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。
env.createWatch(t, "婚攝 推薦", false)
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 {
t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got)
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 1 {
t.Fatalf("SP-01: list = %+v, want one active watch", got)
}
}

View File

@ -39,20 +39,18 @@ func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
return w
}
// SP-01沒建服務檔案就建 active 訂閱 → 400100訊息要指向服務檔案而不是只說「失敗」。
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
func TestCreateActiveWatchWithoutProfileSucceeds(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, false)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("active watch created without a service profile")
if err != nil {
t.Fatalf("active watch without profile: %v", err)
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "service-profile") {
t.Fatalf("message must point at the service profile, got %q", env.Message)
if w.Status != "active" {
t.Fatalf("status = %q, want active", w.Status)
}
}

View File

@ -40,7 +40,7 @@ func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic {
ServiceProfile domain 檔案轉成 API 形狀
p nil 代表使用者還沒建檔 exists=false 的空殼而不是 404 表單本來就要能開空的
exists 這個欄位必須誠實訂閱閘門SP-01與前端引導都看它
exists 這個欄位必須誠實前端用它決定要不要顯示之後再補提示
*/
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
if p == nil {

View File

@ -84,8 +84,7 @@ type FaqItem struct {
/*
ServiceProfile 每會員一份_id 就是 owner_uid
這份檔案是判定與回覆生成的共同輸入沒有它五問判定沒有比對基準回覆也沒有
價格與案例可講所以未建檔時不允許建立 active 訂閱SP-01
這份檔案是判定與回覆生成的加分輸入沒有它仍可用關鍵字巡邏只是比對與回覆較通用
*/
type ServiceProfile struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`

View File

@ -2,6 +2,7 @@ package usecase
import (
"context"
"time"
"github.com/zeromicro/go-zero/core/logx"
)
@ -57,7 +58,10 @@ func (c *charge) Release(ctx context.Context) {
return
}
c.settled = true
if err := c.svc.Usage.ReleaseCall(ctx, c.uid, c.meter, c.mode); err != nil {
// 要補償的失敗常常就是「請求被取消」,退點必須用活得比它久的 context否則使用者被扣點。
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := c.svc.Usage.ReleaseCall(rctx, c.uid, c.meter, c.mode); err != nil {
logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
}
}

View File

@ -35,8 +35,8 @@ func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("generic watch without profile err=%v", err)
if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil {
t.Fatalf("generic watch without profile should resume: %v", err)
}
assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1")
if err != nil {

View File

@ -67,8 +67,7 @@ func New(repo domain.Repository) *Service {
GetServiceProfile 未建檔時回 domain.ErrNotFound由呼叫端決定怎麼表達
HTTP 層會把它翻成 exists=false 200表單本來就要能開空的 usecase 不能
自己回一個零值檔案 那樣沒建檔建了一份空的就分不出來 SP-01
訂閱閘門正是靠這個差別
自己回一個零值檔案 那樣沒建檔建了一份空的就分不出來
*/
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
if ownerUID <= 0 {

View File

@ -128,7 +128,7 @@ func TestUpsertRequiresAtLeastOneService(t *testing.T) {
}
}
// 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。
// 「沒建檔」與「建了一份空的」必須分得出來:列表的 profile_exists 靠這個差別。
func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) {
svc := newTestService()
ctx := context.Background()

View File

@ -40,17 +40,17 @@ func productSuggestPrompt(p *ProductContextSnapshot, limit int) string {
}
/*
suggestPrompt 用服務檔案組建議關鍵字的提示
suggestPrompt 組建議關鍵字的提示
素材全部來自使用者自己填的服務檔案服務項目價格區間案例地區禁語
沒有服務檔案就不呼叫 AI SuggestWatchTerms 沒有依據的建議只是猜測
而使用者會把它當成系統的判斷
有服務檔案就帶服務項目價格案例地區禁語沒有也能給通用求助短詞
*/
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
var b strings.Builder
b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,")
b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,")
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
if p == nil {
b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n")
} else {
b.WriteString("服務項目:\n")
for _, s := range p.Services {
b.WriteString("- " + s.Name)
@ -95,6 +95,7 @@ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
if p.ToneNote != "" {
b.WriteString("語氣備註:" + p.ToneNote + "\n")
}
}
if len(extra) > 0 {
// 既有痛點關鍵字工具的產出當素材不另建第二套關鍵字引擎T514 決策)。
b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n")
@ -123,10 +124,11 @@ type PainTermSource interface {
}
/*
SuggestWatchTerms 依服務檔案回關鍵字建議RW-03
SuggestWatchTerms 回關鍵字建議RW-03
不自動寫入任何 watch使用者逐條採用才有意義也才看得懂每個詞是為什麼在那裡
計費走既有 ai_copy metersource radar.suggestspec §5.5不新增第五個 meter
服務檔案是加分項不是門檻AI 不可用時改走通用短詞避免建議關鍵字變成下一扇牆
不自動寫入任何 watch使用者逐條採用才有意義
計費走既有 ai_copy metersource radar.suggestspec §5.5fallback 不扣點
*/
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
if ownerUID <= 0 {
@ -140,15 +142,12 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i
}
profile, err := s.Repo.GetServiceProfile(ctx, ownerUID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf(
"%w: service profile required before suggesting keywords; fill in /api/v1/radar/service-profile first",
domain.ErrValidation,
)
}
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
if errors.Is(err, domain.ErrNotFound) {
profile = nil
}
var extra []string
if s.PainTerms != nil {
@ -166,18 +165,69 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i
}
defer charge.Settle(ctx, &err)
raw, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
if err != nil {
return nil, err
raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
var out []domain.WatchTermSuggestion
if aiErr == nil {
out = domain.CleanSuggestions(parseSuggestions(raw), limit)
} else {
logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr)
}
out := domain.CleanSuggestions(parseSuggestions(raw), limit)
if len(out) == 0 {
// 空清單會被讀成「你的服務沒有關鍵字可監控」,那是錯的訊息。
out = genericSuggestFallback(profile, extra, limit)
}
if len(out) == 0 {
if aiErr != nil {
return nil, aiErr
}
return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation)
}
if aiErr != nil {
// 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。
charge.Release(ctx)
}
return out, nil
}
func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion {
raw := make([]domain.WatchTermSuggestion, 0, limit+8)
add := func(term, reason, usage string) {
term = strings.TrimSpace(term)
reason = strings.TrimSpace(reason)
if term == "" || reason == "" {
return
}
raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
}
if p != nil {
for _, item := range p.Services {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
for _, term := range domain.SearchableTermVariants(name, true) {
add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude)
}
}
}
for _, term := range extra {
basis := strings.TrimSpace(term)
if basis == "" {
continue
}
for _, v := range domain.SearchableTermVariants(basis, true) {
add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude)
}
}
add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude)
add("有人知道", "口語求助句,能找到正在發問的人", domain.SuggestUsageInclude)
add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude)
add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude)
add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude)
add("徵才", "招募文不是客人", domain.SuggestUsageExclude)
add("接案", "同業供給文,不是需求", domain.SuggestUsageExclude)
return domain.CleanSuggestions(raw, limit)
}
// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product
// snapshot. If the AI provider is unavailable, the catalog's own structured
// fields are used as a deterministic, traceable fallback.

View File

@ -119,21 +119,23 @@ func TestSuggestDoesNotCreateWatches(t *testing.T) {
}
}
// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。
func TestSuggestRequiresServiceProfile(t *testing.T) {
func TestSuggestWorksWithoutServiceProfile(t *testing.T) {
svc := New(repository.NewMemory())
ai := &stubAI{reply: suggestReply}
svc.AI = ai
_, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("err = %v, want ErrValidation", err)
list, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
if err != nil {
t.Fatalf("suggest without profile: %v", err)
}
if !strings.Contains(err.Error(), "service-profile") {
t.Fatalf("error must point at the service profile, got %q", err)
if len(list) == 0 {
t.Fatal("want generic suggestions when profile is missing")
}
if ai.calls != 0 {
t.Fatal("AI was called without a service profile")
if ai.calls != 1 {
t.Fatalf("AI calls = %d, want 1", ai.calls)
}
if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") {
t.Fatal("prompt should say the profile is missing instead of inventing one")
}
}
@ -196,22 +198,29 @@ func TestSuggestToleratesProseAroundJSON(t *testing.T) {
}
}
// 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」
func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) {
// 模型回空話時改走通用短詞,不要讓「建議關鍵字」整顆按鈕壞掉
func TestSuggestFallsBackOnUnusableReply(t *testing.T) {
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("err = %v, want ErrValidation", err)
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("unusable AI reply should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
}
func TestSuggestSurfacesAIFailure(t *testing.T) {
func TestSuggestFallsBackWhenAIFails(t *testing.T) {
svc, ai, ctx := suggestService(t, "")
ai.err = errors.New("provider down")
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
t.Fatal("AI failure was swallowed")
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("AI failure should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
}
@ -262,34 +271,48 @@ func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) {
}
}
// AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳
// AI 失敗改走 fallback使用者仍拿到詞但沒真正呼叫模型所以要退點
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
svc, ai, ctx := suggestService(t, "")
ai.err = errors.New("provider down")
usage := platformUsage(42)
svc.Usage = usage
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
t.Fatal("AI failure was swallowed")
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("AI failure should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
if err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 0 {
t.Fatalf("charged %d events for a failed call", len(events))
t.Fatalf("charged %d events for a fallback call", len(events))
}
}
func TestSuggestWithoutAIClientAsksForKey(t *testing.T) {
func TestSuggestWithoutAIClientUsesFallback(t *testing.T) {
svc := New(repository.NewMemory())
ctx := context.Background()
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
t.Fatalf("seed profile: %v", err)
}
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err)
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil {
t.Fatalf("missing AI client should fall back: %v", err)
}
if len(list) == 0 {
t.Fatal("fallback returned nothing")
}
found := false
for _, item := range list {
if item.Term == "求推薦" {
found = true
break
}
}
if !found {
t.Fatalf("fallback missing 求推薦: %+v", list)
}
}

View File

@ -61,10 +61,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
// for regional/freshness context when present.
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
} else {
profile, err = s.Repo.GetServiceProfile(ctx, ownerUID)
if err != nil {
return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation)
}
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
}
// Resume: if a sweep already exists for this job, reuse it.

View File

@ -2,7 +2,6 @@ package usecase
import (
"context"
"errors"
"fmt"
"apps/backend/internal/module/radar/domain"
@ -47,12 +46,6 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
}
start, end := domain.UTCDayBounds(domain.NowNano())
// Empty-state diagnostics只有「真的沒建檔」才算 no_profile其他 DB 錯誤要往上丟。
_, profileErr := s.Repo.GetServiceProfile(ctx, ownerUID)
noProfile := errors.Is(profileErr, domain.ErrNotFound)
if profileErr != nil && !noProfile {
return nil, profileErr
}
watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50})
if err != nil {
return nil, err
@ -148,7 +141,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
if productFiltered {
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
} else {
out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start)
out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start)
}
}
return out, nil
@ -173,10 +166,7 @@ func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilt
return false
}
func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
if noProfile {
return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。"
}
func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
if watchCount == 0 {
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
}

View File

@ -82,7 +82,7 @@ func TestGetTodayEmptyNoProfile(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.EmptyReason != "no_profile" {
t.Fatalf("empty_reason = %q, want no_profile", got.EmptyReason)
if got.EmptyReason != "no_watch" {
t.Fatalf("empty_reason = %q, want no_watch", got.EmptyReason)
}
}

View File

@ -152,7 +152,7 @@ func (s *Service) PauseWatch(ctx context.Context, ownerUID int64, id string) (*d
}
/*
ResumeWatch 回到 active因此要再過一次配額與服務檔案暫停期間方案可能已降級
ResumeWatch 回到 active因此要再過一次配額暫停期間方案可能已降級
不重驗就會讓人靠暫停再恢復繞過上限
*/
func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {

View File

@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
}
/*
assertCanActivateForWatch 變成 active兩道閘SP-01RW-01
assertCanActivateForWatch 變成 active配額閘RW-01
exceptWatchID 是正在恢復的那一筆它目前不是 active所以不會被算進 CountActive
帶進來只為了在訊息與計算上表達清楚
@ -78,43 +78,9 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active所以不
既有超額者不強制降級spec §3.1這裡只擋再多一個
*/
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
if productWatch {
_ = productWatch
return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID)
}
hasProfile, err := s.HasServiceProfile(ctx, ownerUID)
if err != nil {
return err
}
if !hasProfile {
return fmt.Errorf(
"%w: service profile required before activating a radar watch; fill in /api/v1/radar/service-profile first",
domain.ErrValidation,
)
}
maxActive, err := s.MaxActiveWatches(ctx, ownerUID)
if err != nil {
return err
}
active, err := s.Repo.CountActiveWatches(ctx, ownerUID)
if err != nil {
return err
}
if exceptWatchID != "" {
if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive {
active--
} else if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
}
if active >= int64(maxActive) {
return fmt.Errorf(
"%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan",
domain.ErrValidation, active, maxActive,
)
}
return nil
}
func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error {
maxActive, err := s.MaxActiveWatches(ctx, ownerUID)

View File

@ -237,21 +237,20 @@ func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) {
}
}
// SP-01沒有服務檔案判定沒有比對基準所以不准有 active 訂閱
func TestActiveWatchRequiresServiceProfile(t *testing.T) {
// 服務檔案是選項:沒建檔也能用關鍵字開巡邏
func TestActiveWatchDoesNotRequireServiceProfile(t *testing.T) {
svc := New(repository.NewMemory())
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
ctx := context.Background()
_, err := svc.CreateWatch(ctx, 42, watchInput())
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("err = %v, want ErrValidation", err)
w, err := svc.CreateWatch(ctx, 42, watchInput())
if err != nil {
t.Fatalf("create active without profile: %v", err)
}
if !strings.Contains(err.Error(), "service-profile") {
t.Fatalf("error must point at the service profile, got %q", err)
if w.Status != domain.WatchActive {
t.Fatalf("status = %q, want active", w.Status)
}
// 但可以先建成 paused 把關鍵字備好。
paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}})
if err != nil {
t.Fatalf("create paused without profile: %v", err)
@ -259,15 +258,8 @@ func TestActiveWatchRequiresServiceProfile(t *testing.T) {
if paused.Status != domain.WatchPaused {
t.Fatalf("status = %q, want paused", paused.Status)
}
// 建檔後才能開起來。
if _, err := svc.ResumeWatch(ctx, 42, paused.ID); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("resume without profile: err = %v, want ErrValidation", err)
}
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
t.Fatalf("upsert profile: %v", err)
}
if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil {
t.Fatalf("resume after profile exists: %v", err)
t.Fatalf("resume without profile: %v", err)
}
}

View File

@ -87,9 +87,9 @@ export default function App() {
<Route path="outbox" element={<OutboxPage />} />
<Route path="outbox/:id" element={<OutboxDetailPage />} />
<Route path="scout" element={<ScoutPage />} />
<Route path="radar" element={<RadarOpportunitiesPage />} />
<Route path="radar" element={<Navigate to="/app/today" replace />} />
<Route path="radar/watches" element={<RadarWatchesPage />} />
<Route path="radar/today" element={<RadarOpportunitiesPage />} />
<Route path="radar/today" element={<Navigate to="/app/radar/opportunities" replace />} />
<Route path="radar/opportunities" element={<RadarOpportunitiesPage />} />
<Route path="crm" element={<CrmBoardPage />} />
<Route path="crm/followups" element={<CrmFollowUpsPage />} />

View File

@ -1,31 +1,27 @@
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { useFirstRun } from "../../firstRun/FirstRunContext";
import { useI18n } from "../../i18n/I18nContext";
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
import type { NavGroupKey, NavItem } from "../../lib/nav";
import { AppIcon } from "../ui/AppIcons";
export function SidebarNav() {
const { pathname } = useLocation();
const { t } = useI18n();
const { active } = useFirstRun();
const [openAdvanced, setOpenAdvanced] = useState<NavGroupKey | null>(null);
const groups = active
? navGroupedItemsByKeys(firstRunNavKeys)
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
return (
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
<p className="hb-sidebar__label">{t("nav.navigate")}</p>
{groups.map(({ group, items }) => (
<div className="hb-nav__group" key={group.key}>
<p className="hb-nav__group-label">{t(group.labelKey)}</p>
<nav className="hb-nav">
{items.map((item) => {
const active = isNavActive(pathname, item);
function renderItem(item: NavItem) {
const current = isNavActive(pathname, item);
return (
<NavLink
key={item.key}
to={item.path}
className={`hb-nav__item${active ? " hb-nav__item--active" : ""}`}
className={`hb-nav__item${current ? " hb-nav__item--active" : ""}`}
>
<span className="hb-nav__ico" aria-hidden>
<AppIcon name={item.key} size={18} />
@ -33,10 +29,33 @@ export function SidebarNav() {
<span>{t(item.labelKey)}</span>
</NavLink>
);
})}
</nav>
}
return (
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
<p className="hb-sidebar__label">{t("nav.navigate")}</p>
{groups.map(({ group, items }) => {
// 人在進階頁裡的時候要看得到自己在哪,不能還要先展開才知道。
const insideGroup = items.some((item) => isNavActive(pathname, item));
const expanded = !group.advanced || openAdvanced === group.key || insideGroup;
return (
<div className="hb-nav__group" key={group.key}>
{group.advanced ? (
<button
type="button"
className="hb-nav__group-toggle"
aria-expanded={expanded}
onClick={() => setOpenAdvanced(expanded ? null : group.key)}
>
{t(group.labelKey)}
</button>
) : (
<p className="hb-nav__group-label">{t(group.labelKey)}</p>
)}
{expanded ? <nav className="hb-nav">{items.map(renderItem)}</nav> : null}
</div>
))}
);
})}
</aside>
);
}

View File

@ -1,20 +1,39 @@
import type { Opportunity } from "../../domain/types";
import { useState } from "react";
import type { IntentBand, Opportunity } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { Badge, Button } from "../ui";
import { PrimaryProductPicker } from "./PrimaryProductPicker";
import { ProductMatchDetails } from "./ProductMatchDetails";
import { ReplyComposer } from "./ReplyComposer";
const BANDS: IntentBand[] = ["high", "mid", "low"];
type Props = {
opportunity: Opportunity;
onClose: () => void;
onAccept?: (opportunity: Opportunity) => void;
onComplete?: (opportunity: Opportunity) => void;
/** 改主推產品(多產品匹配時);沒給就不顯示。 */
onSetPrimary?: (opportunity: Opportunity, productId: string, reason: string) => void;
/** 覆寫意向分級;沒給就不顯示。 */
onOverrideBand?: (opportunity: Opportunity, band: IntentBand) => void;
busy?: boolean;
};
export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComplete, busy = false }: Props) {
export function OpportunityDetailDrawer({
opportunity,
onClose,
onAccept,
onComplete,
onSetPrimary,
onOverrideBand,
busy = false,
}: Props) {
const { t } = useI18n();
const [overrideOpen, setOverrideOpen] = useState(false);
const pending = (opportunity.review_state || "pending") === "pending";
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
const matches = opportunity.product_matches ?? [];
return (
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
<div className="hb-opp-drawer__head">
@ -31,12 +50,27 @@ export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComp
{opportunity.priority_score !== undefined ? <Badge tone="warning">{t("radar.drawer.priority", { n: opportunity.priority_score })}</Badge> : null}
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
</div>
{/* 回覆放在證據上方:看完原文最想做的事就是回他。 */}
<section className="hb-opp-drawer__section">
<h3>{t("radar.drawer.reply")}</h3>
<ReplyComposer opportunity={opportunity} />
</section>
{opportunity.demand_evidence?.length ? (
<section className="hb-opp-drawer__section"><h3>{t("radar.drawer.evidence")}</h3><ul>{opportunity.demand_evidence.map((item) => <li key={item}>{item}</li>)}</ul></section>
) : null}
<section className="hb-opp-drawer__section">
<h3>{t("radar.drawer.matches")}</h3>
{opportunity.product_matches?.length ? opportunity.product_matches.map((match) => <ProductMatchDetails key={match.product_id} match={match} />) : <p className="hb-radar-section__hint">{t("radar.drawer.generic")}</p>}
{matches.length ? matches.map((match) => <ProductMatchDetails key={match.product_id} match={match} />) : <p className="hb-radar-section__hint">{t("radar.drawer.generic")}</p>}
{matches.length > 1 && onSetPrimary ? (
<PrimaryProductPicker
matches={matches}
currentId={opportunity.primary_product_id}
busy={busy}
onSet={(productId, reason) => onSetPrimary(opportunity, productId, reason)}
/>
) : null}
</section>
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3>{t("radar.drawer.judge")}</h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
<div className="hb-opp-drawer__actions">
@ -47,7 +81,27 @@ export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComp
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>{t("radar.card.accept")}</Button>
) : null}
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.drawer.openOriginal")}</a>
{onOverrideBand ? (
<Button type="button" variant="ghost" aria-expanded={overrideOpen} onClick={() => setOverrideOpen((value) => !value)}>
{t("radar.today.action.override")}
</Button>
) : null}
</div>
{overrideOpen && onOverrideBand ? (
<div className="hb-radar-actions">
{BANDS.map((band) => (
<Button
key={band}
type="button"
variant="secondary"
disabled={busy || opportunity.intent_band === band}
onClick={() => onOverrideBand(opportunity, band)}
>
{t(`radar.today.band.${band}`)}
</Button>
))}
</div>
) : null}
{pending ? <p className="hb-field__hint">{t("radar.drawer.hint")}</p> : null}
</div>
</aside>

View File

@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { KEYS } from "../../data/mock/keys";
import type { Opportunity } from "../../domain/types";
import { I18nProvider } from "../../i18n/I18nContext";
import { OpportunityInboxCard } from "./OpportunityInboxCard";
function opp(): Opportunity {
return {
id: "o1",
source: "threads",
external_id: "x",
permalink: "https://www.threads.net/@a/post/x",
author_handle: "buyer",
text: "台北有人推薦美甲嗎",
posted_at: Date.now() * 1e6,
status: "qualified",
intent_score: 80,
intent_band: "high",
reasons: [],
region_match: "unknown",
freshness_hours: 1,
matched_terms: [],
created_at: Date.now() * 1e6,
review_state: "pending",
};
}
describe("OpportunityInboxCard simple", () => {
it("only offers reply or skip", () => {
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
const onComplete = vi.fn();
const onRemove = vi.fn();
render(
<I18nProvider>
<OpportunityInboxCard
opportunity={opp()}
simple
onOpen={vi.fn()}
onAccept={vi.fn()}
onComplete={onComplete}
onRemove={onRemove}
onRestore={vi.fn()}
/>
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "回他" })).toBeTruthy();
expect(screen.getByRole("button", { name: "先跳過" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "留下" })).toBeNull();
expect(screen.queryByRole("button", { name: "丟掉" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "先跳過" }));
expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" }), { reason: "pain_mismatch" });
});
it("回他同時把人加進名單,否則名單永遠是空的", () => {
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
const onAccept = vi.fn();
const onComplete = vi.fn();
vi.spyOn(window, "open").mockReturnValue(null);
render(
<I18nProvider>
<OpportunityInboxCard
opportunity={opp()}
simple
onOpen={vi.fn()}
onAccept={onAccept}
onComplete={onComplete}
onRemove={vi.fn()}
onRestore={vi.fn()}
/>
</I18nProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "回他" }));
expect(window.open).toHaveBeenCalledWith("https://www.threads.net/@a/post/x", "_blank", "noopener,noreferrer");
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" }));
expect(onComplete).not.toHaveBeenCalled();
});
});

View File

@ -22,9 +22,11 @@ type Props = {
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
onRestore: (opportunity: Opportunity) => void;
busy?: boolean;
/** 今日:只留回他/先跳過 */
simple?: boolean;
};
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false }: Props) {
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false, simple = false }: Props) {
const { t } = useI18n();
const [removeOpen, setRemoveOpen] = useState(false);
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
@ -53,6 +55,55 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
setNote("");
}
if (simple) {
return (
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
<div className="hb-opp-card__source">
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
<span>{formatTimeAgo(opportunity.posted_at)}</span>
{/* 「回他」會自己開分頁,但瀏覽器可能攔彈窗,留一條手動入口。 */}
{opportunity.permalink ? (
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.card.openOriginal")}</a>
) : null}
</div>
<p className="hb-opp-card__text">{opportunity.text}</p>
{evidence ? <p className="hb-opp-card__evidence">{evidence}</p> : null}
<div className="hb-opp-card__actions" aria-label={t("radar.card.actionsAria")}>
{pending ? (
<Button
type="button"
variant="primary"
disabled={busy}
onClick={() => {
if (opportunity.permalink) window.open(opportunity.permalink, "_blank", "noopener,noreferrer");
// 回他就是這條線的正向動作:同時進名單,否則追蹤與成交統計拿不到人。
if (accepted) onComplete(opportunity);
else onAccept(opportunity);
}}
>
{t("today.simple.reply")}
</Button>
) : null}
{pending ? (
<Button type="button" variant="secondary" onClick={() => onOpen(opportunity)}>
{t("radar.reply.writeForMe")}
</Button>
) : null}
{pending ? (
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={() => onRemove(opportunity, { reason: "pain_mismatch" })}
>
{t("today.simple.skip")}
</Button>
) : null}
</div>
</article>
);
}
return (
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
<div className="hb-opp-card__meta">

View File

@ -19,12 +19,12 @@ export function ProductWatchForm({ brands, products, brandId, productId, disable
const product = products.find((p) => p.id === productId);
return (
<div className="hb-radar-product-context">
<Select name="radar-watch-brand" label={t("radar.inbox.brand")} value={brandId} disabled={disabled} required onChange={(e) => onBrandChange(e.target.value)}>
<option value="">{t("radar.watches.pickBrand")}</option>
<Select name="radar-watch-brand" label={t("radar.inbox.brand")} value={brandId} disabled={disabled} onChange={(e) => onBrandChange(e.target.value)}>
<option value="">{t("radar.watches.skipBrand")}</option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
</Select>
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} required onChange={(e) => onProductChange(e.target.value)}>
<option value="">{t("radar.watches.pickProduct")}</option>
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} onChange={(e) => onProductChange(e.target.value)}>
<option value="">{t("radar.watches.skipProduct")}</option>
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</Select>
<ProductContextReadiness brand={brand} product={product} />

View File

@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { SERVICE_AREAS } from "./serviceAreas";
import { Button, Textarea } from "../ui";
import { useRepos } from "../../data/DataContext";
import type { Brand, BrandProduct } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { expandIncludeTerms } from "../../lib/threadsTerm";
import { ProductWatchForm } from "./ProductWatchForm";
function splitTerms(raw: string): string[] {
return raw
.split(/[\n,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
type Props = {
/** firstSweepTriggered後端已排第一輪前端不該再叫使用者按「立即巡邏」。 */
onCreated: (result: { firstSweepTriggered: boolean }) => void;
};
/** 今日空狀態:關鍵字就能開工,品牌/產品是選項。 */
export function QuickWatchStart({ onCreated }: Props) {
const { t } = useI18n();
const repos = useRepos();
const formatError = useFormatApiError();
const [terms, setTerms] = useState("");
const [regions, setRegions] = useState<string[]>([]);
const [brandId, setBrandId] = useState("");
const [productId, setProductId] = useState("");
const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const scout = (repos as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
useEffect(() => {
if (!scout) return;
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
}, [scout]);
useEffect(() => {
if (!scout || !brandId) {
setProducts([]);
return;
}
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
}, [scout, brandId]);
async function submit() {
const nextTerms = expandIncludeTerms(splitTerms(terms));
if (!nextTerms.length) {
setError(t("radar.watches.threadsRequired"));
return;
}
if ((brandId && !productId) || (!brandId && productId)) {
setError(t("radar.watches.needBrandProductShort"));
return;
}
setBusy(true);
setError("");
try {
const created = await repos.radar.createWatch({
terms: nextTerms,
regions,
enabled: true,
brand_id: brandId || undefined,
product_id: productId || undefined,
});
onCreated({ firstSweepTriggered: Boolean(created.first_sweep_triggered) });
} catch (e) {
setError(formatError(e));
} finally {
setBusy(false);
}
}
return (
<form
className="hb-radar-form hb-stack"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<p className="hb-radar-section__hint">{t("radar.start.hint")}</p>
<Textarea
name="radar-start-terms"
label={t("radar.start.terms")}
hint={t("radar.watches.termsHint")}
rows={3}
required
value={terms}
onChange={(e) => setTerms(e.target.value)}
placeholder={t("radar.start.termsPh")}
/>
{brands.length ? (
<ProductWatchForm
brands={brands}
products={products}
brandId={brandId}
productId={productId}
onBrandChange={(id) => {
setBrandId(id);
setProductId("");
}}
onProductChange={(id) => {
setProductId(id);
const product = products.find((p) => p.id === id);
if (!product || terms.trim()) return;
const seeded = [...product.pain_points, ...product.match_tags].filter(Boolean);
if (seeded.length) setTerms(seeded.join("\n"));
}}
/>
) : null}
<p className="hb-radar-section__hint">{t("radar.start.regions")}</p>
<div className="hb-radar-area-grid">
{SERVICE_AREAS.map((area) => {
const on = regions.includes(area.code);
return (
<button
key={area.code}
type="button"
aria-pressed={on}
className={`hb-radar-chip${on ? " is-active" : ""}`}
onClick={() =>
setRegions((cur) => (on ? cur.filter((c) => c !== area.code) : [...cur, area.code]))
}
>
{area.label}
</button>
);
})}
</div>
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? t("radar.start.saving") : t("radar.start.submit")}
</Button>
</form>
);
}

View File

@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useRepos } from "../../data/DataContext";
import type { Opportunity, ReplyVariant, ReplyVariantKind, ThreadsAccount } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { Button, Select } from "../ui";
/** 公開留言是主線;其餘版本按需生成,避免一次燒五倍點數(需求決策 #6。 */
const SECONDARY_VARIANTS: ReplyVariantKind[] = ["dm", "no_sales", "professional", "humorous"];
type Props = {
opportunity: Opportunity;
/** 送出或標記後讓上層重讀,卡片狀態才跟得上。 */
onChanged?: () => void;
};
/**
* 稿
*
*
*
*/
export function ReplyComposer({ opportunity, onChanged }: Props) {
const repos = useRepos();
const { t } = useI18n();
const formatError = useFormatApiError();
const [reply, setReply] = useState<ReplyVariant | undefined>(opportunity.default_reply);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [accountId, setAccountId] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
setReply(opportunity.default_reply);
}, [opportunity.id, opportunity.default_reply]);
useEffect(() => {
void repos.accounts
.list()
.then((list) => {
const usable = list.filter((a) => a.is_usable);
setAccounts(usable);
setAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || ""));
})
.catch(() => setAccounts([]));
}, [repos.accounts]);
async function generate(variant: ReplyVariantKind) {
setBusy(variant);
setError("");
setMessage("");
try {
setReply(await repos.radar.createReply(opportunity.id, variant));
setMessage(t("radar.today.msg.replyReady"));
} catch (e) {
setError(formatError(e));
} finally {
setBusy("");
}
}
async function copy() {
if (!reply?.text) return;
try {
await navigator.clipboard.writeText(reply.text);
setMessage(t("radar.today.msg.copied"));
} catch {
setError(t("radar.today.msg.copyFail"));
}
}
async function markUsed(channel: "outbox" | "manual_copy") {
if (!reply?.id) {
setError(t("radar.today.msg.needReply"));
return;
}
setBusy(`mark-${channel}`);
setError("");
setMessage("");
try {
const res = await repos.radar.markReplyUsed(
opportunity.id,
reply.id,
channel,
channel === "outbox" ? accountId : undefined,
);
setReply(res.reply);
setMessage(res.health_advice || t(channel === "outbox" ? "radar.today.msg.sent" : "radar.today.msg.marked"));
onChanged?.();
} catch (e) {
setError(formatError(e));
} finally {
setBusy("");
}
}
const isDm = reply?.variant === "dm";
const canSendOutbox = accounts.length > 0;
return (
<div className="hb-reply-variants">
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
<div className="hb-radar-actions">
<Button type="button" disabled={Boolean(busy)} onClick={() => void generate("public_comment")}>
{busy === "public_comment" ? t("radar.today.msg.replyWorking") : t("radar.reply.writeForMe")}
</Button>
{SECONDARY_VARIANTS.map((variant) => (
<Button
key={variant}
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void generate(variant)}
>
{t(`radar.today.reply.variant.${variant}`)}
</Button>
))}
</div>
{reply?.text ? (
<>
<pre className="radar-card__reply">{reply.text}</pre>
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => void copy()}>
{t("radar.today.reply.copy")}
</Button>
{reply.used_at ? (
<span className="hb-radar-section__hint">
{reply.sent_channel === "outbox" && reply.outbox_id
? t("radar.today.reply.usedOutbox")
: t("radar.today.reply.used")}
</span>
) : (
<>
<Button
type="button"
variant="ghost"
disabled={busy === "mark-manual_copy"}
onClick={() => void markUsed("manual_copy")}
>
{t("radar.today.reply.markCopy")}
</Button>
{/* 私訊版一律人工送出:平台沒有合規的自動私訊路徑。 */}
{!isDm ? (
<Button
type="button"
variant="secondary"
disabled={busy === "mark-outbox" || !canSendOutbox}
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
onClick={() => void markUsed("outbox")}
>
{t("radar.today.reply.markOutbox")}
</Button>
) : null}
{!isDm && !canSendOutbox ? (
<span className="hb-radar-section__hint">
{t("radar.today.reply.needAccount")} <Link to="/app/crew">{t("nav.crew")}</Link>
</span>
) : null}
{!isDm && accounts.length > 1 ? (
<Select
name={`reply-account-${opportunity.id}`}
label={t("radar.reply.sendAs")}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
>
{accounts.map((a) => <option key={a.id} value={a.id}>@{a.username}</option>)}
</Select>
) : null}
</>
)}
</div>
</>
) : null}
{error ? <p className="hb-form-error" role="alert">{error}</p> : null}
{message ? <p className="hb-radar-section__hint" role="status">{message}</p> : null}
</div>
);
}

View File

@ -44,7 +44,7 @@ const map: DemandMap = {
};
describe("巡樓必填欄位", () => {
it("在品牌與產品標籤旁顯示星號", () => {
it("品牌與產品是選項,不必先填", () => {
wrap(
<ProductWatchForm
brands={[brand]}
@ -58,8 +58,8 @@ describe("巡樓必填欄位", () => {
for (const name of ["品牌", "產品"]) {
const field = screen.getByLabelText(name, { exact: false }) as HTMLSelectElement;
expect(field.required).toBe(true);
expect(field.closest("label")?.querySelector(".hb-field__required")?.textContent).toBe("*");
expect(field.required).toBe(false);
expect(field.closest("label")?.querySelector(".hb-field__required")).toBeNull();
}
});

View File

@ -21,7 +21,7 @@ type Props = {
const SUGGEST_LIMIT = 8;
/**
* AI
* AI
*
* 使
*/

View File

@ -190,7 +190,6 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
const productMode = Boolean(input.brand_id || input.product_id);
if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together");
if (productMode) productFor(input.brand_id!, input.product_id!);
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
const terms = expandIncludeTerms(input.terms);
if (!terms.length) error("terms required");
const id = `watch-${watches.size + 1}`;
@ -268,7 +267,15 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
const parsed = typeof input === "number" ? { limit: input } : input ?? {};
const limit = parsed.limit ?? 8;
const product = parsed.product_id ? products.get(parsed.product_id) : undefined;
if (!product) return [];
if (!product) {
return [
{ term: "求推薦", reason: "台灣 Threads 上找服務最常這樣問", usage: "include" as const },
{ term: "有人知道", reason: "口語求助句,能找到正在發問的人", usage: "include" as const },
{ term: "求建議", reason: "正在比較或猶豫的人常這樣寫", usage: "include" as const },
{ term: "哪裡找", reason: "明確在找店家或服務的人", usage: "include" as const },
{ term: "徵才", reason: "招募文不是客人", usage: "exclude" as const },
].slice(0, limit);
}
const fields: Array<{ kind: WatchTermSuggestion["basis_kind"]; label: string; terms: string[]; usage: WatchTermSuggestion["usage"] }> = [
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },

View File

@ -7,6 +7,7 @@ import { I18nProvider } from "../i18n/I18nContext";
import { FirstRunBar } from "../components/layout/FirstRunBar";
import { SidebarNav } from "../components/layout/SidebarNav";
import { FirstRunProvider } from "./FirstRunContext";
import { isFirstRunAllowedPath } from "./paths";
const harness = vi.hoisted(() => ({
member: null as Member | null,
@ -84,6 +85,12 @@ describe("FirstRunBar", () => {
expect(screen.queryByRole("link", { name: "創作" })).toBeNull();
});
it("lets a new member open patrol setup without being sent back", () => {
expect(isFirstRunAllowedPath("/app/radar/watches")).toBe(true);
expect(isFirstRunAllowedPath("/app/radar/opportunities")).toBe(true);
expect(isFirstRunAllowedPath("/app/studio")).toBe(false);
});
it("sends people back to connect if they open another feature", async () => {
renderBar("/app/studio");
expect(await screen.findByText("crew-page")).toBeTruthy();
@ -100,6 +107,7 @@ describe("FirstRunBar", () => {
harness.member = member({ onboarding_status: "completed" });
renderBar();
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
expect(screen.getByRole("link", { name: "創作" })).toBeTruthy();
expect(screen.getByRole("link", { name: "名單" })).toBeTruthy();
expect(screen.getByRole("link", { name: "今日" })).toBeTruthy();
});
});

View File

@ -4,5 +4,6 @@ export function isFirstRunAllowedPath(pathname: string): boolean {
if (pathname.startsWith("/app/crew")) return true;
if (pathname.startsWith("/app/settings")) return true;
if (pathname.startsWith("/app/profile")) return true;
if (pathname.startsWith("/app/radar")) return true;
return false;
}

View File

@ -9,7 +9,7 @@ export const en: MessageDict = {
"nav.today": "Today",
"nav.crew": "Accounts",
"nav.studio": "Studio",
"nav.radar": "Demand",
"nav.radar": "Patrol",
"nav.crm": "CRM",
"nav.scout": "Topics",
"nav.outbox": "Outbox",
@ -29,9 +29,9 @@ export const en: MessageDict = {
"nav.settings": "Settings",
"nav.logout": "Log out",
"nav.navigate": "Navigate",
"navGroup.workflow": "Workflow",
"navGroup.accounts": "Accounts & brands",
"navGroup.growth": "Growth tools",
"navGroup.workflow": "Today",
"navGroup.publish": "Post",
"navGroup.advanced": "Advanced",
"workspace.label": "Workspace",
"workspace.default": "Default",
@ -71,11 +71,11 @@ export const en: MessageDict = {
"help.page.generic.tips": "Help never changes your data; open and close anytime.",
"help.page.today.title": "Today",
"help.page.today.what": "Daily dashboard: opportunities, patrol queue, outbox pulse, and account health so you know what to do first.",
"help.page.today.step1": "Check the opportunities summary; open Radar if there are leads.",
"help.page.today.step2": "Clear pending patrol replies.",
"help.page.today.step3": "Track failed or active Outbox items from here.",
"help.page.today.tips": "When there are no opportunities, the summary guides you to Opportunity policy or watches.",
"help.page.today.what": "Everyone worth replying to today. We find them daily; you only decide who to answer.",
"help.page.today.step1": "Read the cards top to bottom — one line tells you what they are asking.",
"help.page.today.step2": "Press Reply to open the original post and save them to your list.",
"help.page.today.step3": "Stuck on wording? Press Write my reply for a draft you can copy. Press Skip for anyone who isn't your customer.",
"help.page.today.tips": "No cards yet? Add the keywords customers search. Change what we look for under \"What to look for\".",
"help.page.crew.title": "Accounts (Crew)",
"help.page.crew.what": "Connected Threads accounts, health, and usability. Publishing and outreach start from accounts here.",
@ -101,15 +101,15 @@ export const en: MessageDict = {
"help.page.radar_today.title": "Demand patrol",
"help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
"help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
"help.page.radar_today.step2": "Read why it was recommended. Keep a fit, discard the rest.",
"help.page.radar_today.step2": "Read why it was recommended; the same drawer drafts the reply, switches the primary product, and overrides the band.",
"help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
"help.page.radar_today.tips": "Immediate and daily patrol can both stay on. Turning one off does not hide the other.",
"help.page.radar_today.tips": "Need more input? Use Explore now or Manual import. Immediate and daily patrol can both stay on.",
"help.page.radar_watches.title": "Patrol setup",
"help.page.radar_watches.what": "Pick a product and keywords. Daily patrol runs on a schedule; you can also run one immediately. Results land on Demand.",
"help.page.radar_watches.step1": "Choose brand and product, then fill the pain map.",
"help.page.radar_watches.step2": "Add terms buyers type and excludes.",
"help.page.radar_watches.step3": "Leave daily patrol on, or hit Run now.",
"help.page.radar_watches.what": "Add keywords customers search. Daily patrol runs on a schedule; you can also run one immediately. Results land on Today.",
"help.page.radar_watches.step1": "Type short customer search words. Brand and service profile can wait.",
"help.page.radar_watches.step2": "Add excludes if needed, or pick a brand to seed product terms.",
"help.page.radar_watches.step3": "Leave daily patrol on, or go back to Today and run another sweep.",
"help.page.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
"help.page.crm_board.title": "Contact management",
@ -2181,7 +2181,7 @@ export const en: MessageDict = {
"radar.profile.subtitle":
"The radar uses this to judge whether an opportunity is worth answering, and replies quote these prices and tone.",
"radar.profile.firstTimeHint":
"Fill this in before turning on a radar watch. The more specific it is, the better the scoring and replies.",
"The service profile can wait. Add it later and scoring and replies get closer to you.",
"radar.profile.updatedAt": "Last updated: {at}",
"radar.profile.saved": "Service profile saved",
"radar.profile.services": "Services and pricing",
@ -2221,6 +2221,22 @@ export const en: MessageDict = {
"radar.watches.needProfileHint":
"Scoring needs your service profile; without it every judgment is a guess.",
"radar.watches.goProfile": "Go to service profile",
"radar.watches.profileOptional": "Service profile can wait — replies get closer to you once you add it.",
"radar.watches.optionalBrandHint": "Brand/product is optional. Pick one to auto-fill keywords, or type what customers search.",
"radar.watches.showAdvanced": "Advanced (exclude terms, brand/product, keyword ideas)",
"radar.watches.hideAdvanced": "Hide advanced",
"radar.watches.skipBrand": "Skip brand for now",
"radar.watches.skipProduct": "Skip product for now",
"radar.start.title": "Start with words customers search",
"radar.start.hint": "No brand setup needed. Then we look for people asking.",
"radar.start.terms": "Keywords",
"radar.start.termsPh": "e.g. Taipei manicure recs\nanyone build websites?",
"radar.start.regions": "Service areas (optional)",
"radar.start.submit": "Start finding people",
"radar.start.saving": "Starting…",
"radar.start.msg.searching": "Looking for people now — a few minutes",
"radar.start.msg.searchingHint": "The first run is already going. No button to press; results show up on this page.",
"radar.start.msg.scheduled": "Saved. The next scheduled patrol will start looking.",
"radar.watches.quota": "Active {used} / {max}",
"radar.watches.quotaFull": "Plan limit reached — pause or archive one to add another",
"radar.watches.add": "+ New watch",
@ -2262,7 +2278,7 @@ export const en: MessageDict = {
"radar.watches.neverSwept": "Never swept",
"radar.watches.empty": "No demand watches yet",
"radar.watches.emptyHint":
"Add short buyer phrases (e.g. “find designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
"Add short buyer phrases (e.g. “find designer”); the system sweeps daily. Whoever we find shows up under Today.",
"radar.watches.emptyFiltered": "No watches in this status",
"radar.watches.scheduleTitle": "Daily patrol: 06:00 Taipei (22:00 UTC)",
"radar.watches.scheduleHint": "Active watches run once a day. Use Run now for an extra pass. Turning off Run now does not stop the daily patrol.",
@ -2271,16 +2287,17 @@ export const en: MessageDict = {
"radar.watches.sweepQueued": "Demand sweep queued",
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
"today.radar.title": "Today's demand",
"today.radar.total": "Found",
"today.radar.high": "High",
"today.radar.mid": "Mid",
"today.radar.low": "Low",
"today.radar.open": "Open today's demand",
"today.radar.empty": "No demand yet. Add keyword watches for a daily auto list (different from Patrols manual scan).",
"today.radar.goProfile": "Fill service profile",
"today.radar.goWatches": "Add keyword watches",
"today.simple.reply": "Reply",
"today.simple.skip": "Skip",
"today.simple.left": "{n} left",
"today.simple.findAgain": "Find more",
"today.simple.setup": "What to look for",
"today.simple.emptyDone": "You're done with this batch",
"today.simple.replied": "Original opened — reply there.",
"today.simple.repliedSaved": "Original opened and saved to your list. Update the status there after you reply.",
"today.simple.viewAll": "See all results",
"today.simple.skipped": "Skipped.",
"firstRun.title": "Connect a Threads account first",
"firstRun.subtitle": "After you connect, the rest of the app unlocks. Tap the button to open Accounts.",
"firstRun.skip": "Skip — I'll look around",
@ -2302,7 +2319,7 @@ export const en: MessageDict = {
"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.",
"Suggested from your service notes or common help-seeking phrases. Adopt them one by one or all at once; you still need to save to create the watch.",
"radar.suggest.ask": "Get suggestions",
"radar.suggest.again": "Suggest more",
"radar.suggest.asking": "Thinking…",
@ -2311,40 +2328,14 @@ export const en: MessageDict = {
"radar.suggest.adoptAll": "Adopt all",
"radar.suggest.include": "Term",
"radar.suggest.exclude": "Exclude",
"radar.suggest.none": "Nothing usable came back. Make the service profile more specific and try again.",
"radar.suggest.none": "Nothing usable came back. Type a few short customer search words, or try again later.",
"radar.today.title": "Today's demand",
"radar.today.subtitle": "Daily auto list from your watches (not Patrols one-off scan).",
"radar.today.link.watches": "Demand watches",
"radar.today.link.crm": "CRM board",
"radar.today.stats.total": "Found today",
"radar.today.stats.high": "High intent",
"radar.today.stats.mid": "Mid intent",
"radar.today.stats.low": "Low intent",
"radar.today.truncated": "Daily cap reached; {n} lower-intent leads were not listed",
"radar.today.lastSwept": "Last sweep: {at}",
"radar.today.band.high": "High",
"radar.today.band.mid": "Mid",
"radar.today.band.low": "Low",
"radar.today.status.accepted": "Added to CRM",
"radar.today.status.dismissed": "Dismissed",
"radar.today.status.qualified": "Open",
"radar.today.status.rejected": "Rejected",
"radar.today.status.judging": "Judging",
"radar.today.regionUnknown": "Region unknown",
"radar.today.group.high": "High intent",
"radar.today.group.mid": "Mid intent",
"radar.today.group.low": "Low intent",
"radar.today.group.empty": "None in this band",
"radar.today.group.expand": "Expand",
"radar.today.group.collapse": "Collapse",
"radar.today.action.open": "Original",
"radar.today.action.accept": "Add to CRM",
"radar.today.action.dismiss": "Dismiss",
"radar.today.action.reply": "Generate reply",
"radar.today.action.hideReply": "Hide reply",
"radar.today.action.reasons": "Why this score",
"radar.today.action.hideReasons": "Hide reasons",
"radar.today.action.override": "Override band",
"radar.today.reply.hint": "Pick a variant. DM is copy-only — never auto-sent.",
"radar.today.reply.copy": "Copy draft",
@ -2353,32 +2344,16 @@ export const en: MessageDict = {
"radar.today.reply.variant.no_sales": "No-sales",
"radar.today.reply.variant.professional": "Professional",
"radar.today.reply.variant.humorous": "Light",
"radar.today.dim.authenticity": "Authenticity",
"radar.today.dim.intent": "Intent",
"radar.today.dim.region": "Region",
"radar.today.dim.freshness": "Freshness",
"radar.today.dim.fit": "Fit",
"radar.today.empty.title": "No demand for today yet",
"radar.today.empty.fallback": "Check back later, or review demand watches and the service profile.",
"radar.today.empty.goProfile": "Fill service profile",
"radar.today.empty.goWatches": "Add keyword watches",
"radar.today.empty.goAll": "View all results",
"radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.",
"radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrols manual scan).",
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
"radar.today.empty.reason.not_swept_yet": "Daily patrol hasnt finished yet; you can also hit Run now on Demand.",
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
"radar.today.msg.accepted": "Added to CRM",
"radar.today.msg.dismissed": "Dismissed",
"radar.today.msg.replyReady": "Reply draft ready",
"radar.today.msg.replyWorking": "Thinking…",
"radar.reply.writeForMe": "Write my reply",
"radar.reply.sendAs": "Send as",
"radar.today.msg.overridden": "Band updated",
"radar.today.msg.copied": "Copied to clipboard",
"radar.today.msg.copyFail": "Could not copy — select the text manually",
"radar.today.msg.marked": "Marked as sent/copied",
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
"radar.today.msg.needReply": "Generate a reply draft first",
"radar.today.sendAccount": "Send from",
"radar.today.reply.markCopy": "Mark as copied & sent",
"radar.today.reply.markOutbox": "Send now (Outbox)",
"radar.today.reply.needAccount": "Connect a Threads account first to send",
@ -2397,7 +2372,7 @@ export const en: MessageDict = {
"radar.inbox.neverSwept": "Not patrolled yet",
"radar.inbox.activeWatches": "{n} watches on",
"radar.inbox.allPaused": "All watches are paused. Run now also needs at least one on.",
"radar.inbox.noWatches": "No product or keywords to patrol yet",
"radar.inbox.noWatches": "No keywords to patrol yet",
"radar.inbox.sweepNow": "Run now",
"radar.inbox.sweeping": "Patrolling…",
"radar.inbox.sweepAgain": "Run again",
@ -2457,7 +2432,7 @@ export const en: MessageDict = {
"radar.inbox.empty.noRemoved": "Nothing discarded yet",
"radar.inbox.empty.noRemovedHint": "Switch back to New to keep going through pain points.",
"radar.inbox.empty.noWatchesTitle": "No patrol set up",
"radar.inbox.empty.noWatchesHint": "Pick a product and the keywords customers search. Then you can run now; daily patrol will follow.",
"radar.inbox.empty.noWatchesHint": "Type what customers search and start. Brand can wait.",
"radar.inbox.empty.pausedTitle": "Daily patrol is off",
"radar.inbox.empty.pausedHint": "Run now and daily patrol both stay on this page. Turn at least one watch back on to use either.",
"radar.inbox.empty.openSchedule": "Turn on daily patrol",
@ -2526,6 +2501,7 @@ export const en: MessageDict = {
"radar.drawer.priority": "Priority {n}",
"radar.drawer.evidence": "Demand evidence",
"radar.drawer.matches": "Product match and risks",
"radar.drawer.reply": "How to reply",
"radar.drawer.generic": "No product assigned. This stays as generic demand.",
"radar.drawer.judge": "Original judgment",
"radar.drawer.openOriginal": "Open original on Threads",
@ -2575,40 +2551,7 @@ export const en: MessageDict = {
"radar.match.hide": "Hide evidence",
"radar.match.basis": "Product basis: {text}",
"radar.match.risks": "Risks: {text}",
"radar.today.empty.goBrands": "Set brand and product",
"radar.today.introTitle": "Start with people worth following up, then decide how to reply",
"radar.today.introBody": "We match Threads posts to your product pains, merge duplicates, and rank by demand score.",
"radar.today.navAria": "Demand radar navigation",
"radar.today.manageWatches": "Manage daily patrol",
"radar.today.viewAll": "View all results",
"radar.today.filterAria": "Filter today's demand",
"radar.today.filterTitle": "Filter today's demand",
"radar.today.filterHint": "Start with everything; narrow by brand or product when the list gets long.",
"radar.today.fit": "Product fit",
"radar.today.allFit": "All fit levels",
"radar.today.fit.strong": "Strong fit",
"radar.today.fit.possible": "Possible",
"radar.today.fit.weak": "Weak fit",
"radar.today.needMore": "Not seeing a post you want?",
"radar.today.setupAria": "First-time demand radar setup",
"radar.today.setupTitle": "First time here — three steps",
"radar.today.setupHint": "After this, daily patrol runs automatically.",
"radar.today.setupStep": "Step {n} of 3",
"radar.today.setup.1.title": "Set brand and product",
"radar.today.setup.1.body": "Fill audience, pains, and product capabilities.",
"radar.today.setup.1.cta": "Go to settings",
"radar.today.setup.2.title": "Create a daily patrol",
"radar.today.setup.2.body": "Pick a product, then adopt suggested keywords.",
"radar.today.setup.2.cta": "Create patrol",
"radar.today.setup.3.title": "Come back and work the list",
"radar.today.setup.3.body": "Start with high scores, then review product evidence.",
"radar.today.productEyebrow": "Recommended product",
"radar.today.noPrimary": "No primary product yet",
"radar.today.fitScore": "Fit {n}",
"radar.today.overridden": "Manually set",
"radar.today.hideEvidence": "Hide product evidence",
"radar.today.showMatches": "View {n} product matches",
"radar.today.genericJudge": "No product set (generic demand scoring)",
"radar.today.msg.primarySet": "Primary product saved. Later high-score matches will not overwrite this.",
"radar.primary.empty": "No product matches yet",

View File

@ -10,7 +10,7 @@ export const zhTW: MessageDict = {
"nav.crew": "帳號",
"nav.studio": "創作",
/** 每日自動需求名單vs 海巡=手動掃一輪) */
"nav.radar": "商機",
"nav.radar": "巡邏",
"nav.crm": "名單",
/** 手動掃場外展vs 商機=訂閱後每天自動) */
"nav.scout": "話題",
@ -31,9 +31,9 @@ export const zhTW: MessageDict = {
"nav.settings": "系統設定",
"nav.logout": "登出",
"nav.navigate": "導覽",
"navGroup.workflow": "主流程",
"navGroup.accounts": "帳號品牌",
"navGroup.growth": "成長工具",
"navGroup.workflow": "今天",
"navGroup.publish": "發文",
"navGroup.advanced": "進階設定",
"workspace.label": "工作區",
"workspace.default": "預設",
@ -74,11 +74,11 @@ export const zhTW: MessageDict = {
"help.page.generic.tips": "說明不會改你的資料,可隨時開關。",
"help.page.today.title": "今日",
"help.page.today.what": "今日儀表板:一眼看商機、海巡待回、發送與帳號脈動,決定今天先做哪件事。",
"help.page.today.step1": "看「今日商機」摘要,有名單就點進雷達處理。",
"help.page.today.step2": "海巡待回區處理需要回覆的貼文。",
"help.page.today.step3": "發送失敗或進行中的 Outbox 可從這裡追蹤。",
"help.page.today.tips": "沒有商機時摘要會引導你去填商機政策或訂閱關鍵字。",
"help.page.today.what": "今天要回的人都在這裡。系統每天自動找,你只決定回誰。",
"help.page.today.step1": "從上往下看卡片,讀一句話就知道對方在問什麼。",
"help.page.today.step2": "要接就按「回他」:會打開原文,同時把他加進名單。",
"help.page.today.step3": "不知道怎麼開口就按「幫我想回覆」AI 會寫一段可以直接複製的留言。不是你的客人按「先跳過」,不用填理由。",
"help.page.today.tips": "還沒有卡片就先填客人會搜的關鍵字。想改要找什麼按「設定要找什麼」。",
"help.page.crew.title": "帳號Crew",
"help.page.crew.what": "管理已連結的 Threads 帳號、健康度與可用性,發文/外展都從這裡的帳號出發。",
@ -104,15 +104,15 @@ export const zhTW: MessageDict = {
"help.page.radar_today.title": "商機巡邏",
"help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
"help.page.radar_today.step1": "看頁頂巡邏狀態:每日定時是否開著、上次何時巡、要不要立即再巡一輪。",
"help.page.radar_today.step2": "讀「為什麼推薦」。對得上就「留下」,不是你的就「丟掉」。",
"help.page.radar_today.step2": "讀「為什麼推薦」,在同一個抽屜裡就能請 AI 寫回覆、改主推產品、覆寫分級。",
"help.page.radar_today.step3": "只有真的要追這個人時才「加入名單」。名單不是看結果的必要步驟。",
"help.page.radar_today.tips": "立即巡邏與每日定時可同時開著。關掉其中一個不會藏掉另一個。",
"help.page.radar_today.tips": "想補資料時用「立即探索」或「手動匯入」;每日定時與立即巡邏可以同時開著。",
"help.page.radar_watches.title": "設定巡邏",
"help.page.radar_watches.what": "選定產品與關鍵字後,每日定時巡邏會自動跑;也可隨時按立即巡邏。結果回到側欄「商機」。",
"help.page.radar_watches.step1": "選品牌與產品,補需求地圖裡的痛點。",
"help.page.radar_watches.step2": "填客人會搜的關鍵字與排除詞。",
"help.page.radar_watches.step3": "打開每日定時,或按「立即巡邏」現在跑一輪。",
"help.page.radar_watches.what": "填客人會搜的關鍵字後,每日定時巡邏會自動跑;也可隨時再巡一輪。結果回到「今日」。",
"help.page.radar_watches.step1": "填客人會打的短詞;品牌與服務檔案可之後再補。",
"help.page.radar_watches.step2": "需要時再加排除詞,或選品牌帶入產品關鍵字。",
"help.page.radar_watches.step3": "打開每日定時,或回今日按「再找一輪」。",
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
"help.page.crm_board.title": "名單管理",
@ -2184,7 +2184,7 @@ export const zhTW: MessageDict = {
"policy.subtitle": "設定服務範圍、禁語、案例、FAQ 與口吻;商機判定和生成回覆會共用這份政策。",
"radar.profile.title": "服務檔案",
"radar.profile.subtitle": "雷達用這份資料判斷商機是否值得回,回覆也照這裡的價格與口吻寫。",
"radar.profile.firstTimeHint": "先填服務檔案,才能開啟雷達訂閱。內容越具體,判定與回覆越準。",
"radar.profile.firstTimeHint": "服務檔案可之後再補。填了之後判定與回覆會更像你。",
"radar.profile.updatedAt": "上次更新:{at}",
"radar.profile.saved": "服務檔案已儲存",
"radar.profile.services": "服務與價格",
@ -2221,6 +2221,22 @@ export const zhTW: MessageDict = {
"radar.watches.needProfile": "先填服務檔案,才能開商機訂閱",
"radar.watches.needProfileHint": "系統靠服務檔案判斷需求適不適合你;沒有它會誤判。",
"radar.watches.goProfile": "去填服務檔案",
"radar.watches.profileOptional": "服務檔案可之後再補,回覆會更像你。",
"radar.watches.optionalBrandHint": "品牌/產品可先不選。選了會帶入關鍵字;沒選就自己填客人會搜的話。",
"radar.watches.showAdvanced": "進階設定(排除詞、品牌產品、關鍵字建議)",
"radar.watches.hideAdvanced": "收合進階設定",
"radar.watches.skipBrand": "先不選品牌",
"radar.watches.skipProduct": "先不選產品",
"radar.start.title": "先寫客人會搜的話",
"radar.start.hint": "不用先建品牌。填完就能開始找正在問的人。",
"radar.start.terms": "關鍵字",
"radar.start.termsPh": "例如:台北 美甲推薦\n有人會做網站嗎",
"radar.start.regions": "服務地區(選填)",
"radar.start.submit": "開始找客人",
"radar.start.saving": "建立中…",
"radar.start.msg.searching": "正在幫你找,大約幾分鐘",
"radar.start.msg.searchingHint": "第一輪已經在跑,不用再按任何按鈕。跑完這頁就會出現找到的人。",
"radar.start.msg.scheduled": "已建立。下一輪定時巡邏會開始找。",
"radar.watches.quota": "啟用中 {used} / {max}",
"radar.watches.quotaFull": "已達方案上限,要新增請先暫停或封存一個",
"radar.watches.add": " 新增訂閱",
@ -2260,7 +2276,7 @@ export const zhTW: MessageDict = {
"radar.watches.lastSwept": "上次巡:{at}",
"radar.watches.neverSwept": "還沒巡過",
"radar.watches.empty": "還沒有商機訂閱",
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。找到的人會出現在「今日」。",
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00UTC 22:00",
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
@ -2269,16 +2285,17 @@ export const zhTW: MessageDict = {
"radar.watches.sweepQueued": "已排入商機巡檢",
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
"today.radar.title": "今日商機",
"today.radar.total": "找到",
"today.radar.high": "高",
"today.radar.mid": "中",
"today.radar.low": "低",
"today.radar.open": "查看今日商機",
"today.radar.empty": "還沒有今日商機。訂閱關鍵字後會每天自動更新(與海巡手動掃場不同)。",
"today.radar.goProfile": "先填服務檔案",
"today.radar.goWatches": "去訂閱關鍵字",
"today.simple.reply": "回他",
"today.simple.skip": "先跳過",
"today.simple.left": "還有 {n} 則",
"today.simple.findAgain": "再找一次",
"today.simple.setup": "設定要找什麼",
"today.simple.emptyDone": "今天這批看完了",
"today.simple.replied": "已打開原文,去回他就好。",
"today.simple.repliedSaved": "已打開原文,也幫你加進名單。回完可以去名單更新狀態。",
"today.simple.viewAll": "看全部結果",
"today.simple.skipped": "已跳過。",
"firstRun.title": "先連一個 Threads 帳號",
"firstRun.subtitle": "連上之後就能用其他功能。點按鈕去帳號頁連接。",
"firstRun.skip": "略過,之後自己摸",
@ -2299,7 +2316,7 @@ export const zhTW: MessageDict = {
"firstRun.status.completed": "已完成引導",
"radar.suggest.title": "關鍵字建議",
"radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
"radar.suggest.hint": "依你填的服務或常見求助句想幾個客人會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
"radar.suggest.ask": "取得建議",
"radar.suggest.again": "再想幾個",
"radar.suggest.asking": "想關鍵字中…",
@ -2308,41 +2325,15 @@ export const zhTW: MessageDict = {
"radar.suggest.adoptAll": "全部採用",
"radar.suggest.include": "關鍵字",
"radar.suggest.exclude": "排除詞",
"radar.suggest.none": "這次沒想出可用的字,請把服務檔案寫具體一點再試。",
"radar.suggest.none": "這次沒想出可用的字。可先自己填幾個客人會打的短詞,或稍後再試。",
// demand-radar今日商機自動名單
"radar.today.title": "今日商機",
"radar.today.subtitle": "訂閱後每天自動整理的需求名單(不是海巡那一輪手動掃)。",
"radar.today.link.watches": "商機訂閱",
"radar.today.link.crm": "名單看板",
"radar.today.stats.total": "今日找到",
"radar.today.stats.high": "高意向",
"radar.today.stats.mid": "中意向",
"radar.today.stats.low": "低意向",
"radar.today.truncated": "已達今日上限,{n} 筆較低意向未收錄",
"radar.today.lastSwept": "上次巡檢:{at}",
"radar.today.band.high": "高",
"radar.today.band.mid": "中",
"radar.today.band.low": "低",
"radar.today.status.accepted": "已加入名單",
"radar.today.status.dismissed": "已略過",
"radar.today.status.qualified": "待處理",
"radar.today.status.rejected": "已否決",
"radar.today.status.judging": "判定中",
"radar.today.regionUnknown": "地區不明",
"radar.today.group.high": "高意向",
"radar.today.group.mid": "中意向",
"radar.today.group.low": "低意向",
"radar.today.group.empty": "這一組目前沒有",
"radar.today.group.expand": "展開",
"radar.today.group.collapse": "收合",
"radar.today.action.open": "原文",
"radar.today.action.accept": "加入名單",
"radar.today.action.dismiss": "略過",
"radar.today.action.reply": "產生回覆",
"radar.today.action.hideReply": "收合回覆",
"radar.today.action.reasons": "判定理由",
"radar.today.action.hideReasons": "收合理由",
"radar.today.action.override": "覆寫分級",
"radar.today.reply.hint": "選一個版本產生草稿;私訊版只提供複製,不會自動送出。",
"radar.today.reply.copy": "複製草稿",
@ -2351,32 +2342,16 @@ export const zhTW: MessageDict = {
"radar.today.reply.variant.no_sales": "不銷售",
"radar.today.reply.variant.professional": "專業",
"radar.today.reply.variant.humorous": "輕鬆",
"radar.today.dim.authenticity": "真實性",
"radar.today.dim.intent": "意圖",
"radar.today.dim.region": "地區",
"radar.today.dim.freshness": "新鮮度",
"radar.today.dim.fit": "服務匹配",
"radar.today.empty.title": "目前沒有今日商機",
"radar.today.empty.fallback": "稍後再回來,或先檢查商機訂閱與服務檔案。",
"radar.today.empty.goProfile": "去填服務檔案",
"radar.today.empty.goWatches": "去訂閱關鍵字",
"radar.today.empty.goAll": "查看全部結果",
"radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。",
"radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。",
"radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。",
"radar.today.empty.reason.not_swept_yet": "每日定時還沒跑完,也可在商機頁按「立即巡邏」。",
"radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。",
"radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。",
"radar.today.msg.accepted": "已加入名單",
"radar.today.msg.dismissed": "已略過",
"radar.today.msg.replyReady": "回覆草稿已產生",
"radar.today.msg.replyWorking": "正在想…",
"radar.reply.writeForMe": "幫我想回覆",
"radar.reply.sendAs": "用哪個帳號送",
"radar.today.msg.overridden": "分級已更新",
"radar.today.msg.copied": "已複製到剪貼簿",
"radar.today.msg.copyFail": "無法複製,請手動選取文字",
"radar.today.msg.marked": "已標記為已送出/已複製",
"radar.today.msg.sent": "已送出,稍後可在發送佇列查看進度",
"radar.today.msg.needReply": "請先產生回覆草稿",
"radar.today.sendAccount": "送出帳號",
"radar.today.reply.markCopy": "標記已複製送出",
"radar.today.reply.markOutbox": "一鍵送出Outbox",
"radar.today.reply.needAccount": "先連一個 Threads 帳號才能一鍵送出",
@ -2395,7 +2370,7 @@ export const zhTW: MessageDict = {
"radar.inbox.neverSwept": "還沒巡邏過",
"radar.inbox.activeWatches": "啟用中 {n} 組",
"radar.inbox.allPaused": "訂閱都暫停了,立即巡邏也需要至少一組開著",
"radar.inbox.noWatches": "還沒設定要巡的產品與關鍵字",
"radar.inbox.noWatches": "還沒寫要巡的關鍵字",
"radar.inbox.sweepNow": "立即巡邏",
"radar.inbox.sweeping": "巡邏中…",
"radar.inbox.sweepAgain": "再巡一次",
@ -2455,7 +2430,7 @@ export const zhTW: MessageDict = {
"radar.inbox.empty.noRemoved": "還沒有丟掉的結果",
"radar.inbox.empty.noRemovedHint": "切回「新找到」繼續看巡邏到的痛點。",
"radar.inbox.empty.noWatchesTitle": "還沒設定巡邏",
"radar.inbox.empty.noWatchesHint": "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
"radar.inbox.empty.noWatchesHint": "寫下客人會搜的話就能開工。品牌以後再選。",
"radar.inbox.empty.pausedTitle": "每日定時巡邏關著",
"radar.inbox.empty.pausedHint": "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
"radar.inbox.empty.openSchedule": "打開每日定時巡邏",
@ -2524,6 +2499,7 @@ export const zhTW: MessageDict = {
"radar.drawer.priority": "優先 {n}",
"radar.drawer.evidence": "需求證據",
"radar.drawer.matches": "產品匹配與風險",
"radar.drawer.reply": "怎麼回他",
"radar.drawer.generic": "尚未指定產品,這筆結果只保留為一般需求。",
"radar.drawer.judge": "原始判定",
"radar.drawer.openOriginal": "開啟 Threads 原文",
@ -2573,40 +2549,7 @@ export const zhTW: MessageDict = {
"radar.match.hide": "收合證據",
"radar.match.basis": "產品依據:{text}",
"radar.match.risks": "風險:{text}",
"radar.today.empty.goBrands": "設定品牌與產品",
"radar.today.introTitle": "先看值得跟進的人,再決定怎麼回",
"radar.today.introBody": "系統會把 Threads 貼文和你的產品痛點比對、合併重複貼文,再依商機分數排序。",
"radar.today.navAria": "商機雷達導覽",
"radar.today.manageWatches": "管理每日巡邏",
"radar.today.viewAll": "查看全部結果",
"radar.today.filterAria": "篩選今日商機",
"radar.today.filterTitle": "篩選今日商機",
"radar.today.filterHint": "先看全部;結果多時再縮小到品牌或產品。",
"radar.today.fit": "產品適配",
"radar.today.allFit": "全部適配",
"radar.today.fit.strong": "高適配",
"radar.today.fit.possible": "可能",
"radar.today.fit.weak": "弱適配",
"radar.today.needMore": "沒有想看的貼文?",
"radar.today.setupAria": "第一次使用商機雷達",
"radar.today.setupTitle": "第一次使用,照這三步就好",
"radar.today.setupHint": "完成後系統會每天自動巡邏。",
"radar.today.setupStep": "目前第 {n} 步",
"radar.today.setup.1.title": "整理品牌與產品",
"radar.today.setup.1.body": "填入受眾、痛點與產品能力。",
"radar.today.setup.1.cta": "前往設定",
"radar.today.setup.2.title": "建立每日巡邏",
"radar.today.setup.2.body": "選產品後採用建議關鍵字。",
"radar.today.setup.2.cta": "建立巡邏",
"radar.today.setup.3.title": "回來處理商機",
"radar.today.setup.3.body": "先看高分,再查看產品證據。",
"radar.today.productEyebrow": "推薦產品",
"radar.today.noPrimary": "尚未指定主推產品",
"radar.today.fitScore": "適配 {n}",
"radar.today.overridden": "人工指定",
"radar.today.hideEvidence": "收合產品證據",
"radar.today.showMatches": "查看 {n} 個產品匹配",
"radar.today.genericJudge": "未指定產品(沿用通用商機判定)",
"radar.today.msg.primarySet": "已設定主推產品;後續高分匹配不會覆蓋這個選擇。",
"radar.primary.empty": "目前沒有產品匹配",

View File

@ -31,8 +31,8 @@ export const primaryNav: NavItem[] = [
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
{ key: "radar", path: "/app/radar", labelKey: "nav.radar", label: "商機", en: "Demand" },
/** 進階:訂閱關鍵字/每日巡邏設定。開工在今日收件匣。 */
{ key: "radar", path: "/app/radar/watches", labelKey: "nav.radar", label: "巡邏", en: "Patrol" },
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
@ -65,40 +65,42 @@ export const primaryNav: NavItem[] = [
/** 第一次工作導覽期間只留這條線需要的入口 */
export const firstRunNavKeys: NavKey[] = ["today", "crew"];
/** 手機底欄固定 4 格主流程radar 進主四格,話題移入更多) */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"];
/** 手機底欄:今日清名單、名單、帳號,其餘進更多 */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "crm", "crew"];
/** 手機底欄「更多」內項目 */
/** 手機底欄「更多」=進階功能(舊網址都還在) */
export const mobileDockMoreKeys: NavKey[] = [
"crew",
"radar",
"studio",
"scout",
"outbox",
"jobs",
"brands",
"policy",
"crm",
"insights",
"utm",
// 暫隱藏playbooks市集、benchmark基準
];
export type NavGroupKey = "workflow" | "accounts" | "growth";
export type NavGroupKey = "workflow" | "publish" | "advanced";
export type NavGroup = {
key: NavGroupKey;
/** i18n key例如 navGroup.workflow */
labelKey: string;
keys: NavKey[];
/** 側欄預設收合:設定類的頁面設一次就好,不該每天佔著視線。 */
advanced?: boolean;
};
/** 側欄二級分類:主流程(商機→名單)→ 帳號品牌 → 成長工具話題放創作側workflow 內 studio 旁) */
/** 側欄:每天走的那條線放最上面,設定與報表收進進階 */
export const navGroups: NavGroup[] = [
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] },
{ key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands", "policy"] },
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "crm", "crew"] },
{ key: "publish", labelKey: "navGroup.publish", keys: ["studio", "outbox"] },
{
key: "growth",
labelKey: "navGroup.growth",
// 暫隱藏benchmark基準、playbooks市集
keys: ["insights", "utm"],
key: "advanced",
labelKey: "navGroup.advanced",
advanced: true,
keys: ["radar", "scout", "brands", "policy", "jobs", "insights", "utm"],
},
];
@ -118,12 +120,17 @@ export function navGroupedItemsByKeys(
}
export function isNavActive(pathname: string, item: NavItem): boolean {
if (item.path === "/app/today") {
return pathname === "/app" || pathname === "/app/today";
if (item.key === "today") {
return (
pathname === "/app" ||
pathname === "/app/today" ||
pathname === "/app/radar" ||
pathname === "/app/radar/today" ||
pathname.startsWith("/app/radar/opportunities")
);
}
// 雷達有 todaywatches 兩個子頁,側欄以 /app/radar 前綴對齊。
if (item.key === "radar") {
return pathname === "/app/radar" || pathname.startsWith("/app/radar/");
return pathname.startsWith("/app/radar/watches");
}
if (item.key === "crm") {
return pathname === "/app/crm" || pathname.startsWith("/app/crm/");
@ -148,9 +155,9 @@ export function pathForNotification(n: {
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
if (n.ref_type === "contact") return "/app/crm/followups";
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar";
if (n.ref_type === "opportunity" && n.ref_id) return "/app/today";
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
if (n.ref_type === "radar") return "/app/radar";
if (n.ref_type === "radar") return "/app/today";
// 任務/系統/無 ref任務中心
return "/app/jobs";
}

View File

@ -7,13 +7,15 @@ describe("pageHelp", () => {
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar");
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar/watches");
expect(resolvePageHelpId("/app/today")).toBe("today");
expect(resolvePageHelpId("/app/radar/opportunities")).toBe("radar_today");
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
expect(resolvePageHelpId("/app/policy")).toBe("policy");
expect(resolvePageHelpId("/app/usage/plans")).toBe("usage_plans");
expect(resolvePageHelpId("/app/today")).toBe("today");
expect(resolvePageHelpId("/app")).toBe("today");
});
it("falls back outside /app", () => {

View File

@ -39,7 +39,6 @@ export type PageHelpRelated = {
/** 最長前綴優先;含查詢字串的路徑先去掉 search/hash。 */
const RULES: { prefix: string; id: PageHelpId }[] = [
{ prefix: "/app/radar/watches", id: "radar_watches" },
{ prefix: "/app/radar/today", id: "radar_today" },
{ prefix: "/app/radar", id: "radar_today" },
{ prefix: "/app/crm/followups", id: "crm_followups" },
{ prefix: "/app/crm/stats", id: "crm_stats" },
@ -62,6 +61,7 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
{ prefix: "/app/usage/checkout", id: "usage_checkout" },
{ prefix: "/app/usage", id: "usage" },
{ prefix: "/app/users", id: "admin_users" },
// 今日是簡化收件匣,進階巡邏頁(/app/radar/*)另有一份說明。
{ prefix: "/app/today", id: "today" },
{ prefix: "/app", id: "today" },
];
@ -69,31 +69,31 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
/** 每個說明可連到的相關頁(可選) */
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
today: [
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/scout", labelKey: "nav.scout" },
{ path: "/app/outbox", labelKey: "nav.outbox" },
{ path: "/app/radar/watches", labelKey: "nav.radar" },
{ path: "/app/crm", labelKey: "nav.crm" },
{ path: "/app/crew", labelKey: "nav.crew" },
],
radar_today: [
{ path: "/app/radar/watches", labelKey: "radar.today.link.watches" },
{ path: "/app/crm", labelKey: "nav.crm" },
{ path: "/app/policy", labelKey: "nav.policy" },
{ path: "/app/crew", labelKey: "nav.crew" },
],
radar_watches: [
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/policy", labelKey: "nav.policy" },
{ path: "/app/today", labelKey: "nav.today" },
{ path: "/app/brands", labelKey: "nav.brands" },
],
crm_board: [
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/today", labelKey: "nav.today" },
],
crm_followups: [
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/today", labelKey: "nav.today" },
],
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
scout: [
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/today", labelKey: "nav.today" },
{ path: "/app/brands", labelKey: "nav.brands" },
],
brands: [

View File

@ -197,7 +197,7 @@ export function CrmBoardPage() {
<>
<PageHeader title={t("crm.board.title")} />
<div className="hb-radar-actions hb-radar-actions--toolbar">
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
<Link className="hb-btn hb-btn--secondary" to="/app/today">
{t("crm.board.link.today")}
</Link>
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
@ -262,7 +262,7 @@ export function CrmBoardPage() {
<EmptyState
title={t("crm.board.empty")}
description={t("crm.board.emptyHint")}
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar">{t("crm.board.link.today")}</Link>}
action={<Link className="hb-btn hb-btn--secondary" to="/app/today">{t("crm.board.link.today")}</Link>}
/>
) : (
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>

View File

@ -4,13 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../data/live/http";
import { KEYS } from "../data/mock/keys";
import { I18nProvider } from "../i18n/I18nContext";
import type { Opportunity, RadarToday } from "../domain/types";
import { RadarTodayPage } from "./RadarTodayPage";
import type { Opportunity } from "../domain/types";
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
const backend = vi.hoisted(() => ({
today: null as RadarToday | null,
error: null as unknown,
calls: [] as Array<Record<string, string | undefined>>,
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
}));
@ -67,64 +65,76 @@ function sampleOpportunity(): Opportunity {
primary_product_fit_score: 100,
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
created_at: Date.now() * 1e6,
review_state: "pending",
};
}
vi.mock("../data/DataContext", () => ({
useRepos: () => ({
useRepos: (() => {
const repos = {
accounts: { async list() { return []; } },
jobs: { async get(id: string) { return { id, template_type: "radar_sweep", status: "succeeded", progress_summary: "", progress_percent: 100, created_at: 1, updated_at: 1 }; } },
scout: {
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }, { id: "p2", brand_id: "b1", label: "修護乳霜", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
async listProducts() {
return [
{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 },
{ id: "p2", brand_id: "b1", label: "修護乳霜", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 },
];
},
},
radar: {
async getToday(filter?: { brand_id?: string; product_id?: string; fit_band?: string }) {
backend.calls.push(filter ?? {});
async listOpportunities() {
if (backend.error) throw backend.error;
if (!backend.today) throw new Error("missing live fixture");
return backend.today;
return { list: [sampleOpportunity()], total: 1 };
},
async listWatches() {
return { list: [], total: 0, active_count: 0, max_active: 5, profile_exists: true };
},
async getToday() {
return { stats: { total: 0, high: 0, mid: 0, low: 0 }, high: [], mid: [], low: [], truncated_count: 0, last_swept_at: Date.now() * 1e6 };
},
async listSweeps() { return { list: [], total: 0 }; },
async setPrimaryProduct(id: string, productId: string, reason: string) {
backend.primaryCalls.push({ id, productId, reason });
return backend.today?.high[0] ?? sampleOpportunity();
return { ...sampleOpportunity(), primary_product_id: productId, primary_product_overridden: true };
},
},
}),
};
return () => repos;
})(),
}));
function renderPage() {
return render(
<MemoryRouter initialEntries={["/app/radar/today?brand_id=b1&product_id=p2&fit_band=strong"]}>
<I18nProvider><RadarTodayPage /></I18nProvider>
<MemoryRouter initialEntries={["/app/radar/opportunities"]}>
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
</MemoryRouter>,
);
}
beforeEach(() => {
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
backend.today = { stats: { total: 1, high: 1, mid: 0, low: 0 }, high: [sampleOpportunity()], mid: [], low: [], truncated_count: 0 };
backend.error = null;
backend.calls = [];
backend.primaryCalls = [];
});
describe("live product radar flow", () => {
it("keeps URL filters, renders multi-product evidence, and sends primary override", async () => {
describe("多產品商機的證據與主推覆寫", () => {
it("抽屜列出每個產品的匹配證據,並能改主推", async () => {
renderPage();
expect(await screen.findByText("敏感肌使用者求推薦:泛紅不適,想了解日常修護。"))
.toBeTruthy();
expect(backend.calls[0]).toEqual({ brand_id: "b1", product_id: "p2", fit_band: "strong" });
expect(screen.getAllByText("舒緩精華").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "查看 2 個產品匹配" }));
fireEvent.click(await screen.findByRole("button", { name: "為什麼推薦" }));
expect(await screen.findByText("對應泛紅不適")).toBeTruthy();
expect(screen.getAllByText(/修護乳霜/).length).toBeGreaterThan(0);
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
});
it("keeps API failures visible instead of showing zero results", async () => {
backend.today = null;
it("API 失敗時說出原因,不顯示成功的零結果", async () => {
backend.error = new ApiError("服務暫時不可用", 501010, 503);
renderPage();
expect(await screen.findByRole("alert")).toBeTruthy();

View File

@ -1,12 +1,15 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { ExplorePanel } from "../components/radar/ExplorePanel";
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
import { QuickWatchStart } from "../components/radar/QuickWatchStart";
import { Button, EmptyState, Select } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
import type { Brand, BrandProduct, IntentBand, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { sanitizeReviewCopy } from "../lib/reviewCopy";
@ -24,6 +27,8 @@ function normalizeSort(value: string | null): string {
export function RadarOpportunitiesPage() {
const repos = useRepos();
const { t, locale } = useI18n();
const { pathname } = useLocation();
const simple = pathname.startsWith("/app/today");
const formatError = useFormatApiError();
const [params, setParams] = useSearchParams();
const [list, setList] = useState<Opportunity[]>([]);
@ -50,6 +55,12 @@ export function RadarOpportunitiesPage() {
const [watchTotal, setWatchTotal] = useState(0);
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
// 系統自己放寬的時間範圍不算「使用者下了篩選」,否則清完卡片會被告知去清一個他沒設過的篩選。
const [autoWidened, setAutoWidened] = useState(false);
// 第一組訂閱建立時後端已排了首輪巡邏,這個旗標讓空狀態說「正在找」而不是叫他再按一次。
const [firstSweepPending, setFirstSweepPending] = useState(false);
const [exploreOpen, setExploreOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
useEffect(() => {
@ -101,23 +112,18 @@ export function RadarOpportunitiesPage() {
if (canWiden && result.total === 0) {
const week = await query("7d");
if (week.total > 0) {
// 不寫進網址:重新整理後會再自己放寬一次,網址留著反而讓下次進頁被當成使用者篩選。
setTimeScope("7d");
setAutoWidened(true);
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "7d");
next.delete("page");
setParams(next, { replace: true });
result = week;
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
} else {
const all = await query("all");
if (all.total > 0) {
setTimeScope("all");
setAutoWidened(true);
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "all");
next.delete("page");
setParams(next, { replace: true });
result = all;
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
} else {
@ -136,7 +142,7 @@ export function RadarOpportunitiesPage() {
} finally {
setLoading(false);
}
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams, t]);
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, t]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
@ -170,7 +176,7 @@ export function RadarOpportunitiesPage() {
await load();
setError("");
setNotice({
text: t("radar.inbox.msg.accepted"),
text: t(simple ? "today.simple.repliedSaved" : "radar.inbox.msg.accepted"),
contactId: result.contact_id,
});
} catch (e) {
@ -180,6 +186,43 @@ export function RadarOpportunitiesPage() {
}
}
/** 主推與覆寫都會改判定結果,所以做完要重讀,抽屜也換成新的那一筆。 */
async function patchOpportunity(
opportunity: Opportunity,
action: () => Promise<Opportunity>,
successText: string,
) {
setBusyId(opportunity.id);
setNotice(null);
try {
const updated = await action();
setSelected((current) => (current?.id === updated.id ? updated : current));
await load();
setError("");
setNotice({ text: successText });
} catch (e) {
setError(formatError(e));
} finally {
setBusyId(null);
}
}
function setPrimary(opportunity: Opportunity, productId: string, reason: string) {
return patchOpportunity(
opportunity,
() => repos.radar.setPrimaryProduct(opportunity.id, productId, reason),
t("radar.today.msg.primarySet"),
);
}
function overrideBand(opportunity: Opportunity, band: IntentBand) {
return patchOpportunity(
opportunity,
() => repos.radar.overrideOpportunity(opportunity.id, { band }),
t("radar.today.msg.overridden"),
);
}
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
const deadline = Date.now() + 120_000;
let last: { status: JobStatus; progress_summary: string; error: string } = {
@ -274,6 +317,7 @@ export function RadarOpportunitiesPage() {
setProductId("");
setSort("recommended");
setTimeScope("today");
setAutoWidened(false);
setAdvancedOpen(false);
setPage(1);
const next = new URLSearchParams();
@ -287,11 +331,14 @@ export function RadarOpportunitiesPage() {
);
const scheduledOn = activeCount > 0;
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
const filtered = Boolean(
advancedFilterCount || sort !== "recommended" || (timeScope !== "today" && !autoWidened),
);
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
if (filtered) {
// 今日頁沒有篩選 UI講「清除篩選」等於指向一個不存在的按鈕。
if (filtered && !simple) {
return {
title: t(
reviewState === "pending"
@ -312,9 +359,15 @@ export function RadarOpportunitiesPage() {
}
if (watchTotal === 0) {
return {
title: t("radar.inbox.empty.noWatchesTitle"),
title: t("radar.start.title"),
description: t("radar.inbox.empty.noWatchesHint"),
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>,
action: <QuickWatchStart onCreated={({ firstSweepTriggered }) => {
setFirstSweepPending(firstSweepTriggered);
// 首輪有跑時,空狀態本身就會說「正在幫你找」,再加一條同樣的橫幅只是重複。
if (!firstSweepTriggered) setNotice({ text: t("radar.start.msg.scheduled") });
void load();
void loadPatrol();
}} />,
};
}
if (!scheduledOn) {
@ -325,6 +378,10 @@ export function RadarOpportunitiesPage() {
};
}
if (!lastSweptAt) {
// 首輪已經在跑:再給一顆「立即巡邏」只會排第二輪,也讓使用者以為剛才沒成功。
if (firstSweepPending) {
return { title: t("radar.start.msg.searching"), description: t("radar.start.msg.searchingHint") };
}
return {
title: t("radar.inbox.empty.neverTitle"),
description: t("radar.inbox.empty.neverHint"),
@ -361,11 +418,14 @@ export function RadarOpportunitiesPage() {
};
}
return {
title: t("radar.inbox.empty.noFitTitle"),
title: simple ? t("today.simple.emptyDone") : t("radar.inbox.empty.noFitTitle"),
description: lastSweep
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
: t("radar.inbox.empty.noFitHint"),
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>,
// 已經在近 7 天/全部時再給「看近 7 天」是死按鈕。
action: timeScope === "today"
? <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setAutoWidened(false); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>
: undefined,
};
}
@ -373,8 +433,20 @@ export function RadarOpportunitiesPage() {
return (
<>
<PageHeader title={t("radar.inbox.title")} />
<PageHeader
title={simple ? t("nav.today") : t("radar.inbox.title")}
description={simple && total > 0 ? t("today.simple.left", { n: total }) : undefined}
/>
{simple && watchTotal > 0 ? (
<div className="hb-today-simple-bar">
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
{sweeping ? t("radar.inbox.sweeping") : t("today.simple.findAgain")}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">{t("today.simple.setup")}</Link>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("today.simple.viewAll")}</Link>
</div>
) : simple ? null : (
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
<div className="hb-radar-patrol__status">
<p>
@ -394,15 +466,36 @@ export function RadarOpportunitiesPage() {
<small>{t("radar.patrol.searchFallback")}</small>
</div>
</section>
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
)}
{!simple && lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
{simple ? null : (
<>
<div className="hb-radar-utility-actions">
<span>{t("radar.today.needMore")}</span>
<Button type="button" variant="secondary" onClick={() => { setExploreOpen((v) => !v); setImportOpen(false); }}>
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
</Button>
<Button type="button" variant="secondary" onClick={() => { setImportOpen((v) => !v); setExploreOpen(false); }}>
{importOpen ? t("radar.import.close") : t("radar.import.open")}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/crm">{t("radar.today.link.crm")}</Link>
</div>
{exploreOpen ? <ExplorePanel onExplored={() => { void load(); void loadPatrol(); }} /> : null}
{importOpen ? <ManualImportPanel onImported={() => { void load(); void loadPatrol(); }} /> : null}
</>
)}
{simple ? null : (
<section className="hb-radar-intro">
<div>
<strong>{t("radar.inbox.introTitle")}</strong>
<p>{t("radar.inbox.introBody")}</p>
</div>
</section>
)}
{simple ? null : (
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
<div className="hb-radar-filter-panel__head">
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
@ -420,7 +513,7 @@ export function RadarOpportunitiesPage() {
<div className="hb-radar-inbox-essential-filters">
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
const value = e.target.value as OpportunityTimeScope;
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
setTimeScope(value); setAutoWidened(false); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
}}>
<option value="today">{t("radar.inbox.time.today")}</option><option value="7d">{t("radar.inbox.time.7d")}</option><option value="all">{t("radar.inbox.time.all")}</option>
</Select>
@ -465,6 +558,7 @@ export function RadarOpportunitiesPage() {
</Select>
</div> : null}
</section>
)}
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>{t("radar.inbox.goCrm")}</Link> : null}</div> : null}
@ -478,11 +572,12 @@ export function RadarOpportunitiesPage() {
{list.map((o) => <OpportunityInboxCard
key={o.id}
opportunity={o}
simple={simple}
busy={busyId === o.id}
onOpen={setSelected}
onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, t("radar.inbox.msg.removed"))}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, simple ? t("today.simple.replied") : t("radar.inbox.msg.kept"))}
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, simple ? t("today.simple.skipped") : t("radar.inbox.msg.removed"))}
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
/>)}
{total > PAGE_SIZE ? (
@ -500,6 +595,8 @@ export function RadarOpportunitiesPage() {
onClose={() => setSelected(null)}
onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
onSetPrimary={simple ? undefined : (item, productId, reason) => void setPrimary(item, productId, reason)}
onOverrideBand={simple ? undefined : (item, band) => void overrideBand(item, band)}
/> : null}
</>
);

View File

@ -1,142 +0,0 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../data/mock/keys";
import { I18nProvider } from "../i18n/I18nContext";
import { translate } from "../lib/i18n/messages";
import type { Opportunity, RadarToday } from "../domain/types";
import { RadarTodayPage } from "./RadarTodayPage";
const t = (key: string, params?: Record<string, string | number>) => translate("zh-TW", key, params);
const backend = vi.hoisted(() => ({
today: null as RadarToday | null,
}));
function sampleOpp(id: string, band: "high" | "mid" | "low", score: number): Opportunity {
return {
id,
source: "threads",
external_id: id,
permalink: `https://example.com/${id}`,
author_handle: "seeker",
text: `商機內文 ${id}`,
posted_at: Date.now() * 1e6,
status: "qualified",
intent_score: score,
intent_band: band,
reasons: [
{ dimension: "authenticity", score: 20, reason: "真" },
{ dimension: "intent", score: 20, reason: "想買" },
{ dimension: "region", score: 10, reason: "地區" },
{ dimension: "freshness", score: 10, reason: "新" },
{ dimension: "fit", score: 10, reason: "合" },
],
region_match: "unknown",
freshness_hours: 2,
matched_terms: ["水電"],
created_at: Date.now() * 1e6,
};
}
vi.mock("../data/DataContext", () => ({
useRepos: () => ({
accounts: {
async list() {
return [];
},
},
radar: {
async getToday() {
if (!backend.today) throw new Error("no today");
return backend.today;
},
async acceptOpportunity() {
return { opportunity_id: "x", status: "accepted" };
},
async dismissOpportunity(id: string) {
return sampleOpp(id, "high", 80);
},
async createReply() {
return {
id: "r1",
opportunity_id: "h1",
variant: "public_comment" as const,
text: "草稿",
created_at: 1,
};
},
async markReplyUsed() {
return {
reply: {
id: "r1",
opportunity_id: "h1",
variant: "public_comment" as const,
text: "草稿",
used_at: 2,
sent_channel: "manual_copy" as const,
created_at: 1,
},
};
},
async overrideOpportunity(id: string) {
return sampleOpp(id, "mid", 60);
},
},
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<I18nProvider>
<RadarTodayPage />
</I18nProvider>
</MemoryRouter>,
);
}
describe("RadarTodayPage", () => {
beforeEach(() => {
backend.today = null;
localStorage.setItem(
KEYS.uiPrefs,
JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }),
);
});
it("shows empty reason and CTA when total is 0", async () => {
backend.today = {
stats: { total: 0, high: 0, mid: 0, low: 0 },
high: [],
mid: [],
low: [],
truncated_count: 0,
empty_reason: "no_watch",
empty_hint: "建立訂閱",
};
renderPage();
expect(await screen.findByText("建立訂閱")).toBeTruthy();
expect(screen.getByRole("link", { name: t("radar.today.empty.goWatches") })).toBeTruthy();
});
it("groups high/mid and collapses low by default", async () => {
backend.today = {
stats: { total: 3, high: 1, mid: 1, low: 1 },
high: [sampleOpp("h1", "high", 90)],
mid: [sampleOpp("m1", "mid", 60)],
low: [sampleOpp("l1", "low", 30)],
truncated_count: 2,
};
renderPage();
expect(await screen.findByText("商機內文 h1")).toBeTruthy();
expect(screen.getByText("商機內文 m1")).toBeTruthy();
expect(screen.queryByText("商機內文 l1")).toBeNull();
expect(screen.getByText(t("radar.today.truncated", { n: 2 }), { exact: false })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /低意向/ }));
await waitFor(() => {
expect(screen.getByText("商機內文 l1")).toBeTruthy();
});
});
});

View File

@ -1,674 +0,0 @@
/**
* demand-radar T551T552
* empty_reason
*/
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { ExplorePanel } from "../components/radar/ExplorePanel";
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
import { Badge, Button, EmptyState, Select } from "../components/ui";
import type { BadgeTone } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type {
Brand,
BrandProduct,
IntentBand,
Opportunity,
RadarEmptyReason,
RadarToday,
ReplyVariant,
ReplyVariantKind,
ThreadsAccount,
} from "../domain/types";
import { ProductMatchDetails } from "../components/radar/ProductMatchDetails";
import { PrimaryProductPicker } from "../components/radar/PrimaryProductPicker";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { formatTimeAgo } from "../lib/time";
import "../styles/radar.css";
const REPLY_VARIANTS: ReplyVariantKind[] = [
"public_comment",
"dm",
"no_sales",
"professional",
"humorous",
];
function bandTone(band: string): BadgeTone {
if (band === "high") return "success";
if (band === "mid") return "warning";
return "neutral";
}
function statusTone(status: string): BadgeTone {
if (status === "accepted") return "brand";
if (status === "dismissed") return "neutral";
return "success";
}
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey?: string; label?: string } | null {
switch (reason) {
case "no_profile":
return { to: "/app/brands", labelKey: "radar.today.empty.goBrands" };
case "no_watch":
case "all_watches_paused":
case "not_swept_yet":
case "sweep_failed":
case "no_hit":
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
case "no_eligible_product_match":
return { to: "/app/radar/opportunities", labelKey: "radar.today.empty.goAll" };
default:
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
}
}
function OppCard({
o,
reply,
busy,
canSendOutbox,
onAccept,
onDismiss,
onGenerateReply,
onCopyReply,
onMarkUsed,
onOverrideBand,
onSetPrimary,
}: {
o: Opportunity;
reply?: ReplyVariant;
busy: string;
/** false沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */
canSendOutbox: boolean;
onAccept: () => void;
onDismiss: () => void;
onGenerateReply: (variant: ReplyVariantKind) => void;
onCopyReply: () => void;
onMarkUsed: (channel: "outbox" | "manual_copy") => void;
onOverrideBand: (band: IntentBand) => void;
onSetPrimary: (productId: string, reason: string) => void;
}) {
const { t } = useI18n();
const [reasonsOpen, setReasonsOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(Boolean(reply?.text || o.default_reply?.text));
const [overrideOpen, setOverrideOpen] = useState(false);
const [productsOpen, setProductsOpen] = useState(false);
const activeReply = reply || o.default_reply;
const shownReply = activeReply?.text;
const isDm = activeReply?.variant === "dm";
const isDone = o.status === "accepted" || o.status === "dismissed";
return (
<article className={`hb-opp-card hb-opp-card--${o.intent_band}`}>
<header className="hb-opp-card__head">
<div className="hb-opp-card__meta">
<Badge tone={bandTone(o.intent_band)}>
{t(`radar.today.band.${o.intent_band}`)} · {o.intent_score}
</Badge>
{o.status !== "qualified" ? (
<Badge tone={statusTone(o.status)}>{t(`radar.today.status.${o.status}`)}</Badge>
) : null}
{o.region_detected ? (
<span
className={
o.region_match === "mismatch"
? "hb-opp-region--mismatch"
: o.region_match === "unknown"
? "hb-opp-region--unknown"
: undefined
}
>
{o.region_detected}
</span>
) : (
<span className="hb-opp-region--unknown">{t("radar.today.regionUnknown")}</span>
)}
<span className="radar-card__meta">{formatTimeAgo(o.posted_at)}</span>
</div>
</header>
<p className="hb-opp-card__text">{o.text}</p>
<p className="radar-card__meta">
@{o.author_handle}
{o.matched_service ? ` · ${o.matched_service}` : ""}
{o.matched_terms?.length ? ` · ${o.matched_terms.join("、")}` : ""}
</p>
{o.product_matches?.length ? (
<div className="hb-opp-products">
<div className="hb-opp-products__summary">
<div className="hb-opp-products__primary">
<span className="hb-opp-products__eyebrow">{t("radar.today.productEyebrow")}</span>
<strong>{o.primary_product_label || t("radar.today.noPrimary")}</strong>
{o.primary_product_fit_score != null ? <Badge tone="brand">{t("radar.today.fitScore", { n: o.primary_product_fit_score })}</Badge> : null}
{o.primary_product_overridden ? <Badge tone="warning">{t("radar.today.overridden")}</Badge> : null}
</div>
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
{productsOpen ? t("radar.today.hideEvidence") : t("radar.today.showMatches", { n: o.product_matches.length })}
</Button>
</div>
{productsOpen ? (
<div className="hb-opp-products__details">
{o.product_matches.map((m) => <ProductMatchDetails key={m.product_id} match={m} />)}
<PrimaryProductPicker matches={o.product_matches} currentId={o.primary_product_id} busy={busy === `primary-${o.id}`} onSet={onSetPrimary} />
</div>
) : null}
</div>
) : <span className="hb-radar-section__hint">{t("radar.today.genericJudge")}</span>}
{shownReply ? (
<pre className="radar-card__reply">{shownReply}</pre>
) : null}
{reasonsOpen ? (
<ul className="hb-opp-reasons">
{(o.reasons ?? []).map((r) => (
<li key={r.dimension} className="hb-opp-reason">
<strong>{t(`radar.today.dim.${r.dimension}`)}</strong>
<span>{r.score}</span>
<span>{r.reason}</span>
</li>
))}
</ul>
) : null}
{replyOpen ? (
<div className="hb-reply-variants">
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
<div className="hb-radar-actions">
{REPLY_VARIANTS.map((v) => (
<Button
key={v}
type="button"
variant="secondary"
disabled={busy === `reply-${o.id}`}
onClick={() => onGenerateReply(v)}
>
{t(`radar.today.reply.variant.${v}`)}
</Button>
))}
{shownReply ? (
<Button type="button" variant="ghost" onClick={onCopyReply}>
{t("radar.today.reply.copy")}
</Button>
) : null}
{activeReply && !activeReply.used_at ? (
<>
<Button
type="button"
variant="ghost"
disabled={busy === `mark-${o.id}`}
onClick={() => onMarkUsed("manual_copy")}
>
{t("radar.today.reply.markCopy")}
</Button>
{!isDm ? (
<Button
type="button"
variant="secondary"
disabled={busy === `mark-${o.id}` || !canSendOutbox}
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
onClick={() => onMarkUsed("outbox")}
>
{t("radar.today.reply.markOutbox")}
</Button>
) : null}
</>
) : null}
{!isDm && !canSendOutbox && activeReply && !activeReply.used_at ? (
<span className="hb-radar-section__hint">
{t("radar.today.reply.needAccount")}{" "}
<Link to="/app/crew">{t("nav.crew")}</Link>
</span>
) : null}
{activeReply?.used_at ? (
<span className="hb-radar-section__hint">
{activeReply.sent_channel === "outbox" && activeReply.outbox_id
? t("radar.today.reply.usedOutbox")
: t("radar.today.reply.used")}
</span>
) : null}
</div>
</div>
) : null}
{overrideOpen ? (
<div className="hb-radar-actions">
{(["high", "mid", "low"] as IntentBand[]).map((b) => (
<Button
key={b}
type="button"
variant="secondary"
disabled={busy === `override-${o.id}` || o.intent_band === b}
onClick={() => onOverrideBand(b)}
>
{t(`radar.today.band.${b}`)}
</Button>
))}
</div>
) : null}
<div className="radar-card__actions">
{!isDone ? (
<>
<Button
type="button"
disabled={busy === `accept-${o.id}`}
onClick={onAccept}
>
{t("radar.today.action.accept")}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy === `dismiss-${o.id}`}
onClick={onDismiss}
>
{t("radar.today.action.dismiss")}
</Button>
</>
) : null}
<a className="hb-btn hb-btn--secondary" href={o.permalink} target="_blank" rel="noreferrer">
{t("radar.today.action.open")}
</a>
<div className="hb-opp-card__secondary-actions">
<Button type="button" variant="ghost" onClick={() => setReplyOpen((v) => !v)}>
{replyOpen ? t("radar.today.action.hideReply") : t("radar.today.action.reply")}
</Button>
<Button type="button" variant="ghost" onClick={() => setReasonsOpen((v) => !v)}>
{reasonsOpen ? t("radar.today.action.hideReasons") : t("radar.today.action.reasons")}
</Button>
<Button type="button" variant="ghost" onClick={() => setOverrideOpen((v) => !v)}>
{t("radar.today.action.override")}
</Button>
</div>
</div>
</article>
);
}
export function RadarTodayPage() {
const { t } = useI18n();
const repos = useRepos();
const formatErr = useFormatApiError();
const [urlParams, setUrlParams] = useSearchParams();
const [data, setData] = useState<RadarToday | null>(null);
const [err, setErr] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [lowOpen, setLowOpen] = useState(false);
const [busy, setBusy] = useState("");
const [replies, setReplies] = useState<Record<string, ReplyVariant>>({});
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [sendAccountId, setSendAccountId] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [exploreOpen, setExploreOpen] = useState(false);
const [filterBrand, setFilterBrand] = useState(urlParams.get("brand_id") || "");
const [filterProduct, setFilterProduct] = useState(urlParams.get("product_id") || "");
const [filterBand, setFilterBand] = useState(urlParams.get("fit_band") || "");
const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
useEffect(() => {
if (!scout) return;
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
}, [scout]);
useEffect(() => {
if (!scout || !filterBrand) { setProducts([]); return; }
void scout.listProducts(filterBrand).then(setProducts).catch(() => setProducts([]));
}, [scout, filterBrand]);
function updateFilterParam(name: string, value: string) {
const next = new URLSearchParams(urlParams);
if (value) next.set(name, value); else next.delete(name);
setUrlParams(next, { replace: true });
}
const load = useCallback(async () => {
const next = await repos.radar.getToday({ brand_id: filterBrand || undefined, product_id: filterProduct || undefined, fit_band: filterBand || undefined });
setData(next);
}, [repos.radar, filterBrand, filterProduct, filterBand]);
useEffect(() => {
let alive = true;
setLoading(true);
load()
.then(() => {
if (alive) setErr(null);
})
.catch((e) => {
if (alive) setErr(formatErr(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [load]); // eslint-disable-line react-hooks/exhaustive-deps
// 一鍵送出要知道用哪個帳號;多數人只有一個已連帳號,預設選第一個可用的即可。
useEffect(() => {
void repos.accounts
.list()
.then((list) => {
const usable = list.filter((a) => a.is_usable);
setAccounts(usable);
setSendAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || ""));
})
.catch(() => undefined);
}, [repos.accounts]);
async function run(key: string, action: () => Promise<void>, okMessage?: string) {
setBusy(key);
setErr(null);
setMsg(null);
try {
await action();
if (okMessage) setMsg(okMessage);
} catch (e) {
setErr(formatErr(e));
} finally {
setBusy("");
}
}
async function accept(id: string) {
await run(`accept-${id}`, async () => {
await repos.radar.acceptOpportunity(id);
await load();
}, t("radar.today.msg.accepted"));
}
async function dismiss(id: string) {
await run(`dismiss-${id}`, async () => {
await repos.radar.dismissOpportunity(id);
await load();
}, t("radar.today.msg.dismissed"));
}
async function generateReply(id: string, variant: ReplyVariantKind) {
await run(`reply-${id}`, async () => {
const r = await repos.radar.createReply(id, variant);
setReplies((m) => ({ ...m, [id]: r }));
}, t("radar.today.msg.replyReady"));
}
async function markUsed(id: string, channel: "outbox" | "manual_copy") {
const r = replies[id] || data?.high.concat(data.mid, data.low).find((o) => o.id === id)?.default_reply;
if (!r?.id) {
setErr(t("radar.today.msg.needReply"));
return;
}
await run(`mark-${id}`, async () => {
const res = await repos.radar.markReplyUsed(
id,
r.id,
channel,
channel === "outbox" ? sendAccountId : undefined,
);
setReplies((m) => ({ ...m, [id]: res.reply }));
if (res.health_advice) setMsg(res.health_advice);
}, channel === "outbox" ? t("radar.today.msg.sent") : t("radar.today.msg.marked"));
}
async function overrideBand(id: string, band: IntentBand) {
await run(`override-${id}`, async () => {
await repos.radar.overrideOpportunity(id, { band });
await load();
}, t("radar.today.msg.overridden"));
}
async function setPrimary(id: string, productId: string, reason: string) {
await run(`primary-${id}`, async () => {
await repos.radar.setPrimaryProduct(id, productId, reason);
await load();
}, t("radar.today.msg.primarySet"));
}
async function copyReply(text: string) {
try {
await navigator.clipboard.writeText(text);
setMsg(t("radar.today.msg.copied"));
} catch {
setErr(t("radar.today.msg.copyFail"));
}
}
const action = data ? emptyAction(data.empty_reason) : null;
return (
<>
<PageHeader title={t("radar.today.title")} />
<section className="hb-radar-intro">
<div>
<strong>{t("radar.today.introTitle")}</strong>
<p>{t("radar.today.introBody")}</p>
</div>
<nav className="hb-radar-intro__actions" aria-label={t("radar.today.navAria")}>
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.today.manageWatches")}</Link>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("radar.today.viewAll")}</Link>
</nav>
</section>
<section className="hb-radar-filter-panel" aria-label={t("radar.today.filterAria")}>
<div className="hb-radar-filter-panel__head">
<strong>{t("radar.today.filterTitle")}</strong>
<span>{t("radar.today.filterHint")}</span>
</div>
<div className="hb-radar-filter-grid">
{brands.length ? <Select name="radar-filter-brand" label={t("radar.inbox.brand")} value={filterBrand} onChange={(e) => { const value = e.target.value; setFilterBrand(value); setFilterProduct(""); const next = new URLSearchParams(urlParams); if (value) next.set("brand_id", value); else next.delete("brand_id"); next.delete("product_id"); setUrlParams(next, { replace: true }); }}><option value="">{t("radar.inbox.allBrands")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</Select> : null}
{products.length ? <Select name="radar-filter-product" label={t("radar.inbox.product")} value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">{t("radar.inbox.allProducts")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
<Select name="radar-filter-fit" label={t("radar.today.fit")} value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">{t("radar.today.allFit")}</option><option value="strong">{t("radar.today.fit.strong")}</option><option value="possible">{t("radar.today.fit.possible")}</option><option value="weak">{t("radar.today.fit.weak")}</option></Select>
</div>
</section>
<div className="hb-radar-utility-actions">
<span>{t("radar.today.needMore")}</span>
<Button
type="button"
variant="secondary"
onClick={() => {
setExploreOpen((v) => !v);
if (!exploreOpen) setImportOpen(false);
}}
>
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setImportOpen((v) => !v);
if (!importOpen) setExploreOpen(false);
}}
>
{importOpen ? t("radar.import.close") : t("radar.import.open")}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/crm">
{t("radar.today.link.crm")}
</Link>
{accounts.length > 1 ? (
<Select
name="radar-send-account"
label={t("radar.today.sendAccount")}
value={sendAccountId}
onChange={(e) => setSendAccountId(e.target.value)}
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
</option>
))}
</Select>
) : null}
</div>
{exploreOpen ? <ExplorePanel onExplored={() => void load()} /> : null}
{importOpen ? <ManualImportPanel onImported={() => void load()} /> : null}
{err ? (
<p className="hb-banner-error" role="alert">
{err}
</p>
) : null}
{msg ? (
<p className="hb-banner-ok" role="status">
{msg}
</p>
) : null}
{loading && !data ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
{data ? (
<section className="hb-radar-page">
<div className="hb-radar-stats">
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.total}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.total")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.high}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.high")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.mid}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.mid")}</span>
</div>
<div className="hb-radar-stat">
<span className="hb-radar-stat__value">{data.stats.low}</span>
<span className="hb-radar-stat__label">{t("radar.today.stats.low")}</span>
</div>
</div>
{data.truncated_count > 0 ? (
<p className="hb-radar-quota hb-radar-quota--full" role="status">
{t("radar.today.truncated", { n: data.truncated_count })}
</p>
) : null}
{data.last_swept_at ? (
<p className="hb-radar-section__hint">
{t("radar.today.lastSwept", { at: formatTimeAgo(data.last_swept_at) })}
</p>
) : null}
{data.stats.total === 0 ? (
<EmptyState
title={data.empty_hint || t("radar.today.empty.title")}
description={
data.empty_reason
? t(`radar.today.empty.reason.${data.empty_reason}`)
: t("radar.today.empty.fallback")
}
action={
action ? (
<Link className="hb-btn hb-btn--secondary" to={action.to}>
{action.labelKey ? t(action.labelKey) : action.label}
</Link>
) : null
}
/>
) : (
<>
<div className="hb-radar-section">
<h2 className="hb-radar-section__title">
{t("radar.today.group.high")} ({data.high.length})
</h2>
{data.high.length === 0 ? (
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
) : (
data.high.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)}
onMarkUsed={(ch) => void markUsed(o.id, ch)}
onCopyReply={() =>
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
}
onOverrideBand={(b) => void overrideBand(o.id, b)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
)}
</div>
<div className="hb-radar-section">
<h2 className="hb-radar-section__title">
{t("radar.today.group.mid")} ({data.mid.length})
</h2>
{data.mid.length === 0 ? (
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
) : (
data.mid.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)}
onMarkUsed={(ch) => void markUsed(o.id, ch)}
onCopyReply={() =>
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
}
onOverrideBand={(b) => void overrideBand(o.id, b)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
)}
</div>
<div className="hb-radar-section">
<button
type="button"
className="hb-btn hb-btn--ghost"
onClick={() => setLowOpen((v) => !v)}
aria-expanded={lowOpen}
>
{t("radar.today.group.low")} ({data.low.length}) ·{" "}
{lowOpen ? t("radar.today.group.collapse") : t("radar.today.group.expand")}
</button>
{lowOpen
? data.low.map((o) => (
<OppCard
key={o.id}
o={o}
reply={replies[o.id]}
busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)}
onMarkUsed={(ch) => void markUsed(o.id, ch)}
onCopyReply={() =>
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
}
onOverrideBand={(b) => void overrideBand(o.id, b)}
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
/>
))
: null}
</div>
</>
)}
</section>
) : null}
</>
);
}

View File

@ -0,0 +1,106 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../data/mock/keys";
import { I18nProvider } from "../i18n/I18nContext";
import { translate } from "../lib/i18n/messages";
import type { RadarWatch } from "../domain/types";
import { RadarWatchesPage } from "./RadarWatchesPage";
const t = (key: string, params?: Record<string, string | number>) => translate("zh-TW", key, params);
const backend = vi.hoisted(() => ({
watches: [] as RadarWatch[],
assigned: [] as Array<{ id: string; brandId: string; productId: string }>,
}));
vi.mock("../data/DataContext", () => {
const repos = {
scout: {
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
async listProducts() {
return [{
id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "",
match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [],
created_at: 1, updated_at: 1,
}];
},
},
radar: {
async listWatches() {
return {
list: backend.watches,
total: backend.watches.length,
active_count: backend.watches.filter((w) => w.status === "active").length,
max_active: 5,
profile_exists: true,
};
},
async updateWatch(id: string, patch: Record<string, unknown>) {
const w = backend.watches.find((x) => x.id === id)!;
Object.assign(w, patch);
return w;
},
async assignWatchProduct(id: string, brandId: string, productId: string) {
backend.assigned.push({ id, brandId, productId });
const w = backend.watches.find((x) => x.id === id)!;
Object.assign(w, { brand_id: brandId, product_id: productId, context_mode: "product" });
return w;
},
async getDemandMap() {
return { product_id: "p1", state: "empty", pains: [], scenarios: [], audiences: [], exclusions: [], updated_at: 1 };
},
async suggestWatchTerms() { return []; },
},
};
return { useRepos: () => repos };
});
function renderPage() {
return render(
<I18nProvider>
<MemoryRouter>
<RadarWatchesPage />
</MemoryRouter>
</I18nProvider>,
);
}
beforeEach(() => {
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
backend.assigned = [];
backend.watches = [{
id: "w1",
terms: ["敏感肌"],
exclude_terms: [],
regions: [],
status: "active",
created_at: 1,
updated_at: 1,
} as RadarWatch];
});
describe("舊訂閱補綁品牌與產品", () => {
it("選了品牌之後,產品選單不能跟著消失", async () => {
renderPage();
fireEvent.click(await screen.findByRole("button", { name: t("common.edit") }));
fireEvent.change(screen.getByLabelText(t("radar.inbox.brand"), { exact: false }), { target: { value: "b1" } });
const product = await screen.findByLabelText(t("radar.inbox.product"), { exact: false });
expect(product).toBeTruthy();
await waitFor(() => expect(screen.getByRole("option", { name: "舒緩精華" })).toBeTruthy());
});
it("儲存時把補綁的品牌與產品一起寫回", async () => {
renderPage();
fireEvent.click(await screen.findByRole("button", { name: t("common.edit") }));
fireEvent.change(screen.getByLabelText(t("radar.inbox.brand"), { exact: false }), { target: { value: "b1" } });
await waitFor(() => expect(screen.getByRole("option", { name: "舒緩精華" })).toBeTruthy());
fireEvent.change(screen.getByLabelText(t("radar.inbox.product"), { exact: false }), { target: { value: "p1" } });
fireEvent.click(screen.getByRole("button", { name: t("common.save") }));
await waitFor(() => expect(backend.assigned).toEqual([{ id: "w1", brandId: "b1", productId: "p1" }]));
});
});

View File

@ -160,15 +160,19 @@ describe("RadarWatchesPage", () => {
expect(screen.getByText(t("radar.watches.requiredHint"))).toBeTruthy();
});
it("沒服務檔案時停用新增,並指路去填", async () => {
it("沒服務檔案也能新增,品牌只是選項", async () => {
backend.profileExists = false;
renderPage();
expect(await screen.findByText(t("radar.watches.needProfile"))).toBeTruthy();
expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled();
expect(
screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"),
).toBe("/app/policy");
expect(await screen.findByText(t("radar.watches.profileOptional"))).toBeTruthy();
expect(screen.getByRole("button", { name: t("radar.watches.add") })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
fireEvent.change(screen.getByLabelText(t("radar.watches.terms"), { exact: false }), {
target: { value: "台北 美甲推薦" },
});
fireEvent.click(screen.getByRole("button", { name: t("common.save") }));
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText("台北 美甲推薦")).toBeTruthy();
});
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {
@ -224,6 +228,7 @@ describe("RadarWatchesPage", () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
await screen.findByText("客人找設計師時最常這樣問");
@ -238,6 +243,8 @@ describe("RadarWatchesPage", () => {
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
.value,
).toBe("徵才");
// 排除詞在進階區裡,採用後不能把使用者的輸入藏回去
expect(screen.getByRole("button", { name: t("radar.watches.hideAdvanced") })).toBeTruthy();
// 已採用的不能再按,否則使用者會以為沒生效而重複點
await waitFor(() =>
expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2),
@ -246,17 +253,18 @@ describe("RadarWatchesPage", () => {
it("建議失敗時說出原因,不裝作沒有建議", async () => {
backend.suggestError = new ApiError(
"service profile required before suggesting watch terms",
"AI 沒有回傳可用的關鍵字建議,請稍後再試",
400100,
400,
);
renderPage();
await screen.findByText(t("radar.watches.empty"));
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
const banner = await screen.findByRole("alert");
expect(banner.textContent).toContain("service profile required before suggesting watch terms");
expect(banner.textContent).toContain("AI 沒有回傳可用的關鍵字建議");
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
});

View File

@ -47,7 +47,7 @@ function statusTone(status: RadarWatchStatus): "success" | "warning" | "neutral"
return "neutral";
}
/** 雷達訂閱:常駐關鍵字,每日自動巡。沒有服務檔案不能啟用(後端也會擋)。 */
/** 雷達訂閱:常駐關鍵字,每日自動巡。品牌/服務檔案是選項。 */
export function RadarWatchesPage() {
const { t } = useI18n();
const repos = useRepos();
@ -63,6 +63,13 @@ export function RadarWatchesPage() {
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [formOpen, setFormOpen] = useState(false);
/** 這次編輯是「舊訂閱補綁品牌/產品」:選了品牌之後選單不能跟著消失,否則永遠選不到產品。 */
const [rebinding, setRebinding] = useState(false);
/**
*
*
*/
const [advancedOpen, setAdvancedOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
@ -154,6 +161,8 @@ export function RadarWatchesPage() {
setDraft(emptyDraft);
setDemandMap(null);
setPendingDemandMapPatch(null);
setRebinding(false);
setAdvancedOpen(false);
setFormOpen(true);
setMessage("");
setError("");
@ -170,6 +179,9 @@ export function RadarWatchesPage() {
productId: w.product_id || "",
});
setPendingDemandMapPatch(null);
setRebinding(!w.brand_id);
// 編輯既有訂閱的人通常就是要改排除詞或補綁產品,這時候藏起來反而多一次點擊。
setAdvancedOpen(true);
setFormOpen(true);
setMessage("");
setError("");
@ -221,7 +233,7 @@ export function RadarWatchesPage() {
setMessage(typeof okMessage === "function" ? okMessage() : okMessage);
return true;
} catch (e) {
// 配額、缺服務檔案都由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
// 配額等硬限制由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
setError(formatError(e));
return false;
} finally {
@ -236,21 +248,14 @@ export function RadarWatchesPage() {
setError(t("radar.watches.threadsRequired"));
return;
}
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
setError(t("radar.watches.needBrandProduct"));
if ((draft.brandId && !draft.productId) || (!draft.brandId && draft.productId)) {
setError(t("radar.watches.needBrandProductShort"));
return;
}
if (!draft.id && productContextAvailable) {
if (pendingDemandMapPatch?.state === "ready") {
if (draft.productId && pendingDemandMapPatch?.state === "ready") {
const savedMap = await persistDemandMap(pendingDemandMapPatch, false);
if (!savedMap) return;
}
const effectiveDemandMapState = pendingDemandMapPatch?.state ?? demandMap?.state;
if (effectiveDemandMapState !== "ready") {
setError(t("radar.watches.needDemandMap"));
return;
}
}
if (draft.id) {
const ok = await run(
"save",
@ -260,10 +265,17 @@ export function RadarWatchesPage() {
exclude_terms: excludeTerms,
regions: draft.regions,
});
// 補綁的品牌/產品要跟著這次儲存一起生效,不能只在「綁定」按鈕生效。
if (rebinding && draft.brandId && draft.productId) {
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
}
},
t("radar.watches.updated"),
);
if (ok) setFormOpen(false);
if (ok) {
setRebinding(false);
setFormOpen(false);
}
return;
}
let firstSweepTriggered = false;
@ -275,8 +287,8 @@ export function RadarWatchesPage() {
exclude_terms: excludeTerms,
regions: draft.regions,
enabled: draft.enabled,
brand_id: productContextAvailable ? draft.brandId : undefined,
product_id: productContextAvailable ? draft.productId : undefined,
brand_id: draft.brandId || undefined,
product_id: draft.productId || undefined,
});
firstSweepTriggered = Boolean(created.first_sweep_triggered);
},
@ -313,6 +325,7 @@ export function RadarWatchesPage() {
await run("assign", async () => {
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
}, t("radar.watches.assigned"));
setRebinding(false);
setFormOpen(false);
}
@ -321,13 +334,10 @@ export function RadarWatchesPage() {
<PageHeader title={t("radar.watches.title")} />
{!profileExists ? (
<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/policy">
{t("radar.watches.goProfile")}
</Link>
</div>
<p className="hb-radar-section__hint" role="note">
{t("radar.watches.profileOptional")}{" "}
<Link to="/app/policy">{t("radar.watches.goProfile")}</Link>
</p>
) : null}
{error ? (
@ -341,7 +351,7 @@ export function RadarWatchesPage() {
{justTriggeredFirstSweep ? (
<>
{" "}
<Link to="/app/radar">{t("today.radar.open")}</Link>
<Link to="/app/today">{t("today.radar.open")}</Link>
</>
) : null}
</p>
@ -359,12 +369,12 @@ export function RadarWatchesPage() {
<strong>{t("radar.watches.scheduleTitle")}</strong>
<p>{t("radar.watches.scheduleHint")}</p>
</div>
<Link className="hb-btn hb-btn--ghost" to="/app/radar">
<Link className="hb-btn hb-btn--ghost" to="/app/today">
{t("radar.watches.openToday")}
</Link>
</div>
<div className="hb-radar-actions">
<Button type="button" onClick={openCreate} disabled={(!profileExists && !productContextAvailable) || formOpen}>
<Button type="button" onClick={openCreate} disabled={formOpen}>
{t("radar.watches.add")}
</Button>
<Select
@ -392,32 +402,6 @@ export function RadarWatchesPage() {
<p className="hb-radar-section__hint">
<span className="hb-field__required" aria-hidden="true">*</span> {t("radar.watches.requiredHint")}
</p>
{productContextAvailable && (!draft.id || !draft.brandId) ? (
<ProductWatchForm
brands={brands}
products={products}
brandId={draft.brandId}
productId={draft.productId}
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
onProductChange={(productId) => setDraft((d) => ({ ...d, productId, terms: d.id ? d.terms : "" }))}
/>
) : null}
{productContextAvailable && draft.productId ? (() => {
const selectedProduct = products.find((product) => product.id === draft.productId);
return selectedProduct ? (
<DemandMapEditor
product={selectedProduct}
map={demandMap}
loading={demandMapLoading}
error={demandMapError}
saving={busy === "demand-map"}
onDraftChange={setPendingDemandMapPatch}
onSave={(patch) => {
void persistDemandMap(patch);
}}
/>
) : null;
})() : null}
<Textarea
name="radar-watch-terms"
label={t("radar.watches.terms")}
@ -433,15 +417,6 @@ export function RadarWatchesPage() {
{t("radar.watches.threadsWarn")}
</p>
) : null}
<Textarea
name="radar-watch-exclude"
label={t("radar.watches.excludeTerms")}
hint={t("radar.watches.excludeHint")}
rows={3}
value={draft.excludeTerms}
onChange={(e) => setDraft((d) => ({ ...d, excludeTerms: e.target.value }))}
placeholder={t("radar.watches.excludePh")}
/>
<p className="hb-radar-section__hint">{t("radar.watches.regionsHint")}</p>
<div className="hb-radar-area-grid">
@ -479,6 +454,50 @@ export function RadarWatchesPage() {
</label>
)}
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((v) => !v)}>
{advancedOpen ? t("radar.watches.hideAdvanced") : t("radar.watches.showAdvanced")}
</Button>
{advancedOpen ? (
<>
{productContextAvailable && (!draft.id || rebinding) ? (
<>
<p className="hb-radar-section__hint">{t("radar.watches.optionalBrandHint")}</p>
<ProductWatchForm
brands={brands}
products={products}
brandId={draft.brandId}
productId={draft.productId}
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
onProductChange={(productId) => setDraft((d) => ({ ...d, productId, terms: d.id ? d.terms : "" }))}
/>
</>
) : null}
{productContextAvailable && draft.productId ? (() => {
const selectedProduct = products.find((product) => product.id === draft.productId);
return selectedProduct ? (
<DemandMapEditor
product={selectedProduct}
map={demandMap}
loading={demandMapLoading}
error={demandMapError}
saving={busy === "demand-map"}
onDraftChange={setPendingDemandMapPatch}
onSave={(patch) => {
void persistDemandMap(patch);
}}
/>
) : null;
})() : null}
<Textarea
name="radar-watch-exclude"
label={t("radar.watches.excludeTerms")}
hint={t("radar.watches.excludeHint")}
rows={3}
value={draft.excludeTerms}
onChange={(e) => setDraft((d) => ({ ...d, excludeTerms: e.target.value }))}
placeholder={t("radar.watches.excludePh")}
/>
<WatchSuggestPanel
onAdopt={adopt}
onAdoptAll={adoptAll}
@ -487,9 +506,11 @@ export function RadarWatchesPage() {
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
/>
</>
) : null}
<div className="hb-radar-actions">
{draft.id && !draft.brandId ? (
{draft.id && rebinding ? (
<Button type="button" variant="secondary" onClick={() => void assignProduct()} disabled={busy === "assign" || !draft.brandId || !draft.productId}>
{busy === "assign" ? t("radar.watches.assigning") : t("radar.watches.assign")}
</Button>

View File

@ -1,57 +1,9 @@
import { render, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Repos } from "../data/repos";
import { I18nProvider } from "../i18n/I18nContext";
import { describe, expect, it } from "vitest";
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
import { TodayPage } from "./TodayPage";
const harness = vi.hoisted(() => ({
repos: null as Repos | null,
legacyListPostsCalls: 0,
}));
vi.mock("../data/DataContext", () => ({
useRepos: () => harness.repos,
useData: () => ({ repos: harness.repos, tick: 0, refresh: () => {} }),
}));
function buildRepos(): Repos {
return {
scout: {
async listPosts() {
harness.legacyListPostsCalls += 1;
return [];
},
async listRuns() { return { list: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } }; },
async listRunPosts() { throw new Error("not used"); },
},
accounts: { async list() { return []; } },
outbox: { async list() { return []; } },
inspiration: {
async listTrends() { return []; },
async refreshTrends() { return []; },
},
ownPosts: { async list() { return []; }, async sync() { return []; } },
mentions: { async list() { return []; } },
growth: { async getOutcomeSummary() { return null; }, async getLatestCheckup() { return null; } },
radar: { async getToday() { return null; } },
} as unknown as Repos;
}
describe("TodayPage legacy Scout compatibility", () => {
beforeEach(() => {
harness.repos = buildRepos();
harness.legacyListPostsCalls = 0;
});
it("continues to load the legacy listPosts endpoint", async () => {
render(
<MemoryRouter>
<I18nProvider>
<TodayPage />
</I18nProvider>
</MemoryRouter>,
);
await waitFor(() => expect(harness.legacyListPostsCalls).toBeGreaterThan(0));
describe("TodayPage", () => {
it("is the demand inbox, not the old dashboard", () => {
expect(TodayPage).toBe(RadarOpportunitiesPage);
});
});

View File

@ -1,757 +1,2 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type {
MentionItem,
OutcomeSummary,
OutboxBundle,
OwnPost,
RadarToday,
ScoutPost,
ThreadsAccount,
TrendItem,
WeeklyCheckup,
} from "../domain/types";
import { useFirstRun } from "../firstRun/FirstRunContext";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { loadScoutToday } from "../lib/scoutToday";
function isPendingScout(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted";
}
function startOfLocalDayNano(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime() * 1_000_000;
}
type AccountPulse = {
account: ThreadsAccount;
posts: number;
views: number;
likes: number;
replies: number;
topInsight?: string;
};
/**
*
* live repos tab
*/
export function TodayPage() {
const repos = useRepos();
const { tick, refresh } = useData();
const { t, locale } = useI18n();
const formatApiError = useFormatApiError();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [trendMessage, setTrendMessage] = useState("");
const [refreshingTrends, setRefreshingTrends] = useState(false);
const [syncingPosts, setSyncingPosts] = useState(false);
const [scoutPosts, setScoutPosts] = useState<ScoutPost[]>([]);
const [outbox, setOutbox] = useState<OutboxBundle[]>([]);
const [trends, setTrends] = useState<TrendItem[]>([]);
const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]);
const [mentions, setMentions] = useState<MentionItem[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [scoutDone, setScoutDone] = useState(0);
const [scoutGoal, setScoutGoal] = useState(8);
const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null);
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
const [radarToday, setRadarToday] = useState<RadarToday | null>(null);
const { active: firstRun } = useFirstRun();
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
const todayLabel = new Date().toLocaleDateString(dateLocale, {
month: "numeric",
day: "numeric",
weekday: "short",
});
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const scoutLocal = loadScoutToday();
setScoutGoal(scoutLocal.goalValue);
const [posts, box, trendList, acc, owns, mentionList, outcomes, latestCheckup, radar] =
await Promise.all([
repos.scout.listPosts(),
repos.outbox.list(),
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
repos.accounts.list(),
repos.ownPosts.list().catch(() => [] as OwnPost[]),
repos.mentions.list().catch(() => [] as MentionItem[]),
repos.growth.getOutcomeSummary("week").catch(() => null),
repos.growth.getLatestCheckup().catch(() => null),
repos.radar.getToday().catch(() => null),
]);
setOutcomeSummary(outcomes);
setCheckup(latestCheckup);
setRadarToday(radar);
const usable = acc.filter((a) => a.is_usable);
setAccounts(usable.length ? usable : acc);
setScoutPosts(posts);
setOutbox(box);
setTrends(
[...trendList]
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
.slice(0, 12),
);
setOwnPosts(owns);
setMentions(mentionList);
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
const dayStart = startOfLocalDayNano();
const publishedN = posts.filter(
(p) =>
p.outreach_status === "published" &&
p.published_at != null &&
p.published_at >= dayStart,
).length;
setScoutDone(Math.max(scoutLocal.done, publishedN));
// 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
if (!owns.length && usable.length) {
void Promise.all(
usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
).then((chunks) => setOwnPosts(chunks.flat()));
}
if (!mentionList.length && usable[0]?.id) {
void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
}
} catch (e) {
setError(formatApiError(e, "today.loadFail"));
} finally {
setLoading(false);
}
}, [repos, formatApiError]);
useEffect(() => {
void load();
}, [load, tick]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
const dayStart = startOfLocalDayNano();
const pendingScout = useMemo(
() =>
scoutPosts
.filter(isPendingScout)
.slice()
.sort((a, b) => (b.score || 0) - (a.score || 0)),
[scoutPosts],
);
const pendingMentions = useMemo(
() => mentions.filter((m) => m.status === "pending"),
[mentions],
);
const failedOutbox = useMemo(
() => outbox.filter((o) => o.status === "partial_failed"),
[outbox],
);
const runningOutbox = useMemo(
() => outbox.filter((o) => o.status === "scheduling" || o.status === "active"),
[outbox],
);
const sentToday = useMemo(() => {
return outbox.filter((o) => {
if (o.status !== "completed") return false;
return o.updated_at >= dayStart;
}).length;
}, [outbox, dayStart]);
const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100));
const topicCards = useMemo(() => trends.slice(0, 6), [trends]);
const accountPulses = useMemo((): AccountPulse[] => {
const byAcc = new Map<string, OwnPost[]>();
for (const p of ownPosts) {
const list = byAcc.get(p.account_id) || [];
list.push(p);
byAcc.set(p.account_id, list);
}
const rows: AccountPulse[] = [];
for (const acc of accounts) {
const posts = byAcc.get(acc.id) || [];
if (!posts.length && accounts.length > 3) continue;
const views = posts.reduce((s, p) => s + (p.view_count || 0), 0);
const likes = posts.reduce((s, p) => s + (p.like_count || 0), 0);
const replies = posts.reduce((s, p) => s + (p.reply_count || 0), 0);
const top = posts.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0))[0];
rows.push({
account: acc,
posts: posts.length,
views,
likes,
replies,
topInsight: top?.insight || top?.formula_summary,
});
}
if (!rows.length) {
for (const [accId, posts] of byAcc) {
const acc =
accounts.find((a) => a.id === accId) ||
({
id: accId,
username: accId,
display_name: accId,
connection: "connected",
is_usable: true,
avatar_color: "var(--hb-muted)",
} as ThreadsAccount);
rows.push({
account: acc,
posts: posts.length,
views: posts.reduce((s, p) => s + (p.view_count || 0), 0),
likes: posts.reduce((s, p) => s + (p.like_count || 0), 0),
replies: posts.reduce((s, p) => s + (p.reply_count || 0), 0),
topInsight: posts[0]?.insight,
});
}
}
return rows
.sort((a, b) => b.views - a.views || b.likes - a.likes)
.slice(0, 4);
}, [accounts, ownPosts]);
const pendingPreview = pendingScout.slice(0, 5);
const hasOutcome = Boolean(
outcomeSummary &&
(outcomeSummary.reach ||
outcomeSummary.conversations ||
outcomeSummary.follows_possible ||
outcomeSummary.follows_confirmed ||
outcomeSummary.conversions),
);
async function onRefreshTrends() {
setRefreshingTrends(true);
setError("");
setTrendMessage("");
try {
const list = await repos.inspiration.refreshTrends("all");
if (!list.length) {
throw new Error(t("today.trendsFail"));
}
setTrends(
[...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
);
setTrendMessage(t("today.trendsUpdated", { n: list.length }));
refresh();
} catch (e) {
setError(formatApiError(e, "today.trendsFail"));
} finally {
setRefreshingTrends(false);
}
}
async function onSyncOwnPosts() {
const acc = accounts[0];
if (!acc) {
setError(t("today.needAccount"));
return;
}
setSyncingPosts(true);
setError("");
try {
const list = await repos.ownPosts.sync(acc.id);
setOwnPosts((prev) => {
const others = prev.filter((p) => p.account_id !== acc.id);
return [...list, ...others];
});
refresh();
} catch (e) {
setError(formatApiError(e, "today.syncPostsFail"));
} finally {
setSyncingPosts(false);
}
}
function topicHref(label: string) {
const q = new URLSearchParams({ tab: "inspire", topic: label });
return `/app/studio?${q.toString()}`;
}
if (loading && !scoutPosts.length && !trends.length && !outbox.length) {
return (
<>
<PageHeader title={t("nav.today")} description={todayLabel} />
<p className="text-muted">{t("common.loading")}</p>
</>
);
}
return (
<>
<PageHeader title={t("nav.today")} description={todayLabel} />
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
{radarToday && radarToday.stats.total > 0 ? (
<>
<div className="hb-today-metrics" role="group" aria-label={t("today.radar.title")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.total")}</span>
<span className="hb-today-metric__value">{radarToday.stats.total}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.high")}</span>
<span className="hb-today-metric__value">{radarToday.stats.high}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.mid")}</span>
<span className="hb-today-metric__value">{radarToday.stats.mid}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.radar.low")}</span>
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
</div>
</div>
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
{t("today.radar.open")}
</Link>
</>
) : (
<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"
}
>
{radarToday?.empty_reason === "no_profile"
? t("radar.today.empty.goBrands")
: t("today.radar.goWatches")}
</Link>
</p>
)}
</Card>
{firstRun ? null : <>
<Card title={t("today.outcome.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
<div className="hb-today-metrics" role="group" aria-label={t("today.outcome.title")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.reach")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.reach ?? 0}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.conversations")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.conversations ?? 0}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.follows")}</span>
<span className="hb-today-metric__value">
{outcomeSummary?.follows_possible ?? 0}
{outcomeSummary?.follows_confirmed ? (
<span className="hb-today-metric__den">
{t("today.outcome.followsConfirmedHint", { n: outcomeSummary.follows_confirmed })}
</span>
) : null}
</span>
<span className="hb-today-metric__hint">{t("today.outcome.followsHint")}</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.outcome.conversions")}</span>
<span className="hb-today-metric__value">{outcomeSummary?.conversions ?? 0}</span>
{outcomeSummary?.conversion_amount ? (
<span className="hb-today-metric__hint">
${Math.round(outcomeSummary.conversion_amount)}
</span>
) : null}
</div>
</div>
{!hasOutcome ? (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.outcome.emptyHint")}{" "}
<Link to="/app/scout">{t("today.goScout")}</Link>
</p>
) : null}
{checkup ? (
<div style={{ marginTop: "0.75rem" }}>
<p className="text-muted" style={{ margin: 0 }}>
{t("today.checkup.prefix")}
{checkup.summary}
</p>
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1.2rem" }}>
{checkup.actions.slice(0, 3).map((a) => (
<li key={a.title}>
<Link
to={
a.deeplink === "scout"
? "/app/scout"
: a.deeplink === "studio_compose"
? "/app/studio?tab=compose"
: a.deeplink === "studio_inspire"
? "/app/studio?tab=inspire"
: a.deeplink === "crew_persona"
? "/app/crew"
: a.deeplink === "outbox"
? "/app/outbox"
: "/app/today"
}
>
{a.title}
</Link>
<span className="text-muted"> {a.reason}</span>
</li>
))}
</ul>
</div>
) : (
<p className="text-muted" style={{ margin: "0.75rem 0 0", fontSize: "var(--hb-text-sm)" }}>
{t("today.checkup.empty")}
</p>
)}
</Card>
<div className="hb-today-actions">
<Link to="/app/scout">
<Button type="button" variant={pendingScout.length ? "primary" : "ghost"}>
{pendingScout.length
? t("today.pendingRepliesN", { n: pendingScout.length })
: t("today.pendingReplies")}
</Button>
</Link>
<Link to="/app/studio?tab=compose">
<Button type="button" variant="ghost">
{t("today.newThread")}
</Button>
</Link>
<Button type="button" variant="ghost" onClick={() => void load()} disabled={loading}>
{t("today.reload")}
</Button>
</div>
{/* 數值列 */}
<div className="hb-today-metrics" role="group" aria-label={t("today.metricsAria")}>
<Link to="/app/scout" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.pending")}</span>
<span className="hb-today-metric__value">{pendingScout.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span>
</Link>
<div className="hb-today-metric" title={t("today.metric.doneGoalHint")}>
<span className="hb-today-metric__label">{t("today.metric.doneGoal")}</span>
<span className="hb-today-metric__value">
{scoutDone}
<span className="hb-today-metric__den">/{scoutGoal}</span>
</span>
<div className="hb-progress hb-progress--sm" aria-hidden>
<div className="hb-progress__bar" style={{ width: `${goalPct}%` }} />
</div>
</div>
<Link to="/app/outbox" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.sentToday")}</span>
<span className="hb-today-metric__value">{sentToday}</span>
<span className="hb-today-metric__hint">
{runningOutbox.length
? t("today.metric.running", { n: runningOutbox.length })
: t("today.metric.sentDone")}
</span>
</Link>
<Link
to="/app/outbox"
className={`hb-today-metric${failedOutbox.length ? " is-alert" : ""}`}
>
<span className="hb-today-metric__label">{t("today.metric.failed")}</span>
<span className="hb-today-metric__value">{failedOutbox.length}</span>
<span className="hb-today-metric__hint">
{failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
</span>
</Link>
<Link to="/app/studio?tab=mentions" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.mentions")}</span>
<span className="hb-today-metric__value">{pendingMentions.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.mentionsHint")}</span>
</Link>
</div>
{/* 待回覆 · 海巡 */}
<Card title={t("today.pending.title", { n: pendingScout.length })}>
{pendingPreview.length === 0 ? (
<EmptyState
title={t("today.pending.empty")}
action={
<Link to="/app/scout">
<Button type="button">{t("today.goScout")}</Button>
</Link>
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-list">
{pendingPreview.map((p) => (
<li key={p.id}>
<Link to="/app/scout" className="hb-today-list__item">
<span className="hb-today-list__meta">
@{p.author}
{p.search_tag ? ` · ${p.search_tag}` : ""}
{typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
{p.outreach_status === "drafted" ? (
<>
{" · "}
<Badge tone="brand">{t("today.badge.drafted")}</Badge>
</>
) : null}
</span>
<span className="hb-today-list__text">
{p.text.slice(0, 96)}
{p.text.length > 96 ? "…" : ""}
</span>
{p.opportunity ? (
<span className="hb-today-list__meta">{p.opportunity}</span>
) : null}
</Link>
</li>
))}
</ul>
{pendingScout.length > pendingPreview.length ? (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "}
<Link to="/app/scout">{t("today.pending.handle")}</Link>
</p>
) : (
<Link to="/app/scout">
<Button type="button" variant="ghost">
{t("today.pending.start")}
</Button>
</Link>
)}
</div>
)}
</Card>
{/* 找話題 */}
<Card title={t("today.topics.title")}>
<div className="hb-today-actions" style={{ margin: "0 0 0.5rem" }}>
<Button
type="button"
variant="ghost"
disabled={refreshingTrends}
onClick={() => void onRefreshTrends()}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
{trendMessage ? (
<span className="text-muted" role="status">
{trendMessage}
</span>
) : null}
</div>
{topicCards.length === 0 ? (
<EmptyState
title={t("today.topics.empty")}
action={
<div className="hb-today-actions" style={{ margin: 0 }}>
<Button
type="button"
onClick={() => void onRefreshTrends()}
disabled={refreshingTrends}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("today.goStudio")}
</Button>
</Link>
</div>
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-list">
{topicCards.map((item) => (
<li key={item.id}>
<Link to={topicHref(item.label)} className="hb-today-list__item">
<span className="hb-today-list__meta">
{item.label}
{typeof item.heat === "number" && item.heat > 0 ? (
<Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge>
) : null}
{item.source_label ? (
<span className="text-muted"> · {item.source_label}</span>
) : null}
</span>
<span className="hb-today-list__text">
{item.summary?.slice(0, 100) ||
item.samples?.[0] ||
t("today.topicAngle")}
{(item.summary?.length || 0) > 100 ? "…" : ""}
</span>
</Link>
</li>
))}
</ul>
<div className="hb-today-actions" style={{ margin: 0 }}>
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("today.moreInspire")}
</Button>
</Link>
{topicCards[0] ? (
<Link to={topicHref(topicCards[0].label)}>
<Button type="button" variant="ghost">
{t("today.useTopic")}
</Button>
</Link>
) : null}
</div>
</div>
)}
</Card>
{/* 發送摘要 */}
<Card title={t("today.outbox.title")}>
{failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (
<p className="text-muted" style={{ margin: 0 }}>
{t("today.outbox.empty")}{" "}
<Link to="/app/studio?tab=compose">{t("today.newThread")}</Link>
{t("today.outbox.emptyMid")}{" "}
<Link to="/app/outbox">{t("nav.outbox")}</Link>
{t("today.outbox.emptyEnd")}
</p>
) : (
<div className="hb-stack" style={{ gap: "0.5rem" }}>
<p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("today.outbox.summary", {
sent: sentToday,
running: runningOutbox.length,
failed: failedOutbox.length,
})}
</p>
{failedOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
<Badge tone="danger">{t("today.badge.failed")}</Badge>{" "}
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
</p>
))}
{runningOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
<Badge tone="brand">
{o.status === "scheduling"
? t("today.badge.scheduling")
: t("today.badge.sending")}
</Badge>{" "}
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
</p>
))}
<Link to="/app/outbox">
<Button type="button" variant="ghost">
{t("today.openOutbox")}
</Button>
</Link>
</div>
)}
</Card>
{/* 帳號成效 */}
<Card title={t("today.accounts.title")}>
{accountPulses.length === 0 ? (
<EmptyState
title={t("today.accounts.empty")}
action={
<div className="hb-today-actions" style={{ margin: 0 }}>
{accounts.length > 0 ? (
<Button
type="button"
onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
) : (
<Link to="/app/crew">
<Button type="button" variant="ghost">
{t("nav.crew")}
</Button>
</Link>
)}
</div>
}
/>
) : (
<div className="hb-stack">
<ul className="hb-today-account-list">
{accountPulses.map((row) => (
<li key={row.account.id} className="hb-today-account">
<div className="hb-today-account__head">
<strong>@{row.account.username}</strong>
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("today.postsCount", { n: row.posts })}
</span>
</div>
<div className="hb-today-account__stats">
<span>
{t("today.views")}{" "}
<strong>{row.views.toLocaleString(dateLocale)}</strong>
</span>
<span>
{t("today.likes")} <strong>{row.likes}</strong>
</span>
<span>
{t("today.repliesShort")} <strong>{row.replies}</strong>
</span>
</div>
{row.topInsight ? (
<p className="hb-today-account__insight">{row.topInsight}</p>
) : null}
</li>
))}
</ul>
<div className="hb-today-actions" style={{ margin: 0 }}>
<Link to="/app/insights">
<Button type="button">{t("today.fullInsights")}</Button>
</Link>
<Link to="/app/studio?tab=posts">
<Button type="button" variant="ghost">
{t("today.viewPosts")}
</Button>
</Link>
<Button
type="button"
variant="ghost"
onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts || !accounts.length}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
<Link to="/app/crew">
<Button type="button" variant="ghost">
{t("today.manageAccounts")}
</Button>
</Link>
</div>
</div>
)}
</Card>
</>}
</>
);
}
/** 今日=商機收件匣。舊儀表板已拆進進階頁,這裡直接開工。 */
export { RadarOpportunitiesPage as TodayPage } from "./RadarOpportunitiesPage";

View File

@ -0,0 +1,200 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../data/mock/keys";
import type { Opportunity, RadarWatch } from "../domain/types";
import { I18nProvider } from "../i18n/I18nContext";
import { TodayPage } from "./TodayPage";
const backend = vi.hoisted(() => ({
accepted: [] as string[],
reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>,
replies: [] as Array<{ id: string; variant: string }>,
created: [] as Array<Record<string, unknown>>,
sweeps: [] as string[],
firstSweepTriggered: true,
byScope: null as null | ((filter: Record<string, unknown>) => { list: Opportunity[]; total: number }),
watches: [] as RadarWatch[],
lastSweptAt: 0,
}));
function watch(overrides: Partial<RadarWatch> = {}): RadarWatch {
return {
id: "w1",
terms: ["敏感肌"],
exclude_terms: [],
regions: [],
status: "active",
last_swept_at: backend.lastSweptAt,
created_at: 1,
updated_at: 1,
...overrides,
} as RadarWatch;
}
function opportunity(): Opportunity {
return {
id: "opp-1",
source: "threads",
external_id: "shared-post",
permalink: "https://www.threads.net/@buyer/post/shared-post",
author_handle: "buyer",
text: "台北有人推薦美甲嗎",
posted_at: Date.now() * 1e6,
status: "qualified",
intent_score: 88,
intent_band: "high",
reasons: [],
region_match: "unknown",
freshness_hours: 1,
matched_terms: ["美甲"],
created_at: Date.now() * 1e6,
review_state: "pending",
};
}
vi.mock("../data/DataContext", () => ({
// 必須回傳同一個物件:頁面的 useCallback 依賴 repos.radar每次新物件會讓 effect 無限重跑。
useRepos: (() => {
const repos = {
accounts: { async list() { return []; } },
scout: {
async listBrands() { return []; },
async listProducts() { return []; },
},
jobs: {
async get(id: string) {
return { id, template_type: "radar_sweep", status: "succeeded", progress_summary: "完成", progress_percent: 100, created_at: 1, updated_at: 1 };
},
},
radar: {
async listOpportunities(filter: Record<string, unknown>) {
if (backend.byScope) return backend.byScope(filter);
return { list: [], total: 0 };
},
async listWatches() {
return {
list: backend.watches,
total: backend.watches.length,
active_count: backend.watches.filter((w) => w.status === "active").length,
max_active: 5,
profile_exists: true,
};
},
async getToday() {
return { stats: { total: 0, high: 0, mid: 0, low: 0 }, high: [], mid: [], low: [], truncated_count: 0, last_swept_at: backend.lastSweptAt };
},
async listSweeps() { return { list: [], total: 0 }; },
async triggerWatchSweep(id: string) {
backend.sweeps.push(id);
return { job_id: `job-${id}` };
},
async createWatch(input: Record<string, unknown>) {
backend.created.push(input);
backend.watches = [watch()];
return { id: "w1", ...input, status: "active", first_sweep_triggered: backend.firstSweepTriggered };
},
async acceptOpportunity(id: string) {
backend.accepted.push(id);
return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" };
},
async createReply(id: string, variant: string) {
backend.replies.push({ id, variant });
return { id: "r1", opportunity_id: id, variant, text: "嗨,我在台北做美甲,可以看看我的作品集嗎?", created_at: 1 };
},
async updateOpportunityReviewState(id: string, patch: Record<string, unknown>) {
backend.reviews.push({ id, patch });
return { ...opportunity(), id, review_state: patch.state } as Opportunity;
},
},
};
return () => repos;
})(),
}));
function renderToday() {
return render(
<MemoryRouter initialEntries={["/app/today"]}>
<I18nProvider><TodayPage /></I18nProvider>
</MemoryRouter>,
);
}
beforeEach(() => {
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
backend.accepted = [];
backend.reviews = [];
backend.replies = [];
backend.created = [];
backend.sweeps = [];
backend.byScope = null;
backend.firstSweepTriggered = true;
backend.lastSweptAt = Date.now() * 1e6;
backend.watches = [watch()];
});
describe("今日簡化流程", () => {
it("回他會把人加進名單,並給出名單入口", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
vi.spyOn(window, "open").mockReturnValue(null);
renderToday();
fireEvent.click(await screen.findByRole("button", { name: "回他" }));
await waitFor(() => expect(backend.accepted).toEqual(["opp-1"]));
expect(await screen.findByText(/也幫你加進名單/)).toBeTruthy();
expect(screen.getByRole("link", { name: "前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
});
it("自動放寬到近 7 天後看完,不會叫使用者去清一個他沒設過的篩選", async () => {
// 只有近 7 天有待處理的,今天沒有 → 頁面會自己放寬;跳過之後就真的空了。
backend.byScope = (filter) => {
const cleared = backend.reviews.length > 0;
if (!cleared && filter.time_scope === "7d" && filter.review_state === "pending") {
return { list: [opportunity()], total: 1 };
}
return { list: [], total: 0 };
};
renderToday();
await screen.findByText("台北有人推薦美甲嗎");
fireEvent.click(screen.getByRole("button", { name: "先跳過" }));
expect(await screen.findByText("今天這批看完了")).toBeTruthy();
expect(screen.queryByRole("button", { name: "清除篩選" })).toBeNull();
});
it("第一組訂閱建立後說正在找,不再叫使用者按立即巡邏", async () => {
backend.watches = [];
backend.lastSweptAt = 0;
renderToday();
fireEvent.change(await screen.findByLabelText(/關鍵字/), { target: { value: "台北 美甲推薦" } });
fireEvent.click(screen.getByRole("button", { name: "開始找客人" }));
await waitFor(() => expect(backend.created).toHaveLength(1));
expect(await screen.findByText("正在幫你找,大約幾分鐘")).toBeTruthy();
expect(screen.queryByRole("button", { name: "立即巡邏" })).toBeNull();
expect(backend.sweeps).toEqual([]);
});
it("想不到怎麼回時,同一頁就能拿到草稿", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
renderToday();
// 卡片上的入口打開抽屜;抽屜裡才真的花點數生成。
fireEvent.click((await screen.findAllByRole("button", { name: "幫我想回覆" }))[0]);
const drawer = await screen.findByRole("dialog");
fireEvent.click(within(drawer).getByRole("button", { name: "幫我想回覆" }));
await waitFor(() => expect(backend.replies).toEqual([{ id: "opp-1", variant: "public_comment" }]));
expect(await screen.findByText(/我在台北做美甲/)).toBeTruthy();
});
it("留得住進階頁入口", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
renderToday();
const link = await screen.findByRole("link", { name: "看全部結果" });
expect(link.getAttribute("href")).toBe("/app/radar/opportunities");
});
});

View File

@ -4708,6 +4708,13 @@ svg {
gap: 0.5rem;
}
.hb-today-simple-bar {
display: flex;
flex-wrap: wrap;
gap: var(--hb-gap-inline);
margin: 0 0 var(--hb-gap-section);
}
.hb-today-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@ -977,6 +977,38 @@ a.hb-topbar__chip:hover {
color: var(--hb-subtle);
}
.hb-nav__group-toggle {
display: flex;
align-items: center;
gap: var(--hb-space-1);
width: 100%;
margin: 0 0 var(--hb-space-1);
padding: var(--hb-space-1) var(--hb-space-2);
border: 0;
border-radius: var(--hb-radius);
background: none;
font-size: var(--hb-text-2xs);
font-weight: 700;
letter-spacing: 0.06em;
color: var(--hb-subtle);
text-align: left;
cursor: pointer;
}
.hb-nav__group-toggle::after {
content: "▸";
font-size: var(--hb-text-2xs);
}
.hb-nav__group-toggle[aria-expanded="true"]::after {
content: "▾";
}
.hb-nav__group-toggle:hover {
background: var(--hb-surface-muted);
color: var(--hb-ink);
}
.hb-nav {
display: flex;
flex-direction: column;