fix all bug
This commit is contained in:
parent
3b113feb3a
commit
2f1336b932
|
|
@ -43,7 +43,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒。
|
// profile_exists 讓雷達頁顯示「服務檔案可之後再補」,不再當硬門檻。
|
||||||
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
|
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -129,30 +129,21 @@ func (e *m1Env) sweepCandidates(t *testing.T) []string {
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP-01:新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。
|
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsAllowed(t *testing.T) {
|
||||||
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) {
|
|
||||||
env := newM1Env(t, 42, 5)
|
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{"婚攝 推薦"},
|
Terms: []string{"婚攝 推薦"},
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("SP-01: active watch was created without a service profile")
|
t.Fatalf("SP-01: active watch without profile: %v", err)
|
||||||
}
|
}
|
||||||
envelope := assertStatus(t, err, http.StatusBadRequest, 400100)
|
if w.Status != "active" {
|
||||||
if !strings.Contains(envelope.Message, "service-profile") {
|
t.Fatalf("SP-01: status = %q, want active", w.Status)
|
||||||
t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message)
|
|
||||||
}
|
}
|
||||||
// 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。
|
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 1 {
|
||||||
if got := env.list(t, ""); got.Pagination.Total != 0 {
|
t.Fatalf("SP-01: list = %+v, want one active watch", got)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,20 +39,18 @@ func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP-01:沒建服務檔案就建 active 訂閱 → 400100,訊息要指向服務檔案而不是只說「失敗」。
|
func TestCreateActiveWatchWithoutProfileSucceeds(t *testing.T) {
|
||||||
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
|
|
||||||
ctx, svcCtx := watchCtx(t, 42, 5, false)
|
ctx, svcCtx := watchCtx(t, 42, 5, false)
|
||||||
|
|
||||||
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
|
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
|
||||||
Terms: []string{"婚攝 推薦"},
|
Terms: []string{"婚攝 推薦"},
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("active watch created without a service profile")
|
t.Fatalf("active watch without profile: %v", err)
|
||||||
}
|
}
|
||||||
env := assertStatus(t, err, http.StatusBadRequest, 400100)
|
if w.Status != "active" {
|
||||||
if !strings.Contains(env.Message, "service-profile") {
|
t.Fatalf("status = %q, want active", w.Status)
|
||||||
t.Fatalf("message must point at the service profile, got %q", env.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic {
|
||||||
ServiceProfile 把 domain 檔案轉成 API 形狀。
|
ServiceProfile 把 domain 檔案轉成 API 形狀。
|
||||||
|
|
||||||
p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。
|
p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。
|
||||||
但 exists 這個欄位必須誠實,訂閱閘門(SP-01)與前端引導都看它。
|
但 exists 這個欄位必須誠實,前端用它決定要不要顯示「之後再補」提示。
|
||||||
*/
|
*/
|
||||||
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
|
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,7 @@ type FaqItem struct {
|
||||||
/*
|
/*
|
||||||
ServiceProfile 每會員一份,_id 就是 owner_uid。
|
ServiceProfile 每會員一份,_id 就是 owner_uid。
|
||||||
|
|
||||||
這份檔案是判定與回覆生成的共同輸入:沒有它,五問判定沒有比對基準,回覆也沒有
|
這份檔案是判定與回覆生成的加分輸入:沒有它仍可用關鍵字巡邏,只是比對與回覆較通用。
|
||||||
價格與案例可講,所以未建檔時不允許建立 active 訂閱(SP-01)。
|
|
||||||
*/
|
*/
|
||||||
type ServiceProfile struct {
|
type ServiceProfile struct {
|
||||||
OwnerUID int64 `bson:"_id" json:"owner_uid"`
|
OwnerUID int64 `bson:"_id" json:"owner_uid"`
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
@ -57,7 +58,10 @@ func (c *charge) Release(ctx context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.settled = true
|
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)
|
logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) {
|
if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil {
|
||||||
t.Fatalf("generic watch without profile err=%v", err)
|
t.Fatalf("generic watch without profile should resume: %v", err)
|
||||||
}
|
}
|
||||||
assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1")
|
assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,7 @@ func New(repo domain.Repository) *Service {
|
||||||
GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。
|
GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。
|
||||||
|
|
||||||
HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能
|
HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能
|
||||||
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來,而 SP-01 的
|
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來。
|
||||||
訂閱閘門正是靠這個差別。
|
|
||||||
*/
|
*/
|
||||||
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
|
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
|
||||||
if ownerUID <= 0 {
|
if ownerUID <= 0 {
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ func TestUpsertRequiresAtLeastOneService(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。
|
// 「沒建檔」與「建了一份空的」必須分得出來:列表的 profile_exists 靠這個差別。
|
||||||
func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) {
|
func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) {
|
||||||
svc := newTestService()
|
svc := newTestService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,")
|
b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,")
|
||||||
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
|
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
|
||||||
|
if p == nil {
|
||||||
|
b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n")
|
||||||
|
} else {
|
||||||
b.WriteString("服務項目:\n")
|
b.WriteString("服務項目:\n")
|
||||||
for _, s := range p.Services {
|
for _, s := range p.Services {
|
||||||
b.WriteString("- " + s.Name)
|
b.WriteString("- " + s.Name)
|
||||||
|
|
@ -95,6 +95,7 @@ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
||||||
if p.ToneNote != "" {
|
if p.ToneNote != "" {
|
||||||
b.WriteString("語氣備註:" + p.ToneNote + "\n")
|
b.WriteString("語氣備註:" + p.ToneNote + "\n")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(extra) > 0 {
|
if len(extra) > 0 {
|
||||||
// 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。
|
// 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。
|
||||||
b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n")
|
b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n")
|
||||||
|
|
@ -123,10 +124,11 @@ type PainTermSource interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
SuggestWatchTerms 依服務檔案回關鍵字建議(RW-03)。
|
SuggestWatchTerms 回關鍵字建議(RW-03)。
|
||||||
|
|
||||||
不自動寫入任何 watch:使用者逐條採用才有意義,也才看得懂每個詞是為什麼在那裡。
|
服務檔案是加分項,不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。
|
||||||
計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5),不新增第五個 meter。
|
不自動寫入任何 watch:使用者逐條採用才有意義。
|
||||||
|
計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5);fallback 不扣點。
|
||||||
*/
|
*/
|
||||||
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
|
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
|
||||||
if ownerUID <= 0 {
|
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)
|
profile, err := s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
profile = nil
|
||||||
|
}
|
||||||
|
|
||||||
var extra []string
|
var extra []string
|
||||||
if s.PainTerms != nil {
|
if s.PainTerms != nil {
|
||||||
|
|
@ -166,18 +165,69 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i
|
||||||
}
|
}
|
||||||
defer charge.Settle(ctx, &err)
|
defer charge.Settle(ctx, &err)
|
||||||
|
|
||||||
raw, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
|
raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
|
||||||
if err != nil {
|
var out []domain.WatchTermSuggestion
|
||||||
return nil, err
|
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 {
|
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)
|
return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
if aiErr != nil {
|
||||||
|
// 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。
|
||||||
|
charge.Release(ctx)
|
||||||
|
}
|
||||||
return out, nil
|
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
|
// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product
|
||||||
// snapshot. If the AI provider is unavailable, the catalog's own structured
|
// snapshot. If the AI provider is unavailable, the catalog's own structured
|
||||||
// fields are used as a deterministic, traceable fallback.
|
// fields are used as a deterministic, traceable fallback.
|
||||||
|
|
|
||||||
|
|
@ -119,21 +119,23 @@ func TestSuggestDoesNotCreateWatches(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。
|
func TestSuggestWorksWithoutServiceProfile(t *testing.T) {
|
||||||
func TestSuggestRequiresServiceProfile(t *testing.T) {
|
|
||||||
svc := New(repository.NewMemory())
|
svc := New(repository.NewMemory())
|
||||||
ai := &stubAI{reply: suggestReply}
|
ai := &stubAI{reply: suggestReply}
|
||||||
svc.AI = ai
|
svc.AI = ai
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
|
list, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
t.Fatalf("suggest without profile: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "service-profile") {
|
if len(list) == 0 {
|
||||||
t.Fatalf("error must point at the service profile, got %q", err)
|
t.Fatal("want generic suggestions when profile is missing")
|
||||||
}
|
}
|
||||||
if ai.calls != 0 {
|
if ai.calls != 1 {
|
||||||
t.Fatal("AI was called without a service profile")
|
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, "我不知道要建議什麼")
|
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
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, "")
|
svc, ai, ctx := suggestService(t, "")
|
||||||
ai.err = errors.New("provider down")
|
ai.err = errors.New("provider down")
|
||||||
|
|
||||||
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
t.Fatal("AI failure was swallowed")
|
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) {
|
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
|
||||||
svc, ai, ctx := suggestService(t, "")
|
svc, ai, ctx := suggestService(t, "")
|
||||||
ai.err = errors.New("provider down")
|
ai.err = errors.New("provider down")
|
||||||
usage := platformUsage(42)
|
usage := platformUsage(42)
|
||||||
svc.Usage = usage
|
svc.Usage = usage
|
||||||
|
|
||||||
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
t.Fatal("AI failure was swallowed")
|
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)
|
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list events: %v", err)
|
t.Fatalf("list events: %v", err)
|
||||||
}
|
}
|
||||||
if len(events) != 0 {
|
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())
|
svc := New(repository.NewMemory())
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
|
|
||||||
t.Fatalf("seed profile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
|
||||||
// for regional/freshness context when present.
|
// for regional/freshness context when present.
|
||||||
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
} else {
|
} else {
|
||||||
profile, err = s.Repo.GetServiceProfile(ctx, ownerUID)
|
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume: if a sweep already exists for this job, reuse it.
|
// Resume: if a sweep already exists for this job, reuse it.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"apps/backend/internal/module/radar/domain"
|
"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())
|
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})
|
watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -148,7 +141,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
|
||||||
if productFiltered {
|
if productFiltered {
|
||||||
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
|
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
|
||||||
} else {
|
} 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
|
return out, nil
|
||||||
|
|
@ -173,10 +166,7 @@ func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilt
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
|
func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
|
||||||
if noProfile {
|
|
||||||
return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。"
|
|
||||||
}
|
|
||||||
if watchCount == 0 {
|
if watchCount == 0 {
|
||||||
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
|
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestGetTodayEmptyNoProfile(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.EmptyReason != "no_profile" {
|
if got.EmptyReason != "no_watch" {
|
||||||
t.Fatalf("empty_reason = %q, want no_profile", got.EmptyReason)
|
t.Fatalf("empty_reason = %q, want no_watch", got.EmptyReason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
assertCanActivateForWatch 是「變成 active」的兩道閘(SP-01、RW-01)。
|
assertCanActivateForWatch 是「變成 active」的配額閘(RW-01)。
|
||||||
|
|
||||||
exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive,
|
exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive,
|
||||||
帶進來只為了在訊息與計算上表達清楚。
|
帶進來只為了在訊息與計算上表達清楚。
|
||||||
|
|
@ -78,42 +78,8 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不
|
||||||
既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。
|
既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。
|
||||||
*/
|
*/
|
||||||
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
|
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
|
||||||
if productWatch {
|
_ = productWatch
|
||||||
return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID)
|
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 {
|
func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error {
|
||||||
|
|
|
||||||
|
|
@ -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 := New(repository.NewMemory())
|
||||||
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
|
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := svc.CreateWatch(ctx, 42, watchInput())
|
w, err := svc.CreateWatch(ctx, 42, watchInput())
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
t.Fatalf("create active without profile: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "service-profile") {
|
if w.Status != domain.WatchActive {
|
||||||
t.Fatalf("error must point at the service profile, got %q", err)
|
t.Fatalf("status = %q, want active", w.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 但可以先建成 paused 把關鍵字備好。
|
|
||||||
paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}})
|
paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create paused without profile: %v", err)
|
t.Fatalf("create paused without profile: %v", err)
|
||||||
|
|
@ -259,15 +258,8 @@ func TestActiveWatchRequiresServiceProfile(t *testing.T) {
|
||||||
if paused.Status != domain.WatchPaused {
|
if paused.Status != domain.WatchPaused {
|
||||||
t.Fatalf("status = %q, want paused", paused.Status)
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,9 @@ export default function App() {
|
||||||
<Route path="outbox" element={<OutboxPage />} />
|
<Route path="outbox" element={<OutboxPage />} />
|
||||||
<Route path="outbox/:id" element={<OutboxDetailPage />} />
|
<Route path="outbox/:id" element={<OutboxDetailPage />} />
|
||||||
<Route path="scout" element={<ScoutPage />} />
|
<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/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="radar/opportunities" element={<RadarOpportunitiesPage />} />
|
||||||
<Route path="crm" element={<CrmBoardPage />} />
|
<Route path="crm" element={<CrmBoardPage />} />
|
||||||
<Route path="crm/followups" element={<CrmFollowUpsPage />} />
|
<Route path="crm/followups" element={<CrmFollowUpsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
|
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
|
||||||
|
import type { NavGroupKey, NavItem } from "../../lib/nav";
|
||||||
import { AppIcon } from "../ui/AppIcons";
|
import { AppIcon } from "../ui/AppIcons";
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { active } = useFirstRun();
|
const { active } = useFirstRun();
|
||||||
|
const [openAdvanced, setOpenAdvanced] = useState<NavGroupKey | null>(null);
|
||||||
const groups = active
|
const groups = active
|
||||||
? navGroupedItemsByKeys(firstRunNavKeys)
|
? navGroupedItemsByKeys(firstRunNavKeys)
|
||||||
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
|
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
|
||||||
|
|
||||||
return (
|
function renderItem(item: NavItem) {
|
||||||
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
|
const current = isNavActive(pathname, item);
|
||||||
<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);
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
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>
|
<span className="hb-nav__ico" aria-hidden>
|
||||||
<AppIcon name={item.key} size={18} />
|
<AppIcon name={item.key} size={18} />
|
||||||
|
|
@ -33,10 +29,33 @@ export function SidebarNav() {
|
||||||
<span>{t(item.labelKey)}</span>
|
<span>{t(item.labelKey)}</span>
|
||||||
</NavLink>
|
</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>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useI18n } from "../../i18n/I18nContext";
|
||||||
import { Badge, Button } from "../ui";
|
import { Badge, Button } from "../ui";
|
||||||
|
import { PrimaryProductPicker } from "./PrimaryProductPicker";
|
||||||
import { ProductMatchDetails } from "./ProductMatchDetails";
|
import { ProductMatchDetails } from "./ProductMatchDetails";
|
||||||
|
import { ReplyComposer } from "./ReplyComposer";
|
||||||
|
|
||||||
|
const BANDS: IntentBand[] = ["high", "mid", "low"];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
opportunity: Opportunity;
|
opportunity: Opportunity;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAccept?: (opportunity: Opportunity) => void;
|
onAccept?: (opportunity: Opportunity) => void;
|
||||||
onComplete?: (opportunity: Opportunity) => void;
|
onComplete?: (opportunity: Opportunity) => void;
|
||||||
|
/** 改主推產品(多產品匹配時);沒給就不顯示。 */
|
||||||
|
onSetPrimary?: (opportunity: Opportunity, productId: string, reason: string) => void;
|
||||||
|
/** 覆寫意向分級;沒給就不顯示。 */
|
||||||
|
onOverrideBand?: (opportunity: Opportunity, band: IntentBand) => void;
|
||||||
busy?: boolean;
|
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 { t } = useI18n();
|
||||||
|
const [overrideOpen, setOverrideOpen] = useState(false);
|
||||||
const pending = (opportunity.review_state || "pending") === "pending";
|
const pending = (opportunity.review_state || "pending") === "pending";
|
||||||
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
|
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
|
||||||
|
const matches = opportunity.product_matches ?? [];
|
||||||
return (
|
return (
|
||||||
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
|
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
|
||||||
<div className="hb-opp-drawer__head">
|
<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}
|
{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>
|
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 回覆放在證據上方:看完原文最想做的事就是回他。 */}
|
||||||
|
<section className="hb-opp-drawer__section">
|
||||||
|
<h3>{t("radar.drawer.reply")}</h3>
|
||||||
|
<ReplyComposer opportunity={opportunity} />
|
||||||
|
</section>
|
||||||
|
|
||||||
{opportunity.demand_evidence?.length ? (
|
{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>
|
<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}
|
) : null}
|
||||||
<section className="hb-opp-drawer__section">
|
<section className="hb-opp-drawer__section">
|
||||||
<h3>{t("radar.drawer.matches")}</h3>
|
<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>
|
</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}
|
{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">
|
<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>
|
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>{t("radar.card.accept")}</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.drawer.openOriginal")}</a>
|
<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>
|
</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}
|
{pending ? <p className="hb-field__hint">{t("radar.drawer.hint")}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,9 +22,11 @@ type Props = {
|
||||||
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
|
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
|
||||||
onRestore: (opportunity: Opportunity) => void;
|
onRestore: (opportunity: Opportunity) => void;
|
||||||
busy?: boolean;
|
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 { t } = useI18n();
|
||||||
const [removeOpen, setRemoveOpen] = useState(false);
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
||||||
|
|
@ -53,6 +55,55 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
||||||
setNote("");
|
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 (
|
return (
|
||||||
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
||||||
<div className="hb-opp-card__meta">
|
<div className="hb-opp-card__meta">
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ export function ProductWatchForm({ brands, products, brandId, productId, disable
|
||||||
const product = products.find((p) => p.id === productId);
|
const product = products.find((p) => p.id === productId);
|
||||||
return (
|
return (
|
||||||
<div className="hb-radar-product-context">
|
<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)}>
|
<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.pickBrand")}</option>
|
<option value="">{t("radar.watches.skipBrand")}</option>
|
||||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} required onChange={(e) => onProductChange(e.target.value)}>
|
<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.pickProduct")}</option>
|
<option value="">{t("radar.watches.skipProduct")}</option>
|
||||||
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<ProductContextReadiness brand={brand} product={product} />
|
<ProductContextReadiness brand={brand} product={product} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ const map: DemandMap = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("巡樓必填欄位", () => {
|
describe("巡樓必填欄位", () => {
|
||||||
it("在品牌與產品標籤旁顯示星號", () => {
|
it("品牌與產品是選項,不必先填", () => {
|
||||||
wrap(
|
wrap(
|
||||||
<ProductWatchForm
|
<ProductWatchForm
|
||||||
brands={[brand]}
|
brands={[brand]}
|
||||||
|
|
@ -58,8 +58,8 @@ describe("巡樓必填欄位", () => {
|
||||||
|
|
||||||
for (const name of ["品牌", "產品"]) {
|
for (const name of ["品牌", "產品"]) {
|
||||||
const field = screen.getByLabelText(name, { exact: false }) as HTMLSelectElement;
|
const field = screen.getByLabelText(name, { exact: false }) as HTMLSelectElement;
|
||||||
expect(field.required).toBe(true);
|
expect(field.required).toBe(false);
|
||||||
expect(field.closest("label")?.querySelector(".hb-field__required")?.textContent).toBe("*");
|
expect(field.closest("label")?.querySelector(".hb-field__required")).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ type Props = {
|
||||||
const SUGGEST_LIMIT = 8;
|
const SUGGEST_LIMIT = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 關鍵字建議:讀服務檔案問 AI,逐條或全部採用。
|
* 關鍵字建議:有服務檔案或產品就依它問 AI,沒有也給通用求助短詞。
|
||||||
*
|
*
|
||||||
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,6 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
|
||||||
const productMode = Boolean(input.brand_id || input.product_id);
|
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 && (!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) productFor(input.brand_id!, input.product_id!);
|
||||||
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
|
|
||||||
const terms = expandIncludeTerms(input.terms);
|
const terms = expandIncludeTerms(input.terms);
|
||||||
if (!terms.length) error("terms required");
|
if (!terms.length) error("terms required");
|
||||||
const id = `watch-${watches.size + 1}`;
|
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 parsed = typeof input === "number" ? { limit: input } : input ?? {};
|
||||||
const limit = parsed.limit ?? 8;
|
const limit = parsed.limit ?? 8;
|
||||||
const product = parsed.product_id ? products.get(parsed.product_id) : undefined;
|
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"] }> = [
|
const fields: Array<{ kind: WatchTermSuggestion["basis_kind"]; label: string; terms: string[]; usage: WatchTermSuggestion["usage"] }> = [
|
||||||
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
|
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
|
||||||
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },
|
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { I18nProvider } from "../i18n/I18nContext";
|
||||||
import { FirstRunBar } from "../components/layout/FirstRunBar";
|
import { FirstRunBar } from "../components/layout/FirstRunBar";
|
||||||
import { SidebarNav } from "../components/layout/SidebarNav";
|
import { SidebarNav } from "../components/layout/SidebarNav";
|
||||||
import { FirstRunProvider } from "./FirstRunContext";
|
import { FirstRunProvider } from "./FirstRunContext";
|
||||||
|
import { isFirstRunAllowedPath } from "./paths";
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
const harness = vi.hoisted(() => ({
|
||||||
member: null as Member | null,
|
member: null as Member | null,
|
||||||
|
|
@ -84,6 +85,12 @@ describe("FirstRunBar", () => {
|
||||||
expect(screen.queryByRole("link", { name: "創作" })).toBeNull();
|
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 () => {
|
it("sends people back to connect if they open another feature", async () => {
|
||||||
renderBar("/app/studio");
|
renderBar("/app/studio");
|
||||||
expect(await screen.findByText("crew-page")).toBeTruthy();
|
expect(await screen.findByText("crew-page")).toBeTruthy();
|
||||||
|
|
@ -100,6 +107,7 @@ describe("FirstRunBar", () => {
|
||||||
harness.member = member({ onboarding_status: "completed" });
|
harness.member = member({ onboarding_status: "completed" });
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
|
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
|
||||||
expect(screen.getByRole("link", { name: "創作" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "名單" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "今日" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,6 @@ export function isFirstRunAllowedPath(pathname: string): boolean {
|
||||||
if (pathname.startsWith("/app/crew")) return true;
|
if (pathname.startsWith("/app/crew")) return true;
|
||||||
if (pathname.startsWith("/app/settings")) return true;
|
if (pathname.startsWith("/app/settings")) return true;
|
||||||
if (pathname.startsWith("/app/profile")) return true;
|
if (pathname.startsWith("/app/profile")) return true;
|
||||||
|
if (pathname.startsWith("/app/radar")) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const en: MessageDict = {
|
||||||
"nav.today": "Today",
|
"nav.today": "Today",
|
||||||
"nav.crew": "Accounts",
|
"nav.crew": "Accounts",
|
||||||
"nav.studio": "Studio",
|
"nav.studio": "Studio",
|
||||||
"nav.radar": "Demand",
|
"nav.radar": "Patrol",
|
||||||
"nav.crm": "CRM",
|
"nav.crm": "CRM",
|
||||||
"nav.scout": "Topics",
|
"nav.scout": "Topics",
|
||||||
"nav.outbox": "Outbox",
|
"nav.outbox": "Outbox",
|
||||||
|
|
@ -29,9 +29,9 @@ export const en: MessageDict = {
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.navigate": "Navigate",
|
"nav.navigate": "Navigate",
|
||||||
"navGroup.workflow": "Workflow",
|
"navGroup.workflow": "Today",
|
||||||
"navGroup.accounts": "Accounts & brands",
|
"navGroup.publish": "Post",
|
||||||
"navGroup.growth": "Growth tools",
|
"navGroup.advanced": "Advanced",
|
||||||
|
|
||||||
"workspace.label": "Workspace",
|
"workspace.label": "Workspace",
|
||||||
"workspace.default": "Default",
|
"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.generic.tips": "Help never changes your data; open and close anytime.",
|
||||||
|
|
||||||
"help.page.today.title": "Today",
|
"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.what": "Everyone worth replying to today. We find them daily; you only decide who to answer.",
|
||||||
"help.page.today.step1": "Check the opportunities summary; open Radar if there are leads.",
|
"help.page.today.step1": "Read the cards top to bottom — one line tells you what they are asking.",
|
||||||
"help.page.today.step2": "Clear pending patrol replies.",
|
"help.page.today.step2": "Press Reply to open the original post and save them to your list.",
|
||||||
"help.page.today.step3": "Track failed or active Outbox items from here.",
|
"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": "When there are no opportunities, the summary guides you to Opportunity policy or watches.",
|
"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.title": "Accounts (Crew)",
|
||||||
"help.page.crew.what": "Connected Threads accounts, health, and usability. Publishing and outreach start from accounts here.",
|
"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.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.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.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.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.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.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": "Choose brand and product, then fill the pain map.",
|
"help.page.radar_watches.step1": "Type short customer search words. Brand and service profile can wait.",
|
||||||
"help.page.radar_watches.step2": "Add terms buyers type and excludes.",
|
"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 hit Run now.",
|
"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.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
|
||||||
|
|
||||||
"help.page.crm_board.title": "Contact management",
|
"help.page.crm_board.title": "Contact management",
|
||||||
|
|
@ -2181,7 +2181,7 @@ export const en: MessageDict = {
|
||||||
"radar.profile.subtitle":
|
"radar.profile.subtitle":
|
||||||
"The radar uses this to judge whether an opportunity is worth answering, and replies quote these prices and tone.",
|
"The radar uses this to judge whether an opportunity is worth answering, and replies quote these prices and tone.",
|
||||||
"radar.profile.firstTimeHint":
|
"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.updatedAt": "Last updated: {at}",
|
||||||
"radar.profile.saved": "Service profile saved",
|
"radar.profile.saved": "Service profile saved",
|
||||||
"radar.profile.services": "Services and pricing",
|
"radar.profile.services": "Services and pricing",
|
||||||
|
|
@ -2221,6 +2221,22 @@ export const en: MessageDict = {
|
||||||
"radar.watches.needProfileHint":
|
"radar.watches.needProfileHint":
|
||||||
"Scoring needs your service profile; without it every judgment is a guess.",
|
"Scoring needs your service profile; without it every judgment is a guess.",
|
||||||
"radar.watches.goProfile": "Go to service profile",
|
"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.quota": "Active {used} / {max}",
|
||||||
"radar.watches.quotaFull": "Plan limit reached — pause or archive one to add another",
|
"radar.watches.quotaFull": "Plan limit reached — pause or archive one to add another",
|
||||||
"radar.watches.add": "+ New watch",
|
"radar.watches.add": "+ New watch",
|
||||||
|
|
@ -2262,7 +2278,7 @@ export const en: MessageDict = {
|
||||||
"radar.watches.neverSwept": "Never swept",
|
"radar.watches.neverSwept": "Never swept",
|
||||||
"radar.watches.empty": "No demand watches yet",
|
"radar.watches.empty": "No demand watches yet",
|
||||||
"radar.watches.emptyHint":
|
"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.emptyFiltered": "No watches in this status",
|
||||||
"radar.watches.scheduleTitle": "Daily patrol: 06:00 Taipei (22:00 UTC)",
|
"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.",
|
"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.sweepQueued": "Demand sweep queued",
|
||||||
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
|
"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.open": "Open today's demand",
|
||||||
"today.radar.empty": "No demand yet. Add keyword watches for a daily auto list (different from Patrol’s manual scan).",
|
"today.simple.reply": "Reply",
|
||||||
"today.radar.goProfile": "Fill service profile",
|
"today.simple.skip": "Skip",
|
||||||
"today.radar.goWatches": "Add keyword watches",
|
"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.title": "Connect a Threads account first",
|
||||||
"firstRun.subtitle": "After you connect, the rest of the app unlocks. Tap the button to open Accounts.",
|
"firstRun.subtitle": "After you connect, the rest of the app unlocks. Tap the button to open Accounts.",
|
||||||
"firstRun.skip": "Skip — I'll look around",
|
"firstRun.skip": "Skip — I'll look around",
|
||||||
|
|
@ -2302,7 +2319,7 @@ export const en: MessageDict = {
|
||||||
|
|
||||||
"radar.suggest.title": "Term suggestions",
|
"radar.suggest.title": "Term suggestions",
|
||||||
"radar.suggest.hint":
|
"radar.suggest.hint":
|
||||||
"Terms drawn from your service profile. Adopt them one by one or all at once; you still need to save to create the watch.",
|
"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.ask": "Get suggestions",
|
||||||
"radar.suggest.again": "Suggest more",
|
"radar.suggest.again": "Suggest more",
|
||||||
"radar.suggest.asking": "Thinking…",
|
"radar.suggest.asking": "Thinking…",
|
||||||
|
|
@ -2311,40 +2328,14 @@ export const en: MessageDict = {
|
||||||
"radar.suggest.adoptAll": "Adopt all",
|
"radar.suggest.adoptAll": "Adopt all",
|
||||||
"radar.suggest.include": "Term",
|
"radar.suggest.include": "Term",
|
||||||
"radar.suggest.exclude": "Exclude",
|
"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 Patrol’s one-off scan).",
|
|
||||||
"radar.today.link.watches": "Demand watches",
|
"radar.today.link.watches": "Demand watches",
|
||||||
"radar.today.link.crm": "CRM board",
|
"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.high": "High",
|
||||||
"radar.today.band.mid": "Mid",
|
"radar.today.band.mid": "Mid",
|
||||||
"radar.today.band.low": "Low",
|
"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.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.action.override": "Override band",
|
||||||
"radar.today.reply.hint": "Pick a variant. DM is copy-only — never auto-sent.",
|
"radar.today.reply.hint": "Pick a variant. DM is copy-only — never auto-sent.",
|
||||||
"radar.today.reply.copy": "Copy draft",
|
"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.no_sales": "No-sales",
|
||||||
"radar.today.reply.variant.professional": "Professional",
|
"radar.today.reply.variant.professional": "Professional",
|
||||||
"radar.today.reply.variant.humorous": "Light",
|
"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 Patrol’s 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 hasn’t 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.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.overridden": "Band updated",
|
||||||
"radar.today.msg.copied": "Copied to clipboard",
|
"radar.today.msg.copied": "Copied to clipboard",
|
||||||
"radar.today.msg.copyFail": "Could not copy — select the text manually",
|
"radar.today.msg.copyFail": "Could not copy — select the text manually",
|
||||||
"radar.today.msg.marked": "Marked as sent/copied",
|
"radar.today.msg.marked": "Marked as sent/copied",
|
||||||
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
|
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
|
||||||
"radar.today.msg.needReply": "Generate a reply draft first",
|
"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.markCopy": "Mark as copied & sent",
|
||||||
"radar.today.reply.markOutbox": "Send now (Outbox)",
|
"radar.today.reply.markOutbox": "Send now (Outbox)",
|
||||||
"radar.today.reply.needAccount": "Connect a Threads account first to send",
|
"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.neverSwept": "Not patrolled yet",
|
||||||
"radar.inbox.activeWatches": "{n} watches on",
|
"radar.inbox.activeWatches": "{n} watches on",
|
||||||
"radar.inbox.allPaused": "All watches are paused. Run now also needs at least one 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.sweepNow": "Run now",
|
||||||
"radar.inbox.sweeping": "Patrolling…",
|
"radar.inbox.sweeping": "Patrolling…",
|
||||||
"radar.inbox.sweepAgain": "Run again",
|
"radar.inbox.sweepAgain": "Run again",
|
||||||
|
|
@ -2457,7 +2432,7 @@ export const en: MessageDict = {
|
||||||
"radar.inbox.empty.noRemoved": "Nothing discarded yet",
|
"radar.inbox.empty.noRemoved": "Nothing discarded yet",
|
||||||
"radar.inbox.empty.noRemovedHint": "Switch back to New to keep going through pain points.",
|
"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.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.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.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",
|
"radar.inbox.empty.openSchedule": "Turn on daily patrol",
|
||||||
|
|
@ -2526,6 +2501,7 @@ export const en: MessageDict = {
|
||||||
"radar.drawer.priority": "Priority {n}",
|
"radar.drawer.priority": "Priority {n}",
|
||||||
"radar.drawer.evidence": "Demand evidence",
|
"radar.drawer.evidence": "Demand evidence",
|
||||||
"radar.drawer.matches": "Product match and risks",
|
"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.generic": "No product assigned. This stays as generic demand.",
|
||||||
"radar.drawer.judge": "Original judgment",
|
"radar.drawer.judge": "Original judgment",
|
||||||
"radar.drawer.openOriginal": "Open original on Threads",
|
"radar.drawer.openOriginal": "Open original on Threads",
|
||||||
|
|
@ -2575,40 +2551,7 @@ export const en: MessageDict = {
|
||||||
"radar.match.hide": "Hide evidence",
|
"radar.match.hide": "Hide evidence",
|
||||||
"radar.match.basis": "Product basis: {text}",
|
"radar.match.basis": "Product basis: {text}",
|
||||||
"radar.match.risks": "Risks: {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.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.today.msg.primarySet": "Primary product saved. Later high-score matches will not overwrite this.",
|
||||||
|
|
||||||
"radar.primary.empty": "No product matches yet",
|
"radar.primary.empty": "No product matches yet",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const zhTW: MessageDict = {
|
||||||
"nav.crew": "帳號",
|
"nav.crew": "帳號",
|
||||||
"nav.studio": "創作",
|
"nav.studio": "創作",
|
||||||
/** 每日自動需求名單(vs 海巡=手動掃一輪) */
|
/** 每日自動需求名單(vs 海巡=手動掃一輪) */
|
||||||
"nav.radar": "商機",
|
"nav.radar": "巡邏",
|
||||||
"nav.crm": "名單",
|
"nav.crm": "名單",
|
||||||
/** 手動掃場外展(vs 商機=訂閱後每天自動) */
|
/** 手動掃場外展(vs 商機=訂閱後每天自動) */
|
||||||
"nav.scout": "話題",
|
"nav.scout": "話題",
|
||||||
|
|
@ -31,9 +31,9 @@ export const zhTW: MessageDict = {
|
||||||
"nav.settings": "系統設定",
|
"nav.settings": "系統設定",
|
||||||
"nav.logout": "登出",
|
"nav.logout": "登出",
|
||||||
"nav.navigate": "導覽",
|
"nav.navigate": "導覽",
|
||||||
"navGroup.workflow": "主流程",
|
"navGroup.workflow": "今天",
|
||||||
"navGroup.accounts": "帳號品牌",
|
"navGroup.publish": "發文",
|
||||||
"navGroup.growth": "成長工具",
|
"navGroup.advanced": "進階設定",
|
||||||
|
|
||||||
"workspace.label": "工作區",
|
"workspace.label": "工作區",
|
||||||
"workspace.default": "預設",
|
"workspace.default": "預設",
|
||||||
|
|
@ -74,11 +74,11 @@ export const zhTW: MessageDict = {
|
||||||
"help.page.generic.tips": "說明不會改你的資料,可隨時開關。",
|
"help.page.generic.tips": "說明不會改你的資料,可隨時開關。",
|
||||||
|
|
||||||
"help.page.today.title": "今日",
|
"help.page.today.title": "今日",
|
||||||
"help.page.today.what": "今日儀表板:一眼看商機、海巡待回、發送與帳號脈動,決定今天先做哪件事。",
|
"help.page.today.what": "今天要回的人都在這裡。系統每天自動找,你只決定回誰。",
|
||||||
"help.page.today.step1": "看「今日商機」摘要,有名單就點進雷達處理。",
|
"help.page.today.step1": "從上往下看卡片,讀一句話就知道對方在問什麼。",
|
||||||
"help.page.today.step2": "海巡待回區處理需要回覆的貼文。",
|
"help.page.today.step2": "要接就按「回他」:會打開原文,同時把他加進名單。",
|
||||||
"help.page.today.step3": "發送失敗或進行中的 Outbox 可從這裡追蹤。",
|
"help.page.today.step3": "不知道怎麼開口就按「幫我想回覆」,AI 會寫一段可以直接複製的留言。不是你的客人按「先跳過」,不用填理由。",
|
||||||
"help.page.today.tips": "沒有商機時摘要會引導你去填商機政策或訂閱關鍵字。",
|
"help.page.today.tips": "還沒有卡片就先填客人會搜的關鍵字。想改要找什麼按「設定要找什麼」。",
|
||||||
|
|
||||||
"help.page.crew.title": "帳號(Crew)",
|
"help.page.crew.title": "帳號(Crew)",
|
||||||
"help.page.crew.what": "管理已連結的 Threads 帳號、健康度與可用性,發文/外展都從這裡的帳號出發。",
|
"help.page.crew.what": "管理已連結的 Threads 帳號、健康度與可用性,發文/外展都從這裡的帳號出發。",
|
||||||
|
|
@ -104,15 +104,15 @@ export const zhTW: MessageDict = {
|
||||||
"help.page.radar_today.title": "商機巡邏",
|
"help.page.radar_today.title": "商機巡邏",
|
||||||
"help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
|
"help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
|
||||||
"help.page.radar_today.step1": "看頁頂巡邏狀態:每日定時是否開著、上次何時巡、要不要立即再巡一輪。",
|
"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.step3": "只有真的要追這個人時才「加入名單」。名單不是看結果的必要步驟。",
|
||||||
"help.page.radar_today.tips": "立即巡邏與每日定時可同時開著。關掉其中一個不會藏掉另一個。",
|
"help.page.radar_today.tips": "想補資料時用「立即探索」或「手動匯入」;每日定時與立即巡邏可以同時開著。",
|
||||||
|
|
||||||
"help.page.radar_watches.title": "設定巡邏",
|
"help.page.radar_watches.title": "設定巡邏",
|
||||||
"help.page.radar_watches.what": "選定產品與關鍵字後,每日定時巡邏會自動跑;也可隨時按立即巡邏。結果回到側欄「商機」。",
|
"help.page.radar_watches.what": "填客人會搜的關鍵字後,每日定時巡邏會自動跑;也可隨時再巡一輪。結果回到「今日」。",
|
||||||
"help.page.radar_watches.step1": "選品牌與產品,補需求地圖裡的痛點。",
|
"help.page.radar_watches.step1": "填客人會打的短詞;品牌與服務檔案可之後再補。",
|
||||||
"help.page.radar_watches.step2": "填客人會搜的關鍵字與排除詞。",
|
"help.page.radar_watches.step2": "需要時再加排除詞,或選品牌帶入產品關鍵字。",
|
||||||
"help.page.radar_watches.step3": "打開每日定時,或按「立即巡邏」現在跑一輪。",
|
"help.page.radar_watches.step3": "打開每日定時,或回今日按「再找一輪」。",
|
||||||
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
|
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
|
||||||
|
|
||||||
"help.page.crm_board.title": "名單管理",
|
"help.page.crm_board.title": "名單管理",
|
||||||
|
|
@ -2184,7 +2184,7 @@ export const zhTW: MessageDict = {
|
||||||
"policy.subtitle": "設定服務範圍、禁語、案例、FAQ 與口吻;商機判定和生成回覆會共用這份政策。",
|
"policy.subtitle": "設定服務範圍、禁語、案例、FAQ 與口吻;商機判定和生成回覆會共用這份政策。",
|
||||||
"radar.profile.title": "服務檔案",
|
"radar.profile.title": "服務檔案",
|
||||||
"radar.profile.subtitle": "雷達用這份資料判斷商機是否值得回,回覆也照這裡的價格與口吻寫。",
|
"radar.profile.subtitle": "雷達用這份資料判斷商機是否值得回,回覆也照這裡的價格與口吻寫。",
|
||||||
"radar.profile.firstTimeHint": "先填服務檔案,才能開啟雷達訂閱。內容越具體,判定與回覆越準。",
|
"radar.profile.firstTimeHint": "服務檔案可之後再補。填了之後判定與回覆會更像你。",
|
||||||
"radar.profile.updatedAt": "上次更新:{at}",
|
"radar.profile.updatedAt": "上次更新:{at}",
|
||||||
"radar.profile.saved": "服務檔案已儲存",
|
"radar.profile.saved": "服務檔案已儲存",
|
||||||
"radar.profile.services": "服務與價格",
|
"radar.profile.services": "服務與價格",
|
||||||
|
|
@ -2221,6 +2221,22 @@ export const zhTW: MessageDict = {
|
||||||
"radar.watches.needProfile": "先填服務檔案,才能開商機訂閱",
|
"radar.watches.needProfile": "先填服務檔案,才能開商機訂閱",
|
||||||
"radar.watches.needProfileHint": "系統靠服務檔案判斷需求適不適合你;沒有它會誤判。",
|
"radar.watches.needProfileHint": "系統靠服務檔案判斷需求適不適合你;沒有它會誤判。",
|
||||||
"radar.watches.goProfile": "去填服務檔案",
|
"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.quota": "啟用中 {used} / {max}",
|
||||||
"radar.watches.quotaFull": "已達方案上限,要新增請先暫停或封存一個",
|
"radar.watches.quotaFull": "已達方案上限,要新增請先暫停或封存一個",
|
||||||
"radar.watches.add": "+ 新增訂閱",
|
"radar.watches.add": "+ 新增訂閱",
|
||||||
|
|
@ -2260,7 +2276,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.watches.lastSwept": "上次巡:{at}",
|
"radar.watches.lastSwept": "上次巡:{at}",
|
||||||
"radar.watches.neverSwept": "還沒巡過",
|
"radar.watches.neverSwept": "還沒巡過",
|
||||||
"radar.watches.empty": "還沒有商機訂閱",
|
"radar.watches.empty": "還沒有商機訂閱",
|
||||||
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
|
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。找到的人會出現在「今日」。",
|
||||||
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
|
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
|
||||||
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00(UTC 22:00)",
|
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00(UTC 22:00)",
|
||||||
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
|
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
|
||||||
|
|
@ -2269,16 +2285,17 @@ export const zhTW: MessageDict = {
|
||||||
"radar.watches.sweepQueued": "已排入商機巡檢",
|
"radar.watches.sweepQueued": "已排入商機巡檢",
|
||||||
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
|
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
|
||||||
|
|
||||||
"today.radar.title": "今日商機",
|
|
||||||
"today.radar.total": "找到",
|
|
||||||
"today.radar.high": "高",
|
|
||||||
"today.radar.mid": "中",
|
|
||||||
"today.radar.low": "低",
|
|
||||||
"today.radar.open": "查看今日商機",
|
"today.radar.open": "查看今日商機",
|
||||||
"today.radar.empty": "還沒有今日商機。訂閱關鍵字後會每天自動更新(與海巡手動掃場不同)。",
|
"today.simple.reply": "回他",
|
||||||
"today.radar.goProfile": "先填服務檔案",
|
"today.simple.skip": "先跳過",
|
||||||
"today.radar.goWatches": "去訂閱關鍵字",
|
"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.title": "先連一個 Threads 帳號",
|
||||||
"firstRun.subtitle": "連上之後就能用其他功能。點按鈕去帳號頁連接。",
|
"firstRun.subtitle": "連上之後就能用其他功能。點按鈕去帳號頁連接。",
|
||||||
"firstRun.skip": "略過,之後自己摸",
|
"firstRun.skip": "略過,之後自己摸",
|
||||||
|
|
@ -2299,7 +2316,7 @@ export const zhTW: MessageDict = {
|
||||||
"firstRun.status.completed": "已完成引導",
|
"firstRun.status.completed": "已完成引導",
|
||||||
|
|
||||||
"radar.suggest.title": "關鍵字建議",
|
"radar.suggest.title": "關鍵字建議",
|
||||||
"radar.suggest.hint": "依你的服務檔案想幾個客人真的會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
|
"radar.suggest.hint": "依你填的服務或常見求助句想幾個客人會打的字,逐條或全部採用;採用後仍要按儲存才會建立。",
|
||||||
"radar.suggest.ask": "取得建議",
|
"radar.suggest.ask": "取得建議",
|
||||||
"radar.suggest.again": "再想幾個",
|
"radar.suggest.again": "再想幾個",
|
||||||
"radar.suggest.asking": "想關鍵字中…",
|
"radar.suggest.asking": "想關鍵字中…",
|
||||||
|
|
@ -2308,41 +2325,15 @@ export const zhTW: MessageDict = {
|
||||||
"radar.suggest.adoptAll": "全部採用",
|
"radar.suggest.adoptAll": "全部採用",
|
||||||
"radar.suggest.include": "關鍵字",
|
"radar.suggest.include": "關鍵字",
|
||||||
"radar.suggest.exclude": "排除詞",
|
"radar.suggest.exclude": "排除詞",
|
||||||
"radar.suggest.none": "這次沒想出可用的字,請把服務檔案寫具體一點再試。",
|
"radar.suggest.none": "這次沒想出可用的字。可先自己填幾個客人會打的短詞,或稍後再試。",
|
||||||
|
|
||||||
// demand-radar:今日商機(自動名單)
|
// demand-radar:今日商機(自動名單)
|
||||||
"radar.today.title": "今日商機",
|
|
||||||
"radar.today.subtitle": "訂閱後每天自動整理的需求名單(不是海巡那一輪手動掃)。",
|
|
||||||
"radar.today.link.watches": "商機訂閱",
|
"radar.today.link.watches": "商機訂閱",
|
||||||
"radar.today.link.crm": "名單看板",
|
"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.high": "高",
|
||||||
"radar.today.band.mid": "中",
|
"radar.today.band.mid": "中",
|
||||||
"radar.today.band.low": "低",
|
"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.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.action.override": "覆寫分級",
|
||||||
"radar.today.reply.hint": "選一個版本產生草稿;私訊版只提供複製,不會自動送出。",
|
"radar.today.reply.hint": "選一個版本產生草稿;私訊版只提供複製,不會自動送出。",
|
||||||
"radar.today.reply.copy": "複製草稿",
|
"radar.today.reply.copy": "複製草稿",
|
||||||
|
|
@ -2351,32 +2342,16 @@ export const zhTW: MessageDict = {
|
||||||
"radar.today.reply.variant.no_sales": "不銷售",
|
"radar.today.reply.variant.no_sales": "不銷售",
|
||||||
"radar.today.reply.variant.professional": "專業",
|
"radar.today.reply.variant.professional": "專業",
|
||||||
"radar.today.reply.variant.humorous": "輕鬆",
|
"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.replyReady": "回覆草稿已產生",
|
||||||
|
"radar.today.msg.replyWorking": "正在想…",
|
||||||
|
"radar.reply.writeForMe": "幫我想回覆",
|
||||||
|
"radar.reply.sendAs": "用哪個帳號送",
|
||||||
"radar.today.msg.overridden": "分級已更新",
|
"radar.today.msg.overridden": "分級已更新",
|
||||||
"radar.today.msg.copied": "已複製到剪貼簿",
|
"radar.today.msg.copied": "已複製到剪貼簿",
|
||||||
"radar.today.msg.copyFail": "無法複製,請手動選取文字",
|
"radar.today.msg.copyFail": "無法複製,請手動選取文字",
|
||||||
"radar.today.msg.marked": "已標記為已送出/已複製",
|
"radar.today.msg.marked": "已標記為已送出/已複製",
|
||||||
"radar.today.msg.sent": "已送出,稍後可在發送佇列查看進度",
|
"radar.today.msg.sent": "已送出,稍後可在發送佇列查看進度",
|
||||||
"radar.today.msg.needReply": "請先產生回覆草稿",
|
"radar.today.msg.needReply": "請先產生回覆草稿",
|
||||||
"radar.today.sendAccount": "送出帳號",
|
|
||||||
"radar.today.reply.markCopy": "標記已複製送出",
|
"radar.today.reply.markCopy": "標記已複製送出",
|
||||||
"radar.today.reply.markOutbox": "一鍵送出(Outbox)",
|
"radar.today.reply.markOutbox": "一鍵送出(Outbox)",
|
||||||
"radar.today.reply.needAccount": "先連一個 Threads 帳號才能一鍵送出",
|
"radar.today.reply.needAccount": "先連一個 Threads 帳號才能一鍵送出",
|
||||||
|
|
@ -2395,7 +2370,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.inbox.neverSwept": "還沒巡邏過",
|
"radar.inbox.neverSwept": "還沒巡邏過",
|
||||||
"radar.inbox.activeWatches": "啟用中 {n} 組",
|
"radar.inbox.activeWatches": "啟用中 {n} 組",
|
||||||
"radar.inbox.allPaused": "訂閱都暫停了,立即巡邏也需要至少一組開著",
|
"radar.inbox.allPaused": "訂閱都暫停了,立即巡邏也需要至少一組開著",
|
||||||
"radar.inbox.noWatches": "還沒設定要巡的產品與關鍵字",
|
"radar.inbox.noWatches": "還沒寫要巡的關鍵字",
|
||||||
"radar.inbox.sweepNow": "立即巡邏",
|
"radar.inbox.sweepNow": "立即巡邏",
|
||||||
"radar.inbox.sweeping": "巡邏中…",
|
"radar.inbox.sweeping": "巡邏中…",
|
||||||
"radar.inbox.sweepAgain": "再巡一次",
|
"radar.inbox.sweepAgain": "再巡一次",
|
||||||
|
|
@ -2455,7 +2430,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.inbox.empty.noRemoved": "還沒有丟掉的結果",
|
"radar.inbox.empty.noRemoved": "還沒有丟掉的結果",
|
||||||
"radar.inbox.empty.noRemovedHint": "切回「新找到」繼續看巡邏到的痛點。",
|
"radar.inbox.empty.noRemovedHint": "切回「新找到」繼續看巡邏到的痛點。",
|
||||||
"radar.inbox.empty.noWatchesTitle": "還沒設定巡邏",
|
"radar.inbox.empty.noWatchesTitle": "還沒設定巡邏",
|
||||||
"radar.inbox.empty.noWatchesHint": "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
|
"radar.inbox.empty.noWatchesHint": "寫下客人會搜的話就能開工。品牌以後再選。",
|
||||||
"radar.inbox.empty.pausedTitle": "每日定時巡邏關著",
|
"radar.inbox.empty.pausedTitle": "每日定時巡邏關著",
|
||||||
"radar.inbox.empty.pausedHint": "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
|
"radar.inbox.empty.pausedHint": "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
|
||||||
"radar.inbox.empty.openSchedule": "打開每日定時巡邏",
|
"radar.inbox.empty.openSchedule": "打開每日定時巡邏",
|
||||||
|
|
@ -2524,6 +2499,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.drawer.priority": "優先 {n}",
|
"radar.drawer.priority": "優先 {n}",
|
||||||
"radar.drawer.evidence": "需求證據",
|
"radar.drawer.evidence": "需求證據",
|
||||||
"radar.drawer.matches": "產品匹配與風險",
|
"radar.drawer.matches": "產品匹配與風險",
|
||||||
|
"radar.drawer.reply": "怎麼回他",
|
||||||
"radar.drawer.generic": "尚未指定產品,這筆結果只保留為一般需求。",
|
"radar.drawer.generic": "尚未指定產品,這筆結果只保留為一般需求。",
|
||||||
"radar.drawer.judge": "原始判定",
|
"radar.drawer.judge": "原始判定",
|
||||||
"radar.drawer.openOriginal": "開啟 Threads 原文",
|
"radar.drawer.openOriginal": "開啟 Threads 原文",
|
||||||
|
|
@ -2573,40 +2549,7 @@ export const zhTW: MessageDict = {
|
||||||
"radar.match.hide": "收合證據",
|
"radar.match.hide": "收合證據",
|
||||||
"radar.match.basis": "產品依據:{text}",
|
"radar.match.basis": "產品依據:{text}",
|
||||||
"radar.match.risks": "風險:{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.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.today.msg.primarySet": "已設定主推產品;後續高分匹配不會覆蓋這個選擇。",
|
||||||
|
|
||||||
"radar.primary.empty": "目前沒有產品匹配",
|
"radar.primary.empty": "目前沒有產品匹配",
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export const primaryNav: NavItem[] = [
|
||||||
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
|
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
|
||||||
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
||||||
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
|
{ 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: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
|
||||||
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
||||||
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
|
{ 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"];
|
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[] = [
|
export const mobileDockMoreKeys: NavKey[] = [
|
||||||
"crew",
|
"radar",
|
||||||
|
"studio",
|
||||||
"scout",
|
"scout",
|
||||||
|
"outbox",
|
||||||
"jobs",
|
"jobs",
|
||||||
"brands",
|
"brands",
|
||||||
"policy",
|
"policy",
|
||||||
"crm",
|
|
||||||
"insights",
|
"insights",
|
||||||
"utm",
|
"utm",
|
||||||
// 暫隱藏:playbooks(市集)、benchmark(基準)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export type NavGroupKey = "workflow" | "accounts" | "growth";
|
export type NavGroupKey = "workflow" | "publish" | "advanced";
|
||||||
|
|
||||||
export type NavGroup = {
|
export type NavGroup = {
|
||||||
key: NavGroupKey;
|
key: NavGroupKey;
|
||||||
/** i18n key,例如 navGroup.workflow */
|
/** i18n key,例如 navGroup.workflow */
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
keys: NavKey[];
|
keys: NavKey[];
|
||||||
|
/** 側欄預設收合:設定類的頁面設一次就好,不該每天佔著視線。 */
|
||||||
|
advanced?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 側欄二級分類:主流程(商機→名單)→ 帳號品牌 → 成長工具;話題放創作側(workflow 內 studio 旁) */
|
/** 側欄:每天走的那條線放最上面,設定與報表收進進階 */
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] },
|
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "crm", "crew"] },
|
||||||
{ key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands", "policy"] },
|
{ key: "publish", labelKey: "navGroup.publish", keys: ["studio", "outbox"] },
|
||||||
{
|
{
|
||||||
key: "growth",
|
key: "advanced",
|
||||||
labelKey: "navGroup.growth",
|
labelKey: "navGroup.advanced",
|
||||||
// 暫隱藏:benchmark(基準)、playbooks(市集)
|
advanced: true,
|
||||||
keys: ["insights", "utm"],
|
keys: ["radar", "scout", "brands", "policy", "jobs", "insights", "utm"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -118,12 +120,17 @@ export function navGroupedItemsByKeys(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNavActive(pathname: string, item: NavItem): boolean {
|
export function isNavActive(pathname: string, item: NavItem): boolean {
|
||||||
if (item.path === "/app/today") {
|
if (item.key === "today") {
|
||||||
return pathname === "/app" || pathname === "/app/today";
|
return (
|
||||||
|
pathname === "/app" ||
|
||||||
|
pathname === "/app/today" ||
|
||||||
|
pathname === "/app/radar" ||
|
||||||
|
pathname === "/app/radar/today" ||
|
||||||
|
pathname.startsWith("/app/radar/opportunities")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 雷達有 today/watches 兩個子頁,側欄以 /app/radar 前綴對齊。
|
|
||||||
if (item.key === "radar") {
|
if (item.key === "radar") {
|
||||||
return pathname === "/app/radar" || pathname.startsWith("/app/radar/");
|
return pathname.startsWith("/app/radar/watches");
|
||||||
}
|
}
|
||||||
if (item.key === "crm") {
|
if (item.key === "crm") {
|
||||||
return pathname === "/app/crm" || pathname.startsWith("/app/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" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
|
||||||
if (n.ref_type === "contact") return "/app/crm/followups";
|
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 === "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 === "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:任務中心
|
// 任務/系統/無 ref:任務中心
|
||||||
return "/app/jobs";
|
return "/app/jobs";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,15 @@ describe("pageHelp", () => {
|
||||||
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
||||||
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
||||||
expect(resolvePageHelpId("/app/radar")).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/followups")).toBe("crm_followups");
|
||||||
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
||||||
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
||||||
expect(resolvePageHelpId("/app/policy")).toBe("policy");
|
expect(resolvePageHelpId("/app/policy")).toBe("policy");
|
||||||
expect(resolvePageHelpId("/app/usage/plans")).toBe("usage_plans");
|
expect(resolvePageHelpId("/app/usage/plans")).toBe("usage_plans");
|
||||||
expect(resolvePageHelpId("/app/today")).toBe("today");
|
expect(resolvePageHelpId("/app")).toBe("today");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back outside /app", () => {
|
it("falls back outside /app", () => {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ export type PageHelpRelated = {
|
||||||
/** 最長前綴優先;含查詢字串的路徑先去掉 search/hash。 */
|
/** 最長前綴優先;含查詢字串的路徑先去掉 search/hash。 */
|
||||||
const RULES: { prefix: string; id: PageHelpId }[] = [
|
const RULES: { prefix: string; id: PageHelpId }[] = [
|
||||||
{ prefix: "/app/radar/watches", id: "radar_watches" },
|
{ prefix: "/app/radar/watches", id: "radar_watches" },
|
||||||
{ prefix: "/app/radar/today", id: "radar_today" },
|
|
||||||
{ prefix: "/app/radar", id: "radar_today" },
|
{ prefix: "/app/radar", id: "radar_today" },
|
||||||
{ prefix: "/app/crm/followups", id: "crm_followups" },
|
{ prefix: "/app/crm/followups", id: "crm_followups" },
|
||||||
{ prefix: "/app/crm/stats", id: "crm_stats" },
|
{ 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/checkout", id: "usage_checkout" },
|
||||||
{ prefix: "/app/usage", id: "usage" },
|
{ prefix: "/app/usage", id: "usage" },
|
||||||
{ prefix: "/app/users", id: "admin_users" },
|
{ prefix: "/app/users", id: "admin_users" },
|
||||||
|
// 今日是簡化收件匣,進階巡邏頁(/app/radar/*)另有一份說明。
|
||||||
{ prefix: "/app/today", id: "today" },
|
{ prefix: "/app/today", id: "today" },
|
||||||
{ prefix: "/app", 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[]>> = {
|
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
|
||||||
today: [
|
today: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/radar/watches", labelKey: "nav.radar" },
|
||||||
{ path: "/app/scout", labelKey: "nav.scout" },
|
{ path: "/app/crm", labelKey: "nav.crm" },
|
||||||
{ path: "/app/outbox", labelKey: "nav.outbox" },
|
{ path: "/app/crew", labelKey: "nav.crew" },
|
||||||
],
|
],
|
||||||
radar_today: [
|
radar_today: [
|
||||||
{ path: "/app/radar/watches", labelKey: "radar.today.link.watches" },
|
{ path: "/app/radar/watches", labelKey: "radar.today.link.watches" },
|
||||||
{ path: "/app/crm", labelKey: "nav.crm" },
|
{ path: "/app/crm", labelKey: "nav.crm" },
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/crew", labelKey: "nav.crew" },
|
||||||
],
|
],
|
||||||
radar_watches: [
|
radar_watches: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/brands", labelKey: "nav.brands" },
|
||||||
],
|
],
|
||||||
crm_board: [
|
crm_board: [
|
||||||
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
||||||
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
],
|
],
|
||||||
crm_followups: [
|
crm_followups: [
|
||||||
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
|
{ 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" }],
|
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
|
||||||
scout: [
|
scout: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
{ path: "/app/brands", labelKey: "nav.brands" },
|
{ path: "/app/brands", labelKey: "nav.brands" },
|
||||||
],
|
],
|
||||||
brands: [
|
brands: [
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ export function CrmBoardPage() {
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("crm.board.title")} />
|
<PageHeader title={t("crm.board.title")} />
|
||||||
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
<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")}
|
{t("crm.board.link.today")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
||||||
|
|
@ -262,7 +262,7 @@ export function CrmBoardPage() {
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t("crm.board.empty")}
|
title={t("crm.board.empty")}
|
||||||
description={t("crm.board.emptyHint")}
|
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" : ""}`}>
|
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "../data/live/http";
|
import { ApiError } from "../data/live/http";
|
||||||
import { KEYS } from "../data/mock/keys";
|
import { KEYS } from "../data/mock/keys";
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
import { I18nProvider } from "../i18n/I18nContext";
|
||||||
import type { Opportunity, RadarToday } from "../domain/types";
|
import type { Opportunity } from "../domain/types";
|
||||||
import { RadarTodayPage } from "./RadarTodayPage";
|
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
||||||
|
|
||||||
const backend = vi.hoisted(() => ({
|
const backend = vi.hoisted(() => ({
|
||||||
today: null as RadarToday | null,
|
|
||||||
error: null as unknown,
|
error: null as unknown,
|
||||||
calls: [] as Array<Record<string, string | undefined>>,
|
|
||||||
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
|
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -67,64 +65,76 @@ function sampleOpportunity(): Opportunity {
|
||||||
primary_product_fit_score: 100,
|
primary_product_fit_score: 100,
|
||||||
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
|
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
|
||||||
created_at: Date.now() * 1e6,
|
created_at: Date.now() * 1e6,
|
||||||
|
review_state: "pending",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("../data/DataContext", () => ({
|
vi.mock("../data/DataContext", () => ({
|
||||||
useRepos: () => ({
|
useRepos: (() => {
|
||||||
|
const repos = {
|
||||||
accounts: { async list() { return []; } },
|
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: {
|
scout: {
|
||||||
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
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: {
|
radar: {
|
||||||
async getToday(filter?: { brand_id?: string; product_id?: string; fit_band?: string }) {
|
async listOpportunities() {
|
||||||
backend.calls.push(filter ?? {});
|
|
||||||
if (backend.error) throw backend.error;
|
if (backend.error) throw backend.error;
|
||||||
if (!backend.today) throw new Error("missing live fixture");
|
return { list: [sampleOpportunity()], total: 1 };
|
||||||
return backend.today;
|
|
||||||
},
|
},
|
||||||
|
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) {
|
async setPrimaryProduct(id: string, productId: string, reason: string) {
|
||||||
backend.primaryCalls.push({ id, productId, reason });
|
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() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/app/radar/today?brand_id=b1&product_id=p2&fit_band=strong"]}>
|
<MemoryRouter initialEntries={["/app/radar/opportunities"]}>
|
||||||
<I18nProvider><RadarTodayPage /></I18nProvider>
|
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
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.error = null;
|
||||||
backend.calls = [];
|
|
||||||
backend.primaryCalls = [];
|
backend.primaryCalls = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("live product radar flow", () => {
|
describe("多產品商機的證據與主推覆寫", () => {
|
||||||
it("keeps URL filters, renders multi-product evidence, and sends primary override", async () => {
|
it("抽屜列出每個產品的匹配證據,並能改主推", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByText("敏感肌使用者求推薦:泛紅不適,想了解日常修護。"))
|
fireEvent.click(await screen.findByRole("button", { name: "為什麼推薦" }));
|
||||||
.toBeTruthy();
|
|
||||||
expect(backend.calls[0]).toEqual({ brand_id: "b1", product_id: "p2", fit_band: "strong" });
|
expect(await screen.findByText("對應泛紅不適")).toBeTruthy();
|
||||||
expect(screen.getAllByText("舒緩精華").length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/修護乳霜/).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 2 個產品匹配" }));
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
|
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
|
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
|
||||||
|
|
||||||
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
|
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps API failures visible instead of showing zero results", async () => {
|
it("API 失敗時說出原因,不顯示成功的零結果", async () => {
|
||||||
backend.today = null;
|
|
||||||
backend.error = new ApiError("服務暫時不可用", 501010, 503);
|
backend.error = new ApiError("服務暫時不可用", 501010, 503);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
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 { PageHeader } from "../components/layout/PageHeader";
|
||||||
|
import { ExplorePanel } from "../components/radar/ExplorePanel";
|
||||||
|
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
|
||||||
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
||||||
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
||||||
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
||||||
|
import { QuickWatchStart } from "../components/radar/QuickWatchStart";
|
||||||
import { Button, EmptyState, Select } from "../components/ui";
|
import { Button, EmptyState, Select } from "../components/ui";
|
||||||
import { useRepos } from "../data/DataContext";
|
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 { useI18n } from "../i18n/I18nContext";
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
import { useFormatApiError } from "../lib/apiErrors";
|
||||||
import { sanitizeReviewCopy } from "../lib/reviewCopy";
|
import { sanitizeReviewCopy } from "../lib/reviewCopy";
|
||||||
|
|
@ -24,6 +27,8 @@ function normalizeSort(value: string | null): string {
|
||||||
export function RadarOpportunitiesPage() {
|
export function RadarOpportunitiesPage() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const simple = pathname.startsWith("/app/today");
|
||||||
const formatError = useFormatApiError();
|
const formatError = useFormatApiError();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [list, setList] = useState<Opportunity[]>([]);
|
const [list, setList] = useState<Opportunity[]>([]);
|
||||||
|
|
@ -50,6 +55,12 @@ export function RadarOpportunitiesPage() {
|
||||||
const [watchTotal, setWatchTotal] = useState(0);
|
const [watchTotal, setWatchTotal] = useState(0);
|
||||||
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
|
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 [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;
|
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -101,23 +112,18 @@ export function RadarOpportunitiesPage() {
|
||||||
if (canWiden && result.total === 0) {
|
if (canWiden && result.total === 0) {
|
||||||
const week = await query("7d");
|
const week = await query("7d");
|
||||||
if (week.total > 0) {
|
if (week.total > 0) {
|
||||||
|
// 不寫進網址:重新整理後會再自己放寬一次,網址留著反而讓下次進頁被當成使用者篩選。
|
||||||
setTimeScope("7d");
|
setTimeScope("7d");
|
||||||
|
setAutoWidened(true);
|
||||||
setPage(1);
|
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;
|
result = week;
|
||||||
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
|
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
|
||||||
} else {
|
} else {
|
||||||
const all = await query("all");
|
const all = await query("all");
|
||||||
if (all.total > 0) {
|
if (all.total > 0) {
|
||||||
setTimeScope("all");
|
setTimeScope("all");
|
||||||
|
setAutoWidened(true);
|
||||||
setPage(1);
|
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;
|
result = all;
|
||||||
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
|
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -136,7 +142,7 @@ export function RadarOpportunitiesPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 load(); }, [load]);
|
||||||
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
||||||
|
|
@ -170,7 +176,7 @@ export function RadarOpportunitiesPage() {
|
||||||
await load();
|
await load();
|
||||||
setError("");
|
setError("");
|
||||||
setNotice({
|
setNotice({
|
||||||
text: t("radar.inbox.msg.accepted"),
|
text: t(simple ? "today.simple.repliedSaved" : "radar.inbox.msg.accepted"),
|
||||||
contactId: result.contact_id,
|
contactId: result.contact_id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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 }> {
|
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
|
||||||
const deadline = Date.now() + 120_000;
|
const deadline = Date.now() + 120_000;
|
||||||
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
||||||
|
|
@ -274,6 +317,7 @@ export function RadarOpportunitiesPage() {
|
||||||
setProductId("");
|
setProductId("");
|
||||||
setSort("recommended");
|
setSort("recommended");
|
||||||
setTimeScope("today");
|
setTimeScope("today");
|
||||||
|
setAutoWidened(false);
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams();
|
||||||
|
|
@ -287,11 +331,14 @@ export function RadarOpportunitiesPage() {
|
||||||
);
|
);
|
||||||
const scheduledOn = activeCount > 0;
|
const scheduledOn = activeCount > 0;
|
||||||
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
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));
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
||||||
if (filtered) {
|
// 今日頁沒有篩選 UI,講「清除篩選」等於指向一個不存在的按鈕。
|
||||||
|
if (filtered && !simple) {
|
||||||
return {
|
return {
|
||||||
title: t(
|
title: t(
|
||||||
reviewState === "pending"
|
reviewState === "pending"
|
||||||
|
|
@ -312,9 +359,15 @@ export function RadarOpportunitiesPage() {
|
||||||
}
|
}
|
||||||
if (watchTotal === 0) {
|
if (watchTotal === 0) {
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.noWatchesTitle"),
|
title: t("radar.start.title"),
|
||||||
description: t("radar.inbox.empty.noWatchesHint"),
|
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) {
|
if (!scheduledOn) {
|
||||||
|
|
@ -325,6 +378,10 @@ export function RadarOpportunitiesPage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!lastSweptAt) {
|
if (!lastSweptAt) {
|
||||||
|
// 首輪已經在跑:再給一顆「立即巡邏」只會排第二輪,也讓使用者以為剛才沒成功。
|
||||||
|
if (firstSweepPending) {
|
||||||
|
return { title: t("radar.start.msg.searching"), description: t("radar.start.msg.searchingHint") };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.neverTitle"),
|
title: t("radar.inbox.empty.neverTitle"),
|
||||||
description: t("radar.inbox.empty.neverHint"),
|
description: t("radar.inbox.empty.neverHint"),
|
||||||
|
|
@ -361,11 +418,14 @@ export function RadarOpportunitiesPage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.noFitTitle"),
|
title: simple ? t("today.simple.emptyDone") : t("radar.inbox.empty.noFitTitle"),
|
||||||
description: lastSweep
|
description: lastSweep
|
||||||
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
|
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
|
||||||
: t("radar.inbox.empty.noFitHint"),
|
: 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 (
|
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")}>
|
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
|
||||||
<div className="hb-radar-patrol__status">
|
<div className="hb-radar-patrol__status">
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -394,15 +466,36 @@ export function RadarOpportunitiesPage() {
|
||||||
<small>{t("radar.patrol.searchFallback")}</small>
|
<small>{t("radar.patrol.searchFallback")}</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="hb-radar-intro">
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("radar.inbox.introTitle")}</strong>
|
<strong>{t("radar.inbox.introTitle")}</strong>
|
||||||
<p>{t("radar.inbox.introBody")}</p>
|
<p>{t("radar.inbox.introBody")}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simple ? null : (
|
||||||
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
|
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
|
||||||
<div className="hb-radar-filter-panel__head">
|
<div className="hb-radar-filter-panel__head">
|
||||||
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
|
<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">
|
<div className="hb-radar-inbox-essential-filters">
|
||||||
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
|
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
|
||||||
const value = e.target.value as OpportunityTimeScope;
|
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>
|
<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>
|
</Select>
|
||||||
|
|
@ -465,6 +558,7 @@ export function RadarOpportunitiesPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
{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}
|
{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
|
{list.map((o) => <OpportunityInboxCard
|
||||||
key={o.id}
|
key={o.id}
|
||||||
opportunity={o}
|
opportunity={o}
|
||||||
|
simple={simple}
|
||||||
busy={busyId === o.id}
|
busy={busyId === o.id}
|
||||||
onOpen={setSelected}
|
onOpen={setSelected}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
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 }, t("radar.inbox.msg.removed"))}
|
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"))}
|
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
|
||||||
/>)}
|
/>)}
|
||||||
{total > PAGE_SIZE ? (
|
{total > PAGE_SIZE ? (
|
||||||
|
|
@ -500,6 +595,8 @@ export function RadarOpportunitiesPage() {
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
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}
|
/> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,674 +0,0 @@
|
||||||
/**
|
|
||||||
* 今日商機頁(demand-radar T551/T552)。
|
|
||||||
* 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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" }]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -160,15 +160,19 @@ describe("RadarWatchesPage", () => {
|
||||||
expect(screen.getByText(t("radar.watches.requiredHint"))).toBeTruthy();
|
expect(screen.getByText(t("radar.watches.requiredHint"))).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("沒服務檔案時停用新增,並指路去填", async () => {
|
it("沒服務檔案也能新增,品牌只是選項", async () => {
|
||||||
backend.profileExists = false;
|
backend.profileExists = false;
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText(t("radar.watches.needProfile"))).toBeTruthy();
|
expect(await screen.findByText(t("radar.watches.profileOptional"))).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled();
|
expect(screen.getByRole("button", { name: t("radar.watches.add") })).not.toBeDisabled();
|
||||||
expect(
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
|
||||||
screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"),
|
fireEvent.change(screen.getByLabelText(t("radar.watches.terms"), { exact: false }), {
|
||||||
).toBe("/app/policy");
|
target: { value: "台北 美甲推薦" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: t("common.save") }));
|
||||||
|
await screen.findByText(t("radar.watches.created"));
|
||||||
|
expect(screen.getByText("台北 美甲推薦")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {
|
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {
|
||||||
|
|
@ -224,6 +228,7 @@ describe("RadarWatchesPage", () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText(t("radar.watches.empty"));
|
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.add") }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
|
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
|
||||||
|
|
||||||
await screen.findByText("客人找設計師時最常這樣問");
|
await screen.findByText("客人找設計師時最常這樣問");
|
||||||
|
|
@ -238,6 +243,8 @@ describe("RadarWatchesPage", () => {
|
||||||
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
|
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
|
||||||
.value,
|
.value,
|
||||||
).toBe("徵才");
|
).toBe("徵才");
|
||||||
|
// 排除詞在進階區裡,採用後不能把使用者的輸入藏回去
|
||||||
|
expect(screen.getByRole("button", { name: t("radar.watches.hideAdvanced") })).toBeTruthy();
|
||||||
// 已採用的不能再按,否則使用者會以為沒生效而重複點
|
// 已採用的不能再按,否則使用者會以為沒生效而重複點
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2),
|
expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2),
|
||||||
|
|
@ -246,17 +253,18 @@ describe("RadarWatchesPage", () => {
|
||||||
|
|
||||||
it("建議失敗時說出原因,不裝作沒有建議", async () => {
|
it("建議失敗時說出原因,不裝作沒有建議", async () => {
|
||||||
backend.suggestError = new ApiError(
|
backend.suggestError = new ApiError(
|
||||||
"service profile required before suggesting watch terms",
|
"AI 沒有回傳可用的關鍵字建議,請稍後再試",
|
||||||
400100,
|
400100,
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText(t("radar.watches.empty"));
|
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.add") }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
|
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
|
||||||
|
|
||||||
const banner = await screen.findByRole("alert");
|
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();
|
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ function statusTone(status: RadarWatchStatus): "success" | "warning" | "neutral"
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 雷達訂閱:常駐關鍵字,每日自動巡。沒有服務檔案不能啟用(後端也會擋)。 */
|
/** 雷達訂閱:常駐關鍵字,每日自動巡。品牌/服務檔案是選項。 */
|
||||||
export function RadarWatchesPage() {
|
export function RadarWatchesPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
|
|
@ -63,6 +63,13 @@ export function RadarWatchesPage() {
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
/** 這次編輯是「舊訂閱補綁品牌/產品」:選了品牌之後選單不能跟著消失,否則永遠選不到產品。 */
|
||||||
|
const [rebinding, setRebinding] = useState(false);
|
||||||
|
/**
|
||||||
|
* 預設只問關鍵字與地區,和今日頁的開工表單一致。
|
||||||
|
* 排除詞、品牌產品、需求地圖與關鍵字建議都是進階,收起來才不會第一次就被十個欄位擋住。
|
||||||
|
*/
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
@ -154,6 +161,8 @@ export function RadarWatchesPage() {
|
||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
setDemandMap(null);
|
setDemandMap(null);
|
||||||
setPendingDemandMapPatch(null);
|
setPendingDemandMapPatch(null);
|
||||||
|
setRebinding(false);
|
||||||
|
setAdvancedOpen(false);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -170,6 +179,9 @@ export function RadarWatchesPage() {
|
||||||
productId: w.product_id || "",
|
productId: w.product_id || "",
|
||||||
});
|
});
|
||||||
setPendingDemandMapPatch(null);
|
setPendingDemandMapPatch(null);
|
||||||
|
setRebinding(!w.brand_id);
|
||||||
|
// 編輯既有訂閱的人通常就是要改排除詞或補綁產品,這時候藏起來反而多一次點擊。
|
||||||
|
setAdvancedOpen(true);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setError("");
|
setError("");
|
||||||
|
|
@ -221,7 +233,7 @@ export function RadarWatchesPage() {
|
||||||
setMessage(typeof okMessage === "function" ? okMessage() : okMessage);
|
setMessage(typeof okMessage === "function" ? okMessage() : okMessage);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 配額、缺服務檔案都由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
|
// 配額等硬限制由後端回可讀原因(含上限與升級提示),前端不自行推測文案。
|
||||||
setError(formatError(e));
|
setError(formatError(e));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -236,21 +248,14 @@ export function RadarWatchesPage() {
|
||||||
setError(t("radar.watches.threadsRequired"));
|
setError(t("radar.watches.threadsRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
|
if ((draft.brandId && !draft.productId) || (!draft.brandId && draft.productId)) {
|
||||||
setError(t("radar.watches.needBrandProduct"));
|
setError(t("radar.watches.needBrandProductShort"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!draft.id && productContextAvailable) {
|
if (draft.productId && pendingDemandMapPatch?.state === "ready") {
|
||||||
if (pendingDemandMapPatch?.state === "ready") {
|
|
||||||
const savedMap = await persistDemandMap(pendingDemandMapPatch, false);
|
const savedMap = await persistDemandMap(pendingDemandMapPatch, false);
|
||||||
if (!savedMap) return;
|
if (!savedMap) return;
|
||||||
}
|
}
|
||||||
const effectiveDemandMapState = pendingDemandMapPatch?.state ?? demandMap?.state;
|
|
||||||
if (effectiveDemandMapState !== "ready") {
|
|
||||||
setError(t("radar.watches.needDemandMap"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (draft.id) {
|
if (draft.id) {
|
||||||
const ok = await run(
|
const ok = await run(
|
||||||
"save",
|
"save",
|
||||||
|
|
@ -260,10 +265,17 @@ export function RadarWatchesPage() {
|
||||||
exclude_terms: excludeTerms,
|
exclude_terms: excludeTerms,
|
||||||
regions: draft.regions,
|
regions: draft.regions,
|
||||||
});
|
});
|
||||||
|
// 補綁的品牌/產品要跟著這次儲存一起生效,不能只在「綁定」按鈕生效。
|
||||||
|
if (rebinding && draft.brandId && draft.productId) {
|
||||||
|
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
t("radar.watches.updated"),
|
t("radar.watches.updated"),
|
||||||
);
|
);
|
||||||
if (ok) setFormOpen(false);
|
if (ok) {
|
||||||
|
setRebinding(false);
|
||||||
|
setFormOpen(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let firstSweepTriggered = false;
|
let firstSweepTriggered = false;
|
||||||
|
|
@ -275,8 +287,8 @@ export function RadarWatchesPage() {
|
||||||
exclude_terms: excludeTerms,
|
exclude_terms: excludeTerms,
|
||||||
regions: draft.regions,
|
regions: draft.regions,
|
||||||
enabled: draft.enabled,
|
enabled: draft.enabled,
|
||||||
brand_id: productContextAvailable ? draft.brandId : undefined,
|
brand_id: draft.brandId || undefined,
|
||||||
product_id: productContextAvailable ? draft.productId : undefined,
|
product_id: draft.productId || undefined,
|
||||||
});
|
});
|
||||||
firstSweepTriggered = Boolean(created.first_sweep_triggered);
|
firstSweepTriggered = Boolean(created.first_sweep_triggered);
|
||||||
},
|
},
|
||||||
|
|
@ -313,6 +325,7 @@ export function RadarWatchesPage() {
|
||||||
await run("assign", async () => {
|
await run("assign", async () => {
|
||||||
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
|
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
|
||||||
}, t("radar.watches.assigned"));
|
}, t("radar.watches.assigned"));
|
||||||
|
setRebinding(false);
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,13 +334,10 @@ export function RadarWatchesPage() {
|
||||||
<PageHeader title={t("radar.watches.title")} />
|
<PageHeader title={t("radar.watches.title")} />
|
||||||
|
|
||||||
{!profileExists ? (
|
{!profileExists ? (
|
||||||
<div className="hb-radar-empty" role="status">
|
<p className="hb-radar-section__hint" role="note">
|
||||||
<strong>{t("radar.watches.needProfile")}</strong>
|
{t("radar.watches.profileOptional")}{" "}
|
||||||
<span>{t("radar.watches.needProfileHint")}</span>
|
<Link to="/app/policy">{t("radar.watches.goProfile")}</Link>
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/policy">
|
</p>
|
||||||
{t("radar.watches.goProfile")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -341,7 +351,7 @@ export function RadarWatchesPage() {
|
||||||
{justTriggeredFirstSweep ? (
|
{justTriggeredFirstSweep ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<Link to="/app/radar">{t("today.radar.open")}</Link>
|
<Link to="/app/today">{t("today.radar.open")}</Link>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -359,12 +369,12 @@ export function RadarWatchesPage() {
|
||||||
<strong>{t("radar.watches.scheduleTitle")}</strong>
|
<strong>{t("radar.watches.scheduleTitle")}</strong>
|
||||||
<p>{t("radar.watches.scheduleHint")}</p>
|
<p>{t("radar.watches.scheduleHint")}</p>
|
||||||
</div>
|
</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")}
|
{t("radar.watches.openToday")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="hb-radar-actions">
|
<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")}
|
{t("radar.watches.add")}
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -392,32 +402,6 @@ export function RadarWatchesPage() {
|
||||||
<p className="hb-radar-section__hint">
|
<p className="hb-radar-section__hint">
|
||||||
<span className="hb-field__required" aria-hidden="true">*</span> {t("radar.watches.requiredHint")}
|
<span className="hb-field__required" aria-hidden="true">*</span> {t("radar.watches.requiredHint")}
|
||||||
</p>
|
</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
|
<Textarea
|
||||||
name="radar-watch-terms"
|
name="radar-watch-terms"
|
||||||
label={t("radar.watches.terms")}
|
label={t("radar.watches.terms")}
|
||||||
|
|
@ -433,15 +417,6 @@ export function RadarWatchesPage() {
|
||||||
{t("radar.watches.threadsWarn")}
|
{t("radar.watches.threadsWarn")}
|
||||||
</p>
|
</p>
|
||||||
) : 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")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="hb-radar-section__hint">{t("radar.watches.regionsHint")}</p>
|
<p className="hb-radar-section__hint">{t("radar.watches.regionsHint")}</p>
|
||||||
<div className="hb-radar-area-grid">
|
<div className="hb-radar-area-grid">
|
||||||
|
|
@ -479,6 +454,50 @@ export function RadarWatchesPage() {
|
||||||
</label>
|
</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
|
<WatchSuggestPanel
|
||||||
onAdopt={adopt}
|
onAdopt={adopt}
|
||||||
onAdoptAll={adoptAll}
|
onAdoptAll={adoptAll}
|
||||||
|
|
@ -487,9 +506,11 @@ export function RadarWatchesPage() {
|
||||||
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
|
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
|
||||||
demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
|
demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="hb-radar-actions">
|
<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}>
|
<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")}
|
{busy === "assign" ? t("radar.watches.assigning") : t("radar.watches.assign")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,9 @@
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { Repos } from "../data/repos";
|
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
|
||||||
import { TodayPage } from "./TodayPage";
|
import { TodayPage } from "./TodayPage";
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
describe("TodayPage", () => {
|
||||||
repos: null as Repos | null,
|
it("is the demand inbox, not the old dashboard", () => {
|
||||||
legacyListPostsCalls: 0,
|
expect(TodayPage).toBe(RadarOpportunitiesPage);
|
||||||
}));
|
|
||||||
|
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,757 +1,2 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
/** 今日=商機收件匣。舊儀表板已拆進進階頁,這裡直接開工。 */
|
||||||
import { Link } from "react-router-dom";
|
export { RadarOpportunitiesPage as TodayPage } from "./RadarOpportunitiesPage";
|
||||||
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>
|
|
||||||
</>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4708,6 +4708,13 @@ svg {
|
||||||
gap: 0.5rem;
|
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 {
|
.hb-today-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -977,6 +977,38 @@ a.hb-topbar__chip:hover {
|
||||||
color: var(--hb-subtle);
|
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 {
|
.hb-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue