Compare commits

...

2 Commits

Author SHA1 Message Date
王性驊 e4569d1c2a fix all bug 2026-08-27 05:35:07 +00:00
王性驊 2f1336b932 fix all bug 2026-08-19 07:37:44 +00:00
79 changed files with 2752 additions and 2700 deletions

View File

@ -164,6 +164,9 @@ func indexModels() map[string][]mongo.IndexModel {
// demand-radar. Names and specs match migration 000014 exactly so both paths are // demand-radar. Names and specs match migration 000014 exactly so both paths are
// idempotent; the two unique keys (radar_opportunities.owner_opportunity_external and // idempotent; the two unique keys (radar_opportunities.owner_opportunity_external and
// crm_contacts.owner_contact_identity) are intentionally absent and owned by that migration. // crm_contacts.owner_contact_identity) are intentionally absent and owned by that migration.
"radar_schedules": {
{Keys: bson.D{{Key: "updated_at", Value: 1}}, Options: options.Index().SetName("schedule_updated")},
},
"radar_watches": { "radar_watches": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_watches_status")}, {Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_watches_status")},
ownerIndex("created_at", "owner_watches_created"), ownerIndex("created_at", "owner_watches_created"),

View File

@ -323,7 +323,7 @@ func runMaintenance(
} else if purged > 0 { } else if purged > 0 {
logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged) logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged)
} }
// 雷達每日排程UTC 22:00 之後為每個 active watch 建一筆 radar_sweep同日去重 // 雷達排程:為每個 active watch 補齊今天(台北)已到期的時段 Job
if n, err := radarSvc.ScheduleDailySweeps(ctx, time.Now().UTC()); err != nil { if n, err := radarSvc.ScheduleDailySweeps(ctx, time.Now().UTC()); err != nil {
logx.Errorf("worker %s radar daily schedule: %v", workerID, err) logx.Errorf("worker %s radar daily schedule: %v", workerID, err)
} else if n > 0 { } else if n > 0 {

View File

@ -115,24 +115,34 @@ async function readPosts(page: Page, query: string, limit: number): Promise<Post
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
// 找小範圍卡片:往上最多 8 層,取文字長度 20800 的最近祖先 const flatten = (s: string) => s.replace(/\s+/g, " ").trim();
const keepBreaks = (s: string) =>
s
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.replace(/[ \t\u00a0\u3000]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// 找小範圍卡片:往上最多 8 層。長度用壓平後的字數判斷,正文保留換行。
let el: HTMLElement | null = a; let el: HTMLElement | null = a;
let best = ""; let best = "";
for (let depth = 0; depth < 8 && el; depth++) { for (let depth = 0; depth < 8 && el; depth++) {
const t = (el.innerText || "").replace(/\s+/g, " ").trim(); const raw = el.innerText || "";
const t = flatten(raw);
if (t.length >= 20 && t.length <= 1200) { if (t.length >= 20 && t.length <= 1200) {
best = t; best = keepBreaks(raw);
// 再往上若突然暴衝(整欄 feed就停在 best
const parent = el.parentElement; const parent = el.parentElement;
if (parent) { if (parent) {
const pt = (parent.innerText || "").replace(/\s+/g, " ").trim(); const pt = flatten(parent.innerText || "");
if (pt.length > t.length * 3 && pt.length > 1500) break; if (pt.length > t.length * 3 && pt.length > 1500) break;
} }
} }
el = el.parentElement; el = el.parentElement;
} }
if (best.length < 12) { if (flatten(best).length < 12) {
best = (a.innerText || "").replace(/\s+/g, " ").trim(); best = keepBreaks(a.innerText || "");
} }
if (best.length < 8) continue; if (best.length < 8) continue;
const author = href.match(/@([^/]+)\/post/)?.[1] || ""; const author = href.match(/@([^/]+)\/post/)?.[1] || "";

View File

@ -95,6 +95,17 @@ type (
ProductId string `json:"product_id,optional"` ProductId string `json:"product_id,optional"`
} }
// Hours are 023 in Asia/Taipei. Empty GET means default [6] (台北 06:00).
// int64go-zero JSON 對 []int 常解不出來,會變成 400001。
RadarSchedulePublic {
Hours []int64 `json:"hours"`
Timezone string `json:"timezone"`
}
PutRadarScheduleReq {
Hours []int64 `json:"hours,optional"`
}
UpdateWatchReq { UpdateWatchReq {
Id string `path:"id"` Id string `path:"id"`
Terms []string `json:"terms,optional"` Terms []string `json:"terms,optional"`
@ -545,6 +556,12 @@ service gateway {
@handler UpsertServiceProfile @handler UpsertServiceProfile
put /service-profile (UpsertServiceProfileReq) returns (ServiceProfilePublic) put /service-profile (UpsertServiceProfileReq) returns (ServiceProfilePublic)
@handler GetRadarSchedule
get /schedule returns (RadarSchedulePublic)
@handler PutRadarSchedule
put /schedule (PutRadarScheduleReq) returns (RadarSchedulePublic)
@handler ListWatches @handler ListWatches
get /watches (ListWatchesReq) returns (WatchListData) get /watches (ListWatchesReq) returns (WatchListData)

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func GetRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := radar.NewGetRadarScheduleLogic(r.Context(), svcCtx)
data, err := l.GetRadarSchedule()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PutRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PutRadarScheduleReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewPutRadarScheduleLogic(r.Context(), svcCtx)
data, err := l.PutRadarSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -623,11 +623,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/upload", Path: "/generate-image",
Handler: media.UploadHandler(serverCtx), Handler: media.GenerateImageHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -637,12 +638,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/generate-image", Path: "/upload",
Handler: media.GenerateImageHandler(serverCtx), Handler: media.UploadHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -1126,6 +1126,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/products/:productId/demand-map/enrich", Path: "/products/:productId/demand-map/enrich",
Handler: radar.EnrichDemandMapHandler(serverCtx), Handler: radar.EnrichDemandMapHandler(serverCtx),
}, },
{
Method: http.MethodGet,
Path: "/schedule",
Handler: radar.GetRadarScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/schedule",
Handler: radar.PutRadarScheduleHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/service-profile", Path: "/service-profile",

View File

@ -0,0 +1,40 @@
package radar
import (
"context"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRadarScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarScheduleLogic {
return &GetRadarScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRadarScheduleLogic) GetRadarSchedule() (resp *types.RadarSchedulePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
row, err := l.svcCtx.Radar.GetRadarSchedule(l.ctx, uid)
if err != nil {
return nil, err
}
return &types.RadarSchedulePublic{
Hours: int64Hours(row.Hours),
Timezone: radarDomain.ScheduleTimezone,
}, nil
}

View File

@ -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

View File

@ -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)
} }
} }

View File

@ -20,3 +20,19 @@ func ownerUID(ctx context.Context) (int64, error) {
} }
return uid, nil return uid, nil
} }
func intHours(in []int64) []int {
out := make([]int, 0, len(in))
for _, h := range in {
out = append(out, int(h))
}
return out
}
func int64Hours(in []int) []int64 {
out := make([]int64, 0, len(in))
for _, h := range in {
out = append(out, int64(h))
}
return out
}

View File

@ -0,0 +1,44 @@
package radar
import (
"context"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PutRadarScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPutRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PutRadarScheduleLogic {
return &PutRadarScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PutRadarScheduleLogic) PutRadarSchedule(req *types.PutRadarScheduleReq) (resp *types.RadarSchedulePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
hours := []int{}
if req != nil {
hours = intHours(req.Hours)
}
row, err := l.svcCtx.Radar.PutRadarSchedule(l.ctx, uid, hours)
if err != nil {
return nil, err
}
return &types.RadarSchedulePublic{
Hours: int64Hours(row.Hours),
Timezone: radarDomain.ScheduleTimezone,
}, nil
}

View File

@ -0,0 +1,22 @@
package radar
import (
"encoding/json"
"testing"
"apps/backend/internal/types"
)
func TestPutRadarScheduleReqJSON(t *testing.T) {
var req types.PutRadarScheduleReq
if err := json.Unmarshal([]byte(`{"hours":[6,18,21]}`), &req); err != nil {
t.Fatal(err)
}
if len(req.Hours) != 3 || req.Hours[0] != 6 || req.Hours[2] != 21 {
t.Fatalf("hours=%v", req.Hours)
}
got := intHours(req.Hours)
if len(got) != 3 || got[1] != 18 {
t.Fatalf("intHours=%v", got)
}
}

View File

@ -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)
} }
} }

View File

@ -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 {

View File

@ -160,13 +160,28 @@ type RadarSweepPayload struct {
Day string `json:"day"` Day string `json:"day"`
} }
// RadarSweepRef builds the job RefID for a watch's daily slot. // RadarSweepRef builds the job RefID for a watch's Taipei local date+hour slot.
func RadarSweepRef(watchID, day string) string { func RadarSweepRef(watchID, day string) string {
return strings.TrimSpace(watchID) + ":" + strings.TrimSpace(day) return strings.TrimSpace(watchID) + ":" + strings.TrimSpace(day)
} }
// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the UTC day of runAt. func taipeiLoc() *time.Location {
// If a job for the same watch+day already exists (any status), returns it without inserting. loc, err := time.LoadLocation("Asia/Taipei")
if err != nil {
return time.FixedZone("Asia/Taipei", 8*3600)
}
return loc
}
func radarSweepSlotRef(watchID string, runAt int64) (ref, day string) {
local := time.Unix(0, runAt).In(taipeiLoc())
day = local.Format("2006-01-02")
ref = fmt.Sprintf("%s:tp:%s:%02d", strings.TrimSpace(watchID), day, local.Hour())
return ref, day
}
// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the Taipei slot of runAt.
// If a job for the same watch+date+hour already exists (any status), returns it without inserting.
func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) { func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
watchID = strings.TrimSpace(watchID) watchID = strings.TrimSpace(watchID)
if ownerUID <= 0 || watchID == "" { if ownerUID <= 0 || watchID == "" {
@ -175,8 +190,7 @@ func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchI
if runAt <= 0 { if runAt <= 0 {
runAt = domain.NowNano() runAt = domain.NowNano()
} }
day := time.Unix(0, runAt).UTC().Format("2006-01-02") ref, day := radarSweepSlotRef(watchID, runAt)
ref := RadarSweepRef(watchID, day)
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day}) body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
if err != nil { if err != nil {

View File

@ -11,6 +11,9 @@ type Repository interface {
GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error) GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error)
SaveServiceProfile(ctx context.Context, p *ServiceProfile) error SaveServiceProfile(ctx context.Context, p *ServiceProfile) error
GetRadarSchedule(ctx context.Context, ownerUID int64) (*RadarSchedule, error)
SaveRadarSchedule(ctx context.Context, s *RadarSchedule) error
// DemandMap is the product-specific, user-editable search contract. // DemandMap is the product-specific, user-editable search contract.
GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error) GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error)
SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error) SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error)

View File

@ -0,0 +1,92 @@
package domain
import (
"fmt"
"sort"
"time"
)
const (
ScheduleTimezone = "Asia/Taipei"
DefaultSweepHour = 6
MaxSweepHours = 6
minSweepHour = 0
maxSweepHour = 23
)
// RadarSchedule is the owner's automatic patrol timetable (Taipei local hours).
type RadarSchedule struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`
Hours []int `bson:"hours" json:"hours"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
func TaipeiLocation() *time.Location {
loc, err := time.LoadLocation(ScheduleTimezone)
if err != nil {
return time.FixedZone(ScheduleTimezone, 8*3600)
}
return loc
}
func DefaultSweepHours() []int {
return []int{DefaultSweepHour}
}
func NormalizeSweepHours(hours []int) ([]int, error) {
if len(hours) == 0 {
return DefaultSweepHours(), nil
}
seen := map[int]bool{}
out := make([]int, 0, len(hours))
for _, h := range hours {
if h < minSweepHour || h > maxSweepHour {
return nil, fmt.Errorf("%w: hours must be 023 (got %d)", ErrValidation, h)
}
if seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
if len(out) == 0 {
return DefaultSweepHours(), nil
}
if len(out) > MaxSweepHours {
return nil, fmt.Errorf("%w: at most %d patrol hours", ErrValidation, MaxSweepHours)
}
sort.Ints(out)
return out, nil
}
// SweepSlot is one due automatic patrol (Taipei calendar day + hour).
type SweepSlot struct {
Date string
Hour int
RunAt int64
}
// DueSweepSlots returns selected hours that have already started today (Taipei)
// plus any earlier selected hours the same day, so a late worker still catches up.
func DueSweepSlots(now time.Time, hours []int) []SweepSlot {
hours, err := NormalizeSweepHours(hours)
if err != nil {
hours = DefaultSweepHours()
}
loc := TaipeiLocation()
local := now.In(loc)
day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
out := make([]SweepSlot, 0, len(hours))
for _, h := range hours {
slot := day.Add(time.Duration(h) * time.Hour)
if local.Before(slot) {
continue
}
out = append(out, SweepSlot{
Date: day.Format("2006-01-02"),
Hour: h,
RunAt: slot.UnixNano(),
})
}
return out
}

View File

@ -0,0 +1,48 @@
package domain
import (
"testing"
"time"
)
func TestNormalizeSweepHours(t *testing.T) {
got, err := NormalizeSweepHours(nil)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != 6 {
t.Fatalf("empty → default [6], got %v", got)
}
got, err = NormalizeSweepHours([]int{18, 6, 6, 12})
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0] != 6 || got[1] != 12 || got[2] != 18 {
t.Fatalf("dedupe+sort, got %v", got)
}
if _, err := NormalizeSweepHours([]int{24}); err == nil {
t.Fatal("hour 24 should fail")
}
if _, err := NormalizeSweepHours([]int{0, 3, 6, 9, 12, 15, 18}); err == nil {
t.Fatal("too many hours should fail")
}
}
func TestDueSweepSlots_TaipeiHours(t *testing.T) {
loc := TaipeiLocation()
// 05:59 Taipei 31 Jul → no 06:00 slot yet
before := time.Date(2026, 7, 31, 5, 59, 0, 0, loc)
if slots := DueSweepSlots(before, []int{6, 18}); len(slots) != 0 {
t.Fatalf("before 06:00 want 0, got %+v", slots)
}
at := time.Date(2026, 7, 31, 6, 0, 0, 0, loc)
slots := DueSweepSlots(at, []int{6, 18})
if len(slots) != 1 || slots[0].Hour != 6 {
t.Fatalf("at 06:00 want [6], got %+v", slots)
}
evening := time.Date(2026, 7, 31, 18, 5, 0, 0, loc)
slots = DueSweepSlots(evening, []int{6, 18})
if len(slots) != 2 || slots[0].Hour != 6 || slots[1].Hour != 18 {
t.Fatalf("after 18:00 want [6,18], got %+v", slots)
}
}

View File

@ -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"`

View File

@ -12,6 +12,7 @@ import (
type Memory struct { type Memory struct {
mu sync.Mutex mu sync.Mutex
profiles map[int64]*domain.ServiceProfile profiles map[int64]*domain.ServiceProfile
schedules map[int64]*domain.RadarSchedule
demandMaps map[string]*domain.DemandMap demandMaps map[string]*domain.DemandMap
watches map[string]*domain.RadarWatch watches map[string]*domain.RadarWatch
opportunities map[string]*domain.Opportunity opportunities map[string]*domain.Opportunity
@ -27,6 +28,7 @@ type Memory struct {
func NewMemory() *Memory { func NewMemory() *Memory {
return &Memory{ return &Memory{
profiles: map[int64]*domain.ServiceProfile{}, profiles: map[int64]*domain.ServiceProfile{},
schedules: map[int64]*domain.RadarSchedule{},
demandMaps: map[string]*domain.DemandMap{}, demandMaps: map[string]*domain.DemandMap{},
watches: map[string]*domain.RadarWatch{}, watches: map[string]*domain.RadarWatch{},
opportunities: map[string]*domain.Opportunity{}, opportunities: map[string]*domain.Opportunity{},
@ -60,3 +62,24 @@ func (m *Memory) SaveServiceProfile(_ context.Context, p *domain.ServiceProfile)
m.profiles[p.OwnerUID] = &cp m.profiles[p.OwnerUID] = &cp
return nil return nil
} }
func (m *Memory) GetRadarSchedule(_ context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.schedules[ownerUID]
if !ok {
return nil, domain.ErrNotFound
}
cp := *s
cp.Hours = append([]int(nil), s.Hours...)
return &cp, nil
}
func (m *Memory) SaveRadarSchedule(_ context.Context, s *domain.RadarSchedule) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *s
cp.Hours = append([]int(nil), s.Hours...)
m.schedules[s.OwnerUID] = &cp
return nil
}

View File

@ -13,6 +13,7 @@ import (
type MonStore struct { type MonStore struct {
profiles *mon.Model profiles *mon.Model
schedules *mon.Model
demandMaps *mon.Model demandMaps *mon.Model
watches *mon.Model watches *mon.Model
opportunities *mon.Model opportunities *mon.Model
@ -25,6 +26,7 @@ func NewMonStore(uri, database string) *MonStore {
uri = libmongo.MustMongoURI(uri) uri = libmongo.MustMongoURI(uri)
return &MonStore{ return &MonStore{
profiles: mon.MustNewModel(uri, database, "radar_service_profiles"), profiles: mon.MustNewModel(uri, database, "radar_service_profiles"),
schedules: mon.MustNewModel(uri, database, "radar_schedules"),
demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"), demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"),
watches: mon.MustNewModel(uri, database, "radar_watches"), watches: mon.MustNewModel(uri, database, "radar_watches"),
opportunities: mon.MustNewModel(uri, database, "radar_opportunities"), opportunities: mon.MustNewModel(uri, database, "radar_opportunities"),
@ -52,3 +54,20 @@ func (s *MonStore) SaveServiceProfile(ctx context.Context, p *domain.ServiceProf
_, err := s.profiles.ReplaceOne(ctx, bson.M{"_id": p.OwnerUID}, p, options.Replace().SetUpsert(true)) _, err := s.profiles.ReplaceOne(ctx, bson.M{"_id": p.OwnerUID}, p, options.Replace().SetUpsert(true))
return err return err
} }
func (s *MonStore) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
var row domain.RadarSchedule
err := s.schedules.FindOne(ctx, &row, bson.M{"_id": ownerUID})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *MonStore) SaveRadarSchedule(ctx context.Context, row *domain.RadarSchedule) error {
_, err := s.schedules.ReplaceOne(ctx, bson.M{"_id": row.OwnerUID}, row, options.Replace().SetUpsert(true))
return err
}

View File

@ -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)
} }
} }

View File

@ -33,7 +33,7 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost {
if out.Permalink == "" { if out.Permalink == "" {
out.Permalink = out.ExternalID out.Permalink = out.ExternalID
} }
out.Text = strings.TrimSpace(strings.Join(strings.Fields(out.Text), " ")) out.Text = NormalizePostBody(out.Text)
out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " ")) out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " "))
out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@") out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@")
out.Classification = strings.ToLower(strings.TrimSpace(out.Classification)) out.Classification = strings.ToLower(strings.TrimSpace(out.Classification))
@ -43,6 +43,49 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost {
return &out return &out
} }
// NormalizePostBody keeps paragraph breaks so the inbox can show the post as
// written. Horizontal runs of space/tab are still collapsed.
func NormalizePostBody(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.ReplaceAll(s, "\u2028", "\n")
s = strings.ReplaceAll(s, "\u2029", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
blank := 0
for _, line := range lines {
line = collapseHorizontalSpace(line)
if line == "" {
blank++
if blank == 1 {
out = append(out, "")
}
continue
}
blank = 0
out = append(out, line)
}
return strings.Trim(strings.Join(out, "\n"), "\n")
}
func collapseHorizontalSpace(s string) string {
var b strings.Builder
b.Grow(len(s))
space := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\u00a0' || r == '\u3000' {
if !space {
b.WriteByte(' ')
space = true
}
continue
}
space = false
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
func canonicalCandidateURL(raw string) string { func canonicalCandidateURL(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {

View File

@ -21,6 +21,21 @@ func TestPrefilterNormalizesDedupesAndKeepsWeakDemandForReview(t *testing.T) {
} }
} }
func TestNormalizePostBodyKeepsParagraphs(t *testing.T) {
got := NormalizePostBody(" 最近皮膚泛紅\n\n\n換季怎麼辦 \t有人知道嗎 ")
want := "最近皮膚泛紅\n\n換季怎麼辦 有人知道嗎"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
c := NormalizeCandidate(&domain.CandidatePost{
Permalink: "https://threads.net/@a/post/1",
Text: "第一段\n第二段",
})
if c.Text != "第一段\n第二段" {
t.Fatalf("candidate text collapsed: %q", c.Text)
}
}
func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) { func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) {
items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}}) items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}})
if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 { if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 {

View File

@ -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 {

View File

@ -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 {
@ -77,6 +76,40 @@ func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domai
return s.Repo.GetServiceProfile(ctx, ownerUID) return s.Repo.GetServiceProfile(ctx, ownerUID)
} }
func (s *Service) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
row, err := s.Repo.GetRadarSchedule(ctx, ownerUID)
if errors.Is(err, domain.ErrNotFound) || row == nil {
return &domain.RadarSchedule{OwnerUID: ownerUID, Hours: domain.DefaultSweepHours()}, nil
}
if err != nil {
return nil, err
}
hours, nerr := domain.NormalizeSweepHours(row.Hours)
if nerr != nil {
hours = domain.DefaultSweepHours()
}
row.Hours = hours
return row, nil
}
func (s *Service) PutRadarSchedule(ctx context.Context, ownerUID int64, hours []int) (*domain.RadarSchedule, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
normalized, err := domain.NormalizeSweepHours(hours)
if err != nil {
return nil, err
}
row := &domain.RadarSchedule{OwnerUID: ownerUID, Hours: normalized, UpdatedAt: domain.NowNano()}
if err := s.Repo.SaveRadarSchedule(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *Service) HasServiceProfile(ctx context.Context, ownerUID int64) (bool, error) { func (s *Service) HasServiceProfile(ctx context.Context, ownerUID int64) (bool, error) {
_, err := s.GetServiceProfile(ctx, ownerUID) _, err := s.GetServiceProfile(ctx, ownerUID)
if errors.Is(err, domain.ErrNotFound) { if errors.Is(err, domain.ErrNotFound) {

View File

@ -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()

View File

@ -40,17 +40,17 @@ func productSuggestPrompt(p *ProductContextSnapshot, limit int) string {
} }
/* /*
suggestPrompt 用服務檔案組建議關鍵字的提示 suggestPrompt 組建議關鍵字的提示
素材全部來自使用者自己填的服務檔案服務項目價格區間案例地區禁語 有服務檔案就帶服務項目價格案例地區禁語沒有也能給通用求助短詞
沒有服務檔案就不呼叫 AI SuggestWatchTerms 沒有依據的建議只是猜測
而使用者會把它當成系統的判斷
*/ */
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string { 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 metersource radar.suggestspec §5.5不新增第五個 meter 不自動寫入任何 watch使用者逐條採用才有意義
計費走既有 ai_copy metersource radar.suggestspec §5.5fallback 不扣點
*/ */
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.

View File

@ -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)
} }
} }

View File

@ -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.

View File

@ -31,54 +31,62 @@ func (f SweepJobSchedulerFunc) ScheduleRadarSweep(ctx context.Context, ownerUID
return f(ctx, ownerUID, watchID, runAt) return f(ctx, ownerUID, watchID, runAt)
} }
// ScheduleDailySweeps 在已過當日 UTC 22:00 時,為每個 active watch 建一筆 radar_sweep Job。 // ScheduleDailySweeps 為每個 active watch 補齊「今天(台北)已到期」的時段 Job。
// //
// 呼叫端必須已持有 worker maintenance Redis lockvalue = workerID // 預設時段是台北 06:00等同舊的 UTC 22:00。會員可在 /radar/schedule 多選時段。
// 本函式本身不做分散式鎖;未過 22:00 時回 0 且不建 Job。 // 呼叫端必須已持有 worker maintenance Redis lock。回傳建立或已存在的 slot 數。
//
// 回傳建立(或已存在而回傳)的 job 數。
func (s *Service) ScheduleDailySweeps(ctx context.Context, now time.Time) (int, error) { func (s *Service) ScheduleDailySweeps(ctx context.Context, now time.Time) (int, error) {
if s.SweepJobs == nil { if s.SweepJobs == nil {
return 0, fmt.Errorf("%w: sweep job scheduler not configured", domain.ErrNotReady) return 0, fmt.Errorf("%w: sweep job scheduler not configured", domain.ErrNotReady)
} }
if now.IsZero() { if now.IsZero() {
now = time.Now().UTC() now = time.Now()
} else {
now = now.UTC()
} }
if !PastDailySweepSlot(now) {
return 0, nil
}
runAt := DailySweepRunAt(now)
watches, err := s.Repo.ListAllActiveWatches(ctx) watches, err := s.Repo.ListAllActiveWatches(ctx)
if err != nil { if err != nil {
return 0, err return 0, err
} }
hoursByOwner := map[int64][]int{}
n := 0 n := 0
for _, w := range watches { for _, w := range watches {
if w == nil || w.Status != domain.WatchActive { if w == nil || w.Status != domain.WatchActive {
continue continue
} }
if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, runAt); err != nil { hours, ok := hoursByOwner[w.OwnerUID]
return n, fmt.Errorf("schedule watch %s owner %d: %w", w.ID, w.OwnerUID, err) if !ok {
sch, gerr := s.GetRadarSchedule(ctx, w.OwnerUID)
if gerr != nil {
return n, fmt.Errorf("schedule owner %d: %w", w.OwnerUID, gerr)
}
hours = sch.Hours
hoursByOwner[w.OwnerUID] = hours
}
slots := domain.DueSweepSlots(now, hours)
for _, slot := range slots {
if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, slot.RunAt); err != nil {
return n, fmt.Errorf("schedule watch %s owner %d hour %d: %w", w.ID, w.OwnerUID, slot.Hour, err)
} }
n++ n++
} }
}
return n, nil return n, nil
} }
// PastDailySweepSlot reports whether now is at or after today's UTC 22:00. // PastDailySweepSlot reports whether the default Taipei 06:00 slot has started.
func PastDailySweepSlot(now time.Time) bool { func PastDailySweepSlot(now time.Time) bool {
now = now.UTC() return len(domain.DueSweepSlots(now, domain.DefaultSweepHours())) > 0
slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC)
return !now.Before(slot)
} }
// DailySweepRunAt returns unix-ns for today's UTC 22:00 (the slot being scheduled). // DailySweepRunAt returns unix-ns for today's default Taipei 06:00 slot (or next if not yet).
func DailySweepRunAt(now time.Time) int64 { func DailySweepRunAt(now time.Time) int64 {
now = now.UTC() slots := domain.DueSweepSlots(now, domain.DefaultSweepHours())
slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC) if len(slots) > 0 {
return slots[0].RunAt
}
loc := domain.TaipeiLocation()
local := now.In(loc)
slot := time.Date(local.Year(), local.Month(), local.Day(), domain.DefaultSweepHour, 0, 0, 0, loc)
return slot.UnixNano() return slot.UnixNano()
} }

View File

@ -60,8 +60,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
CreatedAt: now, UpdatedAt: now, CreatedAt: now, UpdatedAt: now,
}) })
// Before 22:00 → no jobs // Before Taipei 06:00 → no jobs
morning := time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC) morning := time.Date(2026, 7, 31, 5, 0, 0, 0, domain.TaipeiLocation())
n, err := svc.ScheduleDailySweeps(ctx, morning) n, err := svc.ScheduleDailySweeps(ctx, morning)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -70,8 +70,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
t.Fatalf("before slot: want 0 jobs, got %d", n) t.Fatalf("before slot: want 0 jobs, got %d", n)
} }
// After 22:00 → two jobs (active only) // After Taipei 06:00 → two jobs (active only)
evening := time.Date(2026, 7, 31, 22, 5, 0, 0, time.UTC) evening := time.Date(2026, 7, 31, 6, 5, 0, 0, domain.TaipeiLocation())
n, err = svc.ScheduleDailySweeps(ctx, evening) n, err = svc.ScheduleDailySweeps(ctx, evening)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -123,6 +123,50 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
} }
} }
func TestScheduleDailySweeps_MultipleHours(t *testing.T) {
ctx := context.Background()
radarMem := repository.NewMemory()
jobMem := jobRepo.NewMemory()
jobs := jobUC.New(jobMem)
svc := New(radarMem)
svc.SweepJobs = SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
})
now := domain.NowNano()
if err := radarMem.SaveWatch(ctx, &domain.RadarWatch{
ID: "w-a", OwnerUID: 100, Terms: []string{"婚攝"}, Status: domain.WatchActive,
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if _, err := svc.PutRadarSchedule(ctx, 100, []int{6, 18}); err != nil {
t.Fatal(err)
}
loc := domain.TaipeiLocation()
n, err := svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 7, 0, 0, 0, loc))
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("07:00 Taipei: want 1 slot, got %d", n)
}
n, err = svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 18, 10, 0, 0, loc))
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("18:10 Taipei: want 2 slots, got %d", n)
}
list, _ := jobs.List(ctx, 100)
if len(list) != 2 {
t.Fatalf("want 2 stored jobs, got %d", len(list))
}
}
func TestScheduleRadarSweep_ConcurrentNoDuplicate(t *testing.T) { func TestScheduleRadarSweep_ConcurrentNoDuplicate(t *testing.T) {
ctx := context.Background() ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory()) jobs := jobUC.New(jobRepo.NewMemory())

View File

@ -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", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
} }

View File

@ -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)
} }
} }

View File

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

View File

@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
} }
/* /*
assertCanActivateForWatch 變成 active兩道閘SP-01RW-01 assertCanActivateForWatch 變成 active配額閘RW-01
exceptWatchID 是正在恢復的那一筆它目前不是 active所以不會被算進 CountActive exceptWatchID 是正在恢復的那一筆它目前不是 active所以不會被算進 CountActive
帶進來只為了在訊息與計算上表達清楚 帶進來只為了在訊息與計算上表達清楚
@ -78,43 +78,9 @@ 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 {
maxActive, err := s.MaxActiveWatches(ctx, ownerUID) maxActive, err := s.MaxActiveWatches(ctx, ownerUID)

View File

@ -237,21 +237,20 @@ func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) {
} }
} }
// SP-01沒有服務檔案判定沒有比對基準所以不准有 active 訂閱 // 服務檔案是選項:沒建檔也能用關鍵字開巡邏
func TestActiveWatchRequiresServiceProfile(t *testing.T) { func TestActiveWatchDoesNotRequireServiceProfile(t *testing.T) {
svc := New(repository.NewMemory()) svc := 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)
} }
} }

View File

@ -1834,6 +1834,15 @@ type PublishPlaybookReq struct {
Anonymous bool `json:"anonymous,optional"` Anonymous bool `json:"anonymous,optional"`
} }
type PutRadarScheduleReq struct {
Hours []int64 `json:"hours,optional"`
}
type RadarSchedulePublic struct {
Hours []int64 `json:"hours"`
Timezone string `json:"timezone"`
}
type RadarSweepPublic struct { type RadarSweepPublic struct {
Id string `json:"id"` Id string `json:"id"`
WatchId string `json:"watch_id"` WatchId string `json:"watch_id"`

View File

@ -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 />} />

View File

@ -47,10 +47,10 @@ export function BellMenu() {
} }
}, [repos.notifications]); }, [repos.notifications]);
// 全域資料變更、任務數改變或打開面板時才立即刷新 // 全域資料變更或任務數改變時刷新。打開面板改走 markSeen
useEffect(() => { useEffect(() => {
void loadNotifs(); void loadNotifs();
}, [revision, loadNotifs, tick, open]); }, [revision, loadNotifs, tick]);
// 關閉時低頻更新;頁面不可見時暫停,避免背景流量。 // 關閉時低頻更新;頁面不可見時暫停,避免背景流量。
useEffect(() => { useEffect(() => {
@ -90,16 +90,27 @@ export function BellMenu() {
); );
} }
function markAllRead() { function markSeen() {
const readAt = Date.now() * 1_000_000; const readAt = Date.now() * 1_000_000;
setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt }))); setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt })));
setUnread(0); setUnread(0);
void repos.notifications.markAllRead().then( void repos.notifications.markAllRead().then(
() => window.dispatchEvent(new Event("harbor:store")), () => {
window.dispatchEvent(new Event("harbor:store"));
return loadNotifs();
},
() => void loadNotifs(), () => void loadNotifs(),
); );
} }
function toggleOpen() {
setOpen((wasOpen) => {
const next = !wasOpen;
if (next) markSeen();
return next;
});
}
const preview = items.slice(0, PREVIEW); const preview = items.slice(0, PREVIEW);
const more = Math.max(0, items.length - PREVIEW); const more = Math.max(0, items.length - PREVIEW);
@ -108,7 +119,7 @@ export function BellMenu() {
<button <button
type="button" type="button"
className={`hb-bell__trigger${open ? " is-open" : ""}${unread > 0 ? " has-unread" : ""}`} className={`hb-bell__trigger${open ? " is-open" : ""}${unread > 0 ? " has-unread" : ""}`}
onClick={() => setOpen((v) => !v)} onClick={toggleOpen}
aria-expanded={open} aria-expanded={open}
aria-haspopup="menu" aria-haspopup="menu"
aria-label={ aria-label={
@ -129,21 +140,7 @@ export function BellMenu() {
<div className="hb-bell__head"> <div className="hb-bell__head">
<div className="hb-bell__head-title"> <div className="hb-bell__head-title">
<strong>{t("topbar.notifications")}</strong> <strong>{t("topbar.notifications")}</strong>
{unread > 0 ? (
<span className="hb-bell__head-count">
{t("topbar.unread", { n: unread })}
</span>
) : null}
</div> </div>
{items.length > 0 ? (
<button
type="button"
className="hb-bell__text-btn"
onClick={markAllRead}
>
{t("topbar.markAllRead")}
</button>
) : null}
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (

View File

@ -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>
); );
} }

View File

@ -1,22 +1,72 @@
import type { Opportunity } from "../../domain/types"; import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
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";
/** 殼層 overflow-x: clip 會把 fixed 鎖在整頁座標;量頂欄/底欄,抽屜才不會衝過頭。 */
function pinDrawerToChrome(el: HTMLElement) {
const header = document.querySelector<HTMLElement>(".hb-shell__header");
const dock = document.querySelector<HTMLElement>(".hb-dock");
const top = header?.getBoundingClientRect().height ?? 0;
const dockHidden = !dock || getComputedStyle(dock).display === "none";
const bottom = dockHidden ? 0 : dock.getBoundingClientRect().height;
el.style.top = `${Math.round(top)}px`;
el.style.bottom = `${Math.round(bottom)}px`;
}
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 drawerRef = useRef<HTMLElement>(null);
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);
return ( const matches = opportunity.product_matches ?? [];
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
useLayoutEffect(() => {
const el = drawerRef.current;
if (!el) return;
const apply = () => pinDrawerToChrome(el);
apply();
const header = document.querySelector(".hb-shell__header");
const dock = document.querySelector(".hb-dock");
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(apply);
if (ro && header) ro.observe(header);
if (ro && dock) ro.observe(dock);
window.addEventListener("resize", apply);
return () => {
ro?.disconnect();
window.removeEventListener("resize", apply);
};
}, []);
const drawer = (
<aside ref={drawerRef} 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">
<div> <div>
<span className="hb-radar-section__hint">{t("radar.drawer.title")}</span> <span className="hb-radar-section__hint">{t("radar.drawer.title")}</span>
@ -31,12 +81,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,9 +112,31 @@ 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>
); );
return createPortal(drawer, document.body);
} }

View File

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

View File

@ -22,9 +22,11 @@ type Props = {
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void; 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">

View File

@ -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} />

View File

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

View File

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

View File

@ -44,7 +44,7 @@ const map: DemandMap = {
}; };
describe("巡樓必填欄位", () => { 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();
} }
}); });

View File

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

View File

@ -18,6 +18,7 @@ import type {
Opportunity, Opportunity,
ProductFitReason, ProductFitReason,
ProductMatch, ProductMatch,
RadarSchedule,
RadarSweep, RadarSweep,
RadarToday, RadarToday,
RadarWatch, RadarWatch,
@ -95,6 +96,16 @@ function mapServiceProfile(raw: Raw): ServiceProfile {
}; };
} }
function mapSchedule(raw: Raw): RadarSchedule {
const hours = Array.isArray(raw.hours)
? raw.hours.map((h) => Number(h)).filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
: [6];
return {
hours: hours.length ? hours : [6],
timezone: str(raw.timezone) || "Asia/Taipei",
};
}
function mapWatch(raw: Raw): RadarWatch { function mapWatch(raw: Raw): RadarWatch {
return { return {
id: str(raw.id), id: str(raw.id),
@ -387,6 +398,17 @@ export function createLiveRadarRepo(): RadarRepo {
}); });
return mapServiceProfile(raw); return mapServiceProfile(raw);
}, },
async getRadarSchedule() {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/schedule`);
return mapSchedule(raw);
},
async saveRadarSchedule(hours: number[]) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/schedule`, {
method: "PUT",
body: { hours },
});
return mapSchedule(raw);
},
async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) { async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) {
const raw = await apiRequest<Raw>( const raw = await apiRequest<Raw>(
`${RADAR_BASE}/watches${query({ page, pageSize, status, context_mode: contextMode, brand_id: brandId, product_id: productId })}`, `${RADAR_BASE}/watches${query({ page, pageSize, status, context_mode: contextMode, brand_id: brandId, product_id: productId })}`,

View File

@ -8,6 +8,7 @@ import type {
OpportunityRemovalReason, OpportunityRemovalReason,
OpportunityReviewState, OpportunityReviewState,
RadarToday, RadarToday,
RadarSchedule,
RadarSweep, RadarSweep,
RadarWatch, RadarWatch,
ServiceProfile, ServiceProfile,
@ -123,6 +124,7 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
remote_ok: false, remote_ok: false,
...copy(effectiveSeed.profile ?? {}), ...copy(effectiveSeed.profile ?? {}),
}; };
const schedule: RadarSchedule = { hours: [6], timezone: "Asia/Taipei" };
function productFor(brandId: string, productId: string): BrandProduct { function productFor(brandId: string, productId: string): BrandProduct {
const brand = brands.get(brandId); const brand = brands.get(brandId);
@ -171,6 +173,16 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
Object.assign(profile, copy(patch), { exists: true, updated_at: nanoNow() }); Object.assign(profile, copy(patch), { exists: true, updated_at: nanoNow() });
return copy(profile); return copy(profile);
}, },
async getRadarSchedule() {
return copy(schedule);
},
async saveRadarSchedule(hours: number[]) {
const next = [...new Set(hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23))].sort((a, b) => a - b);
if (!next.length) error("hours required");
if (next.length > 6) error("at most 6 patrol hours");
schedule.hours = next;
return copy(schedule);
},
async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) { async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) {
const all = [...watches.values()].filter((w) => const all = [...watches.values()].filter((w) =>
(!status || w.status === status) && (!status || w.status === status) &&
@ -190,7 +202,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 +279,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" },

View File

@ -677,6 +677,8 @@ export type GrowthRepo = {
*/ */
export type RadarRepo = { export type RadarRepo = {
getServiceProfile(): Promise<import("../domain/types").ServiceProfile>; getServiceProfile(): Promise<import("../domain/types").ServiceProfile>;
getRadarSchedule(): Promise<import("../domain/types").RadarSchedule>;
saveRadarSchedule(hours: number[]): Promise<import("../domain/types").RadarSchedule>;
saveServiceProfile( saveServiceProfile(
patch: Omit<import("../domain/types").ServiceProfile, "exists" | "updated_at">, patch: Omit<import("../domain/types").ServiceProfile, "exists" | "updated_at">,
): Promise<import("../domain/types").ServiceProfile>; ): Promise<import("../domain/types").ServiceProfile>;

View File

@ -895,6 +895,12 @@ export type RadarWatchStatus = "active" | "paused" | "archived";
export type RadarWatchContextMode = "generic" | "product"; export type RadarWatchContextMode = "generic" | "product";
export type RadarWatchPauseReason = "user" | "product_unavailable" | "brand_unavailable"; export type RadarWatchPauseReason = "user" | "product_unavailable" | "brand_unavailable";
/** 自動巡邏時段(台北當地小時 023 */
export type RadarSchedule = {
hours: number[];
timezone: string;
};
/** 雷達訂閱:常駐關鍵字監控 */ /** 雷達訂閱:常駐關鍵字監控 */
export type RadarWatch = { export type RadarWatch = {
id: string; id: string;

View File

@ -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();
}); });
}); });

View File

@ -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;
} }

View File

@ -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": "Pick one or more Taipei patrol hours, 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,25 +2278,34 @@ 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": "Automatic patrol hours (Taipei time)",
"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": "Pick one or more slots. Every active watch runs at each selected time. Use Run now on a watch for an extra pass.",
"radar.watches.scheduleSaved": "Patrol hours updated",
"radar.watches.scheduleNeedOne": "Choose at least one slot",
"radar.watches.slot.6": "Morning 06:00",
"radar.watches.slot.9": "Late morning 09:00",
"radar.watches.slot.12": "Noon 12:00",
"radar.watches.slot.15": "Afternoon 15:00",
"radar.watches.slot.18": "Evening 18:00",
"radar.watches.slot.21": "Night 21:00",
"radar.watches.openToday": "Back to findings", "radar.watches.openToday": "Back to findings",
"radar.watches.sweepNow": "Run now", "radar.watches.sweepNow": "Run now",
"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 Patrols 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 +2327,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 +2336,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 Patrols 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 +2352,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 Patrols manual scan).",
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
"radar.today.empty.reason.not_swept_yet": "Daily patrol hasnt finished yet; you can also hit Run now on Demand.",
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
"radar.today.msg.accepted": "Added to CRM",
"radar.today.msg.dismissed": "Dismissed",
"radar.today.msg.replyReady": "Reply draft ready", "radar.today.msg.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",
@ -2392,12 +2375,12 @@ export const en: MessageDict = {
"radar.inbox.patrolAria": "Patrol status", "radar.inbox.patrolAria": "Patrol status",
"radar.inbox.scheduledOn": "Daily patrol: on", "radar.inbox.scheduledOn": "Daily patrol: on",
"radar.inbox.scheduledOff": "Daily patrol: off", "radar.inbox.scheduledOff": "Daily patrol: off",
"radar.inbox.scheduleHint": "Runs every day at 06:00 Taipei time. Turning off Run now does not stop the daily patrol.", "radar.inbox.scheduleHint": "Runs at the hours you picked under Patrol settings. Turning off Run now does not stop the scheduled patrol.",
"radar.inbox.lastSweep": "Last patrol: {time}", "radar.inbox.lastSweep": "Last patrol: {time}",
"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 +2440,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 +2509,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 +2559,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",

View File

@ -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,25 +2276,34 @@ 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:00UTC 22:00", "radar.watches.scheduleTitle": "自動巡邏時段(台北時間)",
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。", "radar.watches.scheduleHint": "可多選。到點後所有開著的訂閱會各巡一輪。要現在看結果,到訂閱列按「立即巡邏」。",
"radar.watches.scheduleSaved": "巡邏時段已更新",
"radar.watches.scheduleNeedOne": "至少選一個時段",
"radar.watches.slot.6": "早晨 06:00",
"radar.watches.slot.9": "上午 09:00",
"radar.watches.slot.12": "中午 12:00",
"radar.watches.slot.15": "下午 15:00",
"radar.watches.slot.18": "傍晚 18:00",
"radar.watches.slot.21": "晚上 21:00",
"radar.watches.openToday": "回商機結果", "radar.watches.openToday": "回商機結果",
"radar.watches.sweepNow": "立即巡邏", "radar.watches.sweepNow": "立即巡邏",
"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 +2324,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 +2333,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 +2350,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 帳號才能一鍵送出",
@ -2390,12 +2373,12 @@ export const zhTW: MessageDict = {
"radar.inbox.patrolAria": "巡邏狀態", "radar.inbox.patrolAria": "巡邏狀態",
"radar.inbox.scheduledOn": "每日定時巡邏:開著", "radar.inbox.scheduledOn": "每日定時巡邏:開著",
"radar.inbox.scheduledOff": "每日定時巡邏:關著", "radar.inbox.scheduledOff": "每日定時巡邏:關著",
"radar.inbox.scheduleHint": "每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。", "radar.inbox.scheduleHint": "依你在巡邏設定選的時段自動巡。關掉立即巡邏不會停定時巡邏。",
"radar.inbox.lastSweep": "上次巡邏:{time}", "radar.inbox.lastSweep": "上次巡邏:{time}",
"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 +2438,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 +2507,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 +2557,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": "目前沒有產品匹配",

View File

@ -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")
);
} }
// 雷達有 todaywatches 兩個子頁,側欄以 /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";
} }

View File

@ -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", () => {

View File

@ -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: [

View File

@ -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" : ""}`}>

View File

@ -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();

View File

@ -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}
</> </>
); );

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ const backend = vi.hoisted(() => ({
suggestions: [] as WatchTermSuggestion[], suggestions: [] as WatchTermSuggestion[],
suggestError: null as unknown, suggestError: null as unknown,
seq: 0, seq: 0,
hours: [6] as number[],
})); }));
function activeCount(): number { function activeCount(): number {
@ -103,6 +104,13 @@ vi.mock("../data/DataContext", () => {
if (backend.suggestError) throw backend.suggestError; if (backend.suggestError) throw backend.suggestError;
return backend.suggestions; return backend.suggestions;
}, },
async getRadarSchedule() {
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
async saveRadarSchedule(hours: number[]) {
backend.hours = [...hours].sort((a, b) => a - b);
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
}, },
}; };
return { useRepos: () => repos }; return { useRepos: () => repos };
@ -144,6 +152,7 @@ beforeEach(() => {
backend.suggestions = []; backend.suggestions = [];
backend.suggestError = null; backend.suggestError = null;
backend.seq = 0; backend.seq = 0;
backend.hours = [6];
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
}); });
@ -160,15 +169,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 +237,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 +252,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 +262,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();
}); });
@ -268,4 +285,14 @@ describe("RadarWatchesPage", () => {
expect(screen.getByText("晚上睡覺")).toBeTruthy(); expect(screen.getByText("晚上睡覺")).toBeTruthy();
expect(screen.getByText("口乾舌燥")).toBeTruthy(); expect(screen.getByText("口乾舌燥")).toBeTruthy();
}); });
it("可以多選巡邏時段", async () => {
renderPage();
const evening = await screen.findByRole("button", { name: t("radar.watches.slot.18") });
expect(screen.getByRole("button", { name: t("radar.watches.slot.6") })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(evening);
await screen.findByText(t("radar.watches.scheduleSaved"));
expect(backend.hours).toEqual([6, 18]);
expect(evening).toHaveAttribute("aria-pressed", "true");
});
}); });

View File

@ -17,6 +17,7 @@ import { DemandMapEditor, type DemandMapPatch } from "../components/radar/Demand
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary"; import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const SWEEP_SLOTS = [6, 9, 12, 15, 18, 21] as const;
type StatusFilter = "" | RadarWatchStatus; type StatusFilter = "" | RadarWatchStatus;
@ -47,7 +48,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,12 +64,20 @@ 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("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null); const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
const [justTriggeredFirstSweep, setJustTriggeredFirstSweep] = useState(false); const [justTriggeredFirstSweep, setJustTriggeredFirstSweep] = useState(false);
const [hours, setHours] = useState<number[]>([6]);
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]); const [products, setProducts] = useState<BrandProduct[]>([]);
const [demandMap, setDemandMap] = useState<DemandMap | null>(null); const [demandMap, setDemandMap] = useState<DemandMap | null>(null);
@ -129,6 +138,21 @@ export function RadarWatchesPage() {
setProfileExists(res.profile_exists); setProfileExists(res.profile_exists);
}, [repos, page, statusFilter]); }, [repos, page, statusFilter]);
useEffect(() => {
let alive = true;
void repos.radar
.getRadarSchedule()
.then((s) => {
if (alive) setHours(s.hours);
})
.catch(() => {
/* 排程讀不到仍可用預設 06:00 操作訂閱 */
});
return () => {
alive = false;
};
}, [repos.radar]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@ -154,6 +178,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 +196,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 +250,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 +265,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 +282,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 +304,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);
}, },
@ -287,6 +316,23 @@ export function RadarWatchesPage() {
if (ok) setFormOpen(false); if (ok) setFormOpen(false);
} }
async function toggleHour(hour: number) {
const next = hours.includes(hour) ? hours.filter((h) => h !== hour) : [...hours, hour];
if (!next.length) {
setError(t("radar.watches.scheduleNeedOne"));
return;
}
const ok = await run(
"schedule",
async () => {
const saved = await repos.radar.saveRadarSchedule(next);
setHours(saved.hours);
},
t("radar.watches.scheduleSaved"),
);
if (ok) setError("");
}
async function persistDemandMap(patch: DemandMapPatch, showBusy = true): Promise<DemandMap | null> { async function persistDemandMap(patch: DemandMapPatch, showBusy = true): Promise<DemandMap | null> {
if (showBusy) setBusy("demand-map"); if (showBusy) setBusy("demand-map");
setDemandMapError(""); setDemandMapError("");
@ -313,6 +359,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 +368,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 +385,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>
@ -354,17 +398,31 @@ export function RadarWatchesPage() {
{t("radar.watches.quota", { used: activeCount, max: maxActive })} {t("radar.watches.quota", { used: activeCount, max: maxActive })}
{quotaFull ? ` · ${t("radar.watches.quotaFull")}` : ""} {quotaFull ? ` · ${t("radar.watches.quotaFull")}` : ""}
</p> </p>
<div className="hb-radar-schedule" role="note"> <div className="hb-radar-schedule">
<div> <div>
<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 className="hb-radar-slot-grid" role="group" aria-label={t("radar.watches.scheduleTitle")}>
{SWEEP_SLOTS.map((hour) => (
<button
key={hour}
type="button"
className={`hb-radar-chip${hours.includes(hour) ? " is-active" : ""}`}
aria-pressed={hours.includes(hour)}
disabled={busy === "schedule"}
onClick={() => void toggleHour(hour)}
>
{t(`radar.watches.slot.${hour}`)}
</button>
))}
</div> </div>
<Link className="hb-btn hb-btn--ghost" to="/app/radar"> </div>
<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" variant="ghost" onClick={openCreate} disabled={formOpen}>
{t("radar.watches.add")} {t("radar.watches.add")}
</Button> </Button>
<Select <Select
@ -392,32 +450,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 +465,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 +502,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 +554,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>

View File

@ -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));
}); });
}); });

View File

@ -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>
</>}
</>
);
}

View File

@ -0,0 +1,218 @@
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 });
const header = document.createElement("div");
header.className = "hb-shell__header";
Object.defineProperty(header, "getBoundingClientRect", {
value: () => ({ height: 96, width: 800, top: 0, left: 0, bottom: 96, right: 800, x: 0, y: 0, toJSON() {} }),
});
document.body.append(header);
renderToday();
fireEvent.click((await screen.findAllByRole("button", { name: "幫我想回覆" }))[0]);
const drawer = await screen.findByRole("dialog");
expect(drawer).toHaveStyle({ top: "96px" });
header.remove();
});
it("想不到怎麼回時,同一頁就能拿到草稿", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
renderToday();
// 卡片上的入口打開抽屜;抽屜裡才真的花點數生成。
fireEvent.click((await screen.findAllByRole("button", { name: "幫我想回覆" }))[0]);
const drawer = await screen.findByRole("dialog");
// portal 出殼層,才不會被 overflow-x: clip + sticky 頂欄裁掉。
expect(drawer.parentElement).toBe(document.body);
fireEvent.click(within(drawer).getByRole("button", { name: "幫我想回覆" }));
await waitFor(() => expect(backend.replies).toEqual([{ id: "opp-1", variant: "public_comment" }]));
expect(await screen.findByText(/我在台北做美甲/)).toBeTruthy();
});
it("留得住進階頁入口", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
renderToday();
const link = await screen.findByRole("link", { name: "看全部結果" });
expect(link.getAttribute("href")).toBe("/app/radar/opportunities");
});
});

View File

@ -176,7 +176,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
</> </>
) : null} ) : null}
</p> </p>
<p style={{ marginTop: 0 }}>{m.text}</p> <p className="hb-post-body" style={{ marginTop: 0 }}>{m.text}</p>
{m.status === "pending" && !composeOpen[m.id] ? ( {m.status === "pending" && !composeOpen[m.id] ? (
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
<Button type="button" variant="ghost" onClick={() => openCompose(m.id)}> <Button type="button" variant="ghost" onClick={() => openCompose(m.id)}>

View File

@ -379,7 +379,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
</Badge> </Badge>
) : null} ) : null}
</div> </div>
<p style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p> <p className="hb-post-body" style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
<PostMetrics post={post} variant="compact" /> <PostMetrics post={post} variant="compact" />
<p className="text-muted hb-compact-row__meta"> <p className="text-muted hb-compact-row__meta">
{formatLocalDateTime(post.published_at)} {formatLocalDateTime(post.published_at)}

View File

@ -29,20 +29,11 @@ body,
body { body {
font-family: var(--hb-font-sans); font-family: var(--hb-font-sans);
font-size: var(--hb-text-base); font-size: var(--hb-text-base);
line-height: 1.58; line-height: 1.5;
letter-spacing: -0.011em; letter-spacing: 0;
font-synthesis: none; font-synthesis: none;
color: var(--hb-ink); color: var(--hb-ink);
/* 星圖網格 + 潮汐法光,低對比避免干擾資訊。 */ background: var(--hb-bg);
background:
radial-gradient(circle at 24px 24px, color-mix(in srgb, var(--hb-brand) 16%, transparent) 0 1px, transparent 1.4px) 0 0 / 56px 56px,
linear-gradient(color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
linear-gradient(90deg, color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
radial-gradient(ellipse 70% 50% at 0% -5%, var(--hb-aurora-1), transparent 55%),
radial-gradient(ellipse 55% 40% at 100% 0%, var(--hb-aurora-2), transparent 50%),
radial-gradient(ellipse 50% 35% at 50% 100%, var(--hb-aurora-3), transparent 55%),
var(--hb-bg);
background-attachment: fixed;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@ -55,7 +46,8 @@ h5,
h6 { h6 {
margin: 0; margin: 0;
font-weight: 700; font-weight: 700;
line-height: 1.35; line-height: 1.2;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink); color: var(--hb-ink);
} }
@ -101,11 +93,10 @@ svg {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
margin: 20vh auto; margin: 20vh auto;
border: 2px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line)); border: 2px solid var(--hb-line);
border-top-color: var(--hb-magic); border-top-color: var(--hb-brand);
border-right-color: var(--hb-brand); border-right-color: var(--hb-brand);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 18px var(--hb-magic-glow);
animation: hb-route-spin 0.7s linear infinite; animation: hb-route-spin 0.7s linear infinite;
} }
@ -155,15 +146,10 @@ svg {
color: var(--hb-ink); color: var(--hb-ink);
} }
/** 首頁:乾淨背景少一點「AI 漸層點陣」感 */ /** 首頁:暖紙畫布,白卡圖底 */
.hb-public--home { .hb-public--home {
padding: var(--hb-space-5) var(--hb-space-6) var(--hb-space-8); padding: 0;
background: background: var(--hb-bg);
linear-gradient(
180deg,
color-mix(in srgb, var(--hb-surface-solid) 88%, var(--hb-bg)) 0%,
var(--hb-bg) 12rem
);
} }
.hb-public__header { .hb-public__header {
@ -172,11 +158,22 @@ svg {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--hb-space-4); gap: var(--hb-space-4);
max-width: 56rem; max-width: 68rem;
width: 100%; width: 100%;
margin: 0 auto var(--hb-space-6); margin: 0 auto var(--hb-space-6);
} }
.hb-public--home .hb-public__header {
position: sticky;
top: 0;
z-index: 20;
max-width: none;
margin: 0 0 var(--hb-space-6);
padding: var(--hb-space-4) var(--hb-space-6);
background: var(--hb-surface-solid);
border-bottom: 1px solid var(--hb-line);
}
.hb-public__brand { .hb-public__brand {
display: flex; display: flex;
align-items: center; align-items: center;
@ -191,12 +188,9 @@ svg {
.hb-public__brand .hb-brand-mark { .hb-public__brand .hb-brand-mark {
width: 2.5rem; width: 2.5rem;
height: 2.5rem; height: 2.5rem;
border-radius: 0.75rem; border-radius: var(--hb-radius-lg);
object-fit: cover; object-fit: cover;
box-shadow: box-shadow: 0 0 0 1px var(--hb-line);
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 24px var(--hb-magic-glow),
0 8px 20px color-mix(in srgb, var(--hb-ink) 22%, transparent);
} }
.hb-public__brand-text { .hb-public__brand-text {
@ -268,8 +262,8 @@ svg {
.hb-public__nav-link { .hb-public__nav-link {
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
font-weight: 600; font-weight: 400;
color: var(--hb-brand-deep); color: var(--hb-ink);
text-decoration: none; text-decoration: none;
} }
@ -283,7 +277,7 @@ svg {
.hb-public__main { .hb-public__main {
flex: 1; flex: 1;
max-width: 56rem; max-width: 68rem;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
@ -293,7 +287,7 @@ svg {
.hb-public--home .hb-public__main { .hb-public--home .hb-public__main {
gap: clamp(2.5rem, 5vw, 4rem); gap: clamp(2.5rem, 5vw, 4rem);
padding-bottom: var(--hb-space-6); padding: 0 var(--hb-space-6) var(--hb-space-8);
} }
.hb-public__main--narrow { .hb-public__main--narrow {
@ -304,8 +298,14 @@ svg {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hb-space-5); gap: var(--hb-space-5);
max-width: 36rem; max-width: 40rem;
padding: var(--hb-space-4) 0 var(--hb-space-2); padding: var(--hb-space-8) 0 var(--hb-space-2);
}
.hb-public__hero-cta {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-3);
} }
.hb-public__hero-points { .hb-public__hero-points {
@ -347,10 +347,8 @@ svg {
.hb-public__free-band { .hb-public__free-band {
padding: var(--hb-space-6); padding: var(--hb-space-6);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
border: 1px solid color-mix(in srgb, var(--hb-brand) 35%, var(--hb-line)); border: 1px solid var(--hb-line);
background: background: var(--hb-surface-solid);
radial-gradient(ellipse 70% 80% at 0% 0%, color-mix(in srgb, var(--hb-brand-soft) 70%, transparent), transparent 55%),
var(--hb-surface);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hb-space-5); gap: var(--hb-space-5);
@ -465,17 +463,12 @@ svg {
} }
.hb-public-preview__frame--radar { .hb-public-preview__frame--radar {
box-shadow: box-shadow: none;
0 0 0 1px color-mix(in srgb, var(--hb-brand) 14%, transparent),
var(--hb-shadow-card);
} }
.hb-public-preview__frame--hero { .hb-public-preview__frame--hero {
min-height: 16rem; min-height: 16rem;
transform: rotate(-1.2deg); box-shadow: var(--hb-shadow-soft);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 18%, transparent),
0 18px 40px color-mix(in srgb, var(--hb-ink) 14%, transparent);
} }
.hb-public-preview__chrome { .hb-public-preview__chrome {
@ -519,9 +512,7 @@ svg {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.65rem;
background: background: var(--hb-bg);
linear-gradient(180deg, color-mix(in srgb, var(--hb-brand-soft) 35%, transparent), transparent 60%),
var(--hb-bg);
} }
.hb-mock-row { .hb-mock-row {
@ -750,9 +741,9 @@ svg {
.hb-public__title { .hb-public__title {
margin: 0; margin: 0;
font-size: clamp(1.65rem, 3.2vw, 2.1rem); font-size: var(--hb-text-display);
line-height: 1.25; line-height: 1.04;
letter-spacing: -0.025em; letter-spacing: var(--hb-track-display);
font-weight: 700; font-weight: 700;
color: var(--hb-ink); color: var(--hb-ink);
} }
@ -865,7 +856,7 @@ svg {
border-radius: var(--hb-radius); border-radius: var(--hb-radius);
border: 1px solid var(--hb-line-strong); border: 1px solid var(--hb-line-strong);
background: var(--hb-surface-solid, var(--hb-surface)); background: var(--hb-surface-solid, var(--hb-surface));
box-shadow: var(--hb-shadow-card); box-shadow: var(--hb-shadow-float);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
@ -917,20 +908,41 @@ svg {
.hb-public__feature-item { .hb-public__feature-item {
margin: 0; margin: 0;
padding: var(--hb-space-4); padding: var(--hb-space-6);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid, var(--hb-surface)); background: var(--hb-surface-solid, var(--hb-surface));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hb-space-2); gap: var(--hb-space-2);
box-shadow: none;
overflow: hidden;
position: relative;
}
.hb-public__feature-item::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0.35rem;
background: var(--hb-brand);
}
.hb-public__feature-item[data-outcome="reply"]::before {
background: var(--hb-magic);
}
.hb-public__feature-item[data-outcome="close"]::before {
background: var(--hb-accent-warm);
} }
.hb-public__feature-name { .hb-public__feature-name {
margin: 0; margin: 0;
font-size: var(--hb-text-base); font-size: var(--hb-text-lg);
font-weight: 650; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: var(--hb-track-heading);
color: var(--hb-ink); color: var(--hb-ink);
} }
@ -966,9 +978,9 @@ svg {
.hb-public__section-title { .hb-public__section-title {
margin: 0; margin: 0;
font-size: var(--hb-text-lg); font-size: var(--hb-text-xl);
font-weight: 650; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: var(--hb-track-heading);
color: var(--hb-ink); color: var(--hb-ink);
} }
@ -992,11 +1004,12 @@ svg {
} }
.hb-public__footer { .hb-public__footer {
max-width: 64rem; max-width: none;
width: 100%; width: 100%;
margin: var(--hb-space-8) auto 0; margin: var(--hb-space-8) 0 0;
padding-top: var(--hb-space-5); padding: var(--hb-space-8) var(--hb-space-6);
border-top: 1px solid var(--hb-line); border-top: 1px solid var(--hb-line);
background: var(--hb-bg);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@ -1005,6 +1018,13 @@ svg {
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
} }
.hb-public:not(.hb-public--home) .hb-public__footer {
max-width: 68rem;
margin-left: auto;
margin-right: auto;
background: transparent;
}
.hb-public__footer-links { .hb-public__footer-links {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1037,12 +1057,7 @@ svg {
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--hb-space-6); padding: var(--hb-space-6);
background: background: var(--hb-bg);
radial-gradient(circle at 28px 28px, color-mix(in srgb, var(--hb-brand) 20%, transparent) 0 1px, transparent 1.5px) 0 0 / 64px 64px,
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-brand-dim) 18%, transparent), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-brand) 13%, transparent), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-brand-dim) 10%, transparent), transparent 50%),
var(--hb-bg);
} }
.hb-login-brand { .hb-login-brand {
@ -1055,12 +1070,9 @@ svg {
.hb-login-brand .hb-brand-mark { .hb-login-brand .hb-brand-mark {
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
border-radius: 0.75rem; border-radius: var(--hb-radius-lg);
object-fit: cover; object-fit: cover;
box-shadow: box-shadow: 0 0 0 1px var(--hb-line);
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 24px var(--hb-magic-glow),
0 8px 20px color-mix(in srgb, var(--hb-ink) 22%, transparent);
} }
.hb-login-brand__text { .hb-login-brand__text {
@ -1082,7 +1094,7 @@ svg {
.hb-login .hb-card { .hb-login .hb-card {
width: min(100%, 26rem); width: min(100%, 26rem);
box-shadow: var(--hb-shadow-card); box-shadow: var(--hb-shadow-soft);
} }
.hb-login a { .hb-login a {
@ -1289,10 +1301,9 @@ svg {
0 3px 10px color-mix(in srgb, var(--hb-brand) 12%, transparent); 0 3px 10px color-mix(in srgb, var(--hb-brand) 12%, transparent);
} }
/* 底色由 avatar_color 帶入後端配色皆為中間調近黑字在那組上有 6:1 以上 /* 底色由 avatar_color 帶入(中間調),字色固定近黑,不跟主色 CTA 的白字走。 */
不再需要原本用來救白字的暗色陰影 */
.hb-avatar--initials { .hb-avatar--initials {
color: var(--hb-brand-on); color: var(--hb-on-tint);
font-size: var(--hb-text-2xs); font-size: var(--hb-text-2xs);
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@ -2030,7 +2041,7 @@ svg {
background: var(--hb-surface); background: var(--hb-surface);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg, 0.75rem); border-radius: var(--hb-radius-lg, 0.75rem);
box-shadow: 0 18px 48px color-mix(in srgb, #000 28%, transparent); box-shadow: var(--hb-shadow-float);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.85rem; gap: 0.85rem;
@ -2342,7 +2353,7 @@ svg {
background: var(--hb-surface); background: var(--hb-surface);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg, 0.75rem); border-radius: var(--hb-radius-lg, 0.75rem);
box-shadow: 0 18px 48px color-mix(in srgb, #000 28%, transparent); box-shadow: var(--hb-shadow-float);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -2828,10 +2839,20 @@ svg {
line-height: 1.55; line-height: 1.55;
} }
.hb-post-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.55;
}
.hb-scout-now__text { .hb-scout-now__text {
margin: 0; margin: 0;
font-size: var(--hb-text-base); font-size: var(--hb-text-base);
line-height: 1.55; line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
} }
.hb-scout-queue-item { .hb-scout-queue-item {
@ -2869,6 +2890,9 @@ svg {
.hb-scout-queue-item__text { .hb-scout-queue-item__text {
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
line-height: 1.45; line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
} }
.hb-chip-row { .hb-chip-row {
@ -2909,9 +2933,9 @@ svg {
.hb-brands__rail { .hb-brands__rail {
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
padding: var(--hb-space-5); padding: var(--hb-space-5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -3049,20 +3073,15 @@ svg {
width: 1.85rem; width: 1.85rem;
height: 1.85rem; height: 1.85rem;
border-radius: 50%; border-radius: 50%;
background: linear-gradient( background: var(--hb-brand);
145deg,
var(--hb-accent-warm),
var(--hb-brand)
);
color: var(--hb-brand-on); color: var(--hb-brand-on);
font-size: var(--hb-text-xs); font-size: var(--hb-text-xs);
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
} }
/* 選中時只加深暖色那一端;漸層收到 --hb-brand-deep 會讓近黑字在深端看不見。 */
.hb-brands__chip.is-active .hb-brands__chip-mark { .hb-brands__chip.is-active .hb-brands__chip-mark {
background: linear-gradient(145deg, var(--hb-accent-warm), var(--hb-brand)); background: var(--hb-brand);
} }
.hb-brands__chip-name { .hb-brands__chip-name {
@ -3073,9 +3092,9 @@ svg {
.hb-brands__panel { .hb-brands__panel {
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
padding: var(--hb-space-5); padding: var(--hb-space-5);
min-height: 14rem; min-height: 14rem;
display: flex; display: flex;
@ -3111,13 +3130,13 @@ svg {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(145deg, var(--hb-accent-warm), var(--hb-brand)); background: var(--hb-brand);
color: var(--hb-brand-on); color: var(--hb-brand-on);
font-size: var(--hb-text-lg); font-size: var(--hb-text-lg);
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
box-shadow: var(--hb-shadow-soft); box-shadow: none;
border: 2px solid color-mix(in srgb, var(--hb-surface) 70%, transparent); border: 1px solid var(--hb-line);
} }
.hb-brands__panel-titles { .hb-brands__panel-titles {
@ -3762,20 +3781,21 @@ svg {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1.1rem 1.15rem; padding: var(--hb-space-6);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius);
background: var(--hb-surface); background: var(--hb-surface-solid);
min-width: 0; min-width: 0;
} }
.hb-pricing__card.is-popular { .hb-pricing__card.is-popular {
border-color: color-mix(in srgb, var(--hb-brand) 45%, var(--hb-line)); background: var(--hb-bg);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hb-brand) 18%, transparent); border-color: var(--hb-line);
box-shadow: none;
} }
.hb-pricing__card.is-current { .hb-pricing__card.is-current {
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 45%, var(--hb-surface)); background: var(--hb-bg);
} }
.hb-pricing__title-row { .hb-pricing__title-row {
@ -4708,6 +4728,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));
@ -5098,8 +5125,8 @@ a.hb-today-metric:hover {
flex-direction: column; flex-direction: column;
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: var(--hb-shadow-float);
z-index: 50; z-index: 50;
overflow: hidden; overflow: hidden;
} }
@ -5316,8 +5343,8 @@ a.hb-today-metric:hover {
width: min(15rem, 88vw); width: min(15rem, 88vw);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: var(--hb-shadow-float);
z-index: 50; z-index: 50;
overflow: hidden; overflow: hidden;
} }

View File

@ -20,9 +20,7 @@
z-index: 40; z-index: 40;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: color-mix(in srgb, var(--hb-surface-solid) 82%, transparent); background: var(--hb-surface-solid);
backdrop-filter: saturate(130%) blur(16px);
-webkit-backdrop-filter: saturate(130%) blur(16px);
} }
.hb-topbar { .hb-topbar {
@ -269,21 +267,15 @@
height: 2rem; height: 2rem;
flex-shrink: 0; flex-shrink: 0;
display: block; display: block;
border-radius: 0.65rem; border-radius: var(--hb-radius-lg);
object-fit: cover; object-fit: cover;
box-shadow: box-shadow: 0 0 0 1px var(--hb-line);
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 16px var(--hb-magic-glow),
0 5px 14px color-mix(in srgb, var(--hb-ink) 24%, transparent);
transition: transform 0.16s ease, box-shadow 0.16s ease; transition: transform 0.16s ease, box-shadow 0.16s ease;
} }
.hb-topbar__brand:hover .hb-topbar__mark { .hb-topbar__brand:hover .hb-topbar__mark {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: box-shadow: 0 0 0 1px var(--hb-line-strong);
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
0 0 22px var(--hb-magic-glow),
0 7px 18px color-mix(in srgb, var(--hb-ink) 30%, transparent);
} }
.hb-topbar__title { .hb-topbar__title {
@ -335,9 +327,9 @@
gap: 0.4rem; gap: 0.4rem;
height: var(--hb-top-ctrl); height: var(--hb-top-ctrl);
padding: 0 0.65rem 0 0.55rem; padding: 0 0.65rem 0 0.55rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent); border: 1px solid var(--hb-line);
border-radius: 0.55rem; border-radius: var(--hb-radius);
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted)); background: var(--hb-surface-solid);
color: var(--hb-ink-secondary); color: var(--hb-ink-secondary);
font-size: var(--hb-text-xs); font-size: var(--hb-text-xs);
cursor: pointer; cursor: pointer;
@ -388,16 +380,16 @@
/* 用量 + 鈴鐺 + 帳號:同一工具列(略方圓角,非膠囊) */ /* 用量 + 鈴鐺 + 帳號:同一工具列(略方圓角,非膠囊) */
.hb-topbar__cluster { .hb-topbar__cluster {
--hb-top-ctrl: 2rem; --hb-top-ctrl: 2rem;
--hb-top-cluster-radius: 0.55rem; --hb-top-cluster-radius: var(--hb-radius);
--hb-top-item-radius: 0.4rem; --hb-top-item-radius: var(--hb-radius-xs);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.15rem; gap: 0.15rem;
padding: 0.2rem; padding: 0.2rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent); border: 1px solid var(--hb-line);
border-radius: var(--hb-top-cluster-radius); border-radius: var(--hb-top-cluster-radius);
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted)); background: var(--hb-surface-solid);
box-shadow: 0 1px 0 color-mix(in srgb, #fff 40%, transparent) inset; box-shadow: none;
} }
.hb-topbar__cluster .hb-usage-widget__trigger, .hb-topbar__cluster .hb-usage-widget__trigger,
@ -453,7 +445,7 @@
background: var(--hb-surface-solid, var(--hb-surface)); background: var(--hb-surface-solid, var(--hb-surface));
color: var(--hb-ink); color: var(--hb-ink);
border-left: 1px solid var(--hb-line); border-left: 1px solid var(--hb-line);
box-shadow: var(--hb-shadow-card, 0 8px 32px color-mix(in srgb, var(--hb-ink) 12%, transparent)); box-shadow: var(--hb-shadow-float);
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) 0; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) 0;
} }
@ -639,67 +631,14 @@ a.hb-topbar__chip:hover {
text-shadow: none; text-shadow: none;
} }
/*
* 方案字光只貼在方案名上多層 text-shadow 朦朧
* 比極簡版明顯比整顆光暈小
*/
.hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge { .hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge {
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
font-weight: 770; font-weight: 770;
animation: hb-plan-text-breathe-starter 2.8s ease-in-out infinite;
} }
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge { .hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
color: color-mix(in srgb, var(--hb-gold) 70%, var(--hb-gold-bright) 30%); color: var(--hb-gold);
font-weight: 790; font-weight: 790;
animation: hb-plan-text-breathe-pro 2.8s ease-in-out infinite;
}
@keyframes hb-plan-text-breathe-starter {
0%,
100% {
text-shadow:
0 0 3px color-mix(in srgb, var(--hb-brand) 35%, transparent),
0 0 8px color-mix(in srgb, var(--hb-brand) 22%, transparent),
0 0 14px color-mix(in srgb, var(--hb-brand) 12%, transparent);
}
50% {
text-shadow:
0 0 5px color-mix(in srgb, var(--hb-brand) 70%, transparent),
0 0 12px color-mix(in srgb, var(--hb-brand) 45%, transparent),
0 0 20px color-mix(in srgb, var(--hb-brand) 22%, transparent);
}
}
@keyframes hb-plan-text-breathe-pro {
0%,
100% {
text-shadow:
0 0 3px color-mix(in srgb, var(--hb-gold-bright) 40%, transparent),
0 0 8px color-mix(in srgb, var(--hb-gold) 28%, transparent),
0 0 14px color-mix(in srgb, var(--hb-gold) 14%, transparent);
}
50% {
text-shadow:
0 0 5px color-mix(in srgb, var(--hb-gold-bright) 75%, transparent),
0 0 12px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 20px color-mix(in srgb, var(--hb-gold) 28%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge {
animation: none;
text-shadow:
0 0 4px color-mix(in srgb, var(--hb-brand) 50%, transparent),
0 0 10px color-mix(in srgb, var(--hb-brand) 28%, transparent);
}
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
animation: none;
text-shadow:
0 0 4px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 10px color-mix(in srgb, var(--hb-gold) 30%, transparent);
}
} }
.hb-usage-widget__sep { .hb-usage-widget__sep {
@ -746,10 +685,8 @@ a.hb-topbar__chip:hover {
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: box-shadow: var(--hb-shadow-float);
0 0 0 1px color-mix(in srgb, var(--hb-line) 35%, transparent),
0 14px 36px color-mix(in srgb, #000 10%, transparent);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@ -942,10 +879,8 @@ a.hb-topbar__chip:hover {
.hb-sidebar { .hb-sidebar {
display: none; display: none;
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent); border-right: 1px solid var(--hb-line);
background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent); background: var(--hb-surface-solid);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
padding: var(--hb-space-4) var(--hb-space-3); padding: var(--hb-space-4) var(--hb-space-3);
} }
@ -977,6 +912,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;
@ -1016,18 +983,24 @@ a.hb-topbar__chip:hover {
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
} }
/*
* 選中 = 實心主色塊 + 近黑字10.3:1hover 只是淡底
* 兩者一眼分得出來也不必再靠一條被圓角切掉的 inset 3px 直線來區別
* 原本 hover 與選中的底色字色完全相同只差那條線
*/
.hb-nav__item--active, .hb-nav__item--active,
.hb-nav__item--active:hover, .hb-nav__item--active:hover,
.hb-nav__item--active:focus-visible { .hb-nav__item--active:focus-visible {
background: var(--hb-brand); background: var(--hb-surface-muted);
color: var(--hb-brand-on); color: var(--hb-ink);
font-weight: 700; font-weight: 700;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 10%, transparent); box-shadow: none;
}
.hb-nav__item--active::before {
content: "";
position: absolute;
left: 0.2rem;
top: 0.5rem;
bottom: 0.5rem;
width: 0.18rem;
border-radius: var(--hb-radius-pill);
background: var(--hb-brand);
} }
.hb-nav__item--active .hb-nav__ico { .hb-nav__item--active .hb-nav__ico {
@ -1128,11 +1101,10 @@ a.hb-topbar__chip:hover {
.hb-page-title::after { .hb-page-title::after {
content: ""; content: "";
width: 3.25rem; width: 2rem;
height: 2px; height: 1px;
border-radius: 99px; border-radius: 99px;
background: linear-gradient(90deg, var(--hb-brand), var(--hb-magic), transparent); background: var(--hb-line);
box-shadow: 0 0 12px var(--hb-magic-glow);
} }
.hb-page-title p { .hb-page-title p {
@ -1155,10 +1127,8 @@ a.hb-topbar__chip:hover {
gap: 0.15rem; gap: 0.15rem;
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px)); min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px)); padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent); border-top: 1px solid var(--hb-line);
background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent); background: var(--hb-surface-solid);
backdrop-filter: saturate(160%) blur(22px);
-webkit-backdrop-filter: saturate(160%) blur(22px);
} }
.hb-dock__item { .hb-dock__item {
@ -1194,11 +1164,10 @@ a.hb-topbar__chip:hover {
background: var(--hb-brand-soft); background: var(--hb-brand-soft);
} }
/* 與側欄選單同一套:選中是實心主色塊。 */
.hb-dock__item--active, .hb-dock__item--active,
.hb-dock__item--active:focus-visible { .hb-dock__item--active:focus-visible {
color: var(--hb-brand-on); color: var(--hb-brand-deep);
background: var(--hb-brand); background: var(--hb-brand-soft);
font-weight: 700; font-weight: 700;
} }
@ -1278,8 +1247,8 @@ a.hb-topbar__chip:hover {
z-index: 1; z-index: 1;
margin: 0; margin: 0;
padding: 0.45rem 0.85rem calc(0.85rem + env(safe-area-inset-bottom, 0px)); padding: 0.45rem 0.85rem calc(0.85rem + env(safe-area-inset-bottom, 0px));
border-radius: 1.15rem 1.15rem 0 0; border-radius: var(--hb-radius-xl) var(--hb-radius-xl) 0 0;
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float); box-shadow: var(--hb-shadow-float);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-bottom: 0; border-bottom: 0;
@ -1364,8 +1333,8 @@ a.hb-topbar__chip:hover {
.hb-dock-more__row.is-active, .hb-dock-more__row.is-active,
.hb-dock-more__row.is-active:hover { .hb-dock-more__row.is-active:hover {
border-color: transparent; border-color: transparent;
background: var(--hb-brand); background: var(--hb-brand-soft);
color: var(--hb-brand-on); color: var(--hb-brand-deep);
font-weight: 700; font-weight: 700;
} }

View File

@ -29,7 +29,8 @@
.hb-radar-section__title { .hb-radar-section__title {
font-size: var(--hb-text-lg); font-size: var(--hb-text-lg);
font-weight: 600; font-weight: 700;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink); color: var(--hb-ink);
} }
@ -90,9 +91,9 @@
flex-direction: column; flex-direction: column;
gap: var(--hb-space-2); gap: var(--hb-space-2);
padding: var(--hb-space-6); padding: var(--hb-space-6);
border: 1px dashed var(--hb-line-strong); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface-muted); background: var(--hb-surface-solid);
color: var(--hb-muted); color: var(--hb-muted);
font-size: var(--hb-text-sm); font-size: var(--hb-text-sm);
} }
@ -160,11 +161,23 @@
.hb-radar-actions { .hb-radar-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-end;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
gap: var(--hb-space-3); gap: var(--hb-space-3);
} }
.hb-radar-actions > .hb-btn {
flex: 0 0 auto;
align-self: flex-end;
min-height: 2.25rem;
padding: 0.35rem 0.85rem;
font-size: var(--hb-text-sm);
white-space: nowrap;
word-break: normal;
overflow-wrap: normal;
}
.hb-radar-watch-list { .hb-radar-watch-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -180,8 +193,8 @@
padding: var(--hb-space-4); padding: var(--hb-space-4);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
} }
.hb-radar-watch--paused { .hb-radar-watch--paused {
@ -299,12 +312,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hb-space-3); gap: var(--hb-space-3);
padding: var(--hb-space-4); padding: var(--hb-space-6);
border: 1px solid var(--hb-line); border: 1px solid var(--hb-line);
border-left: 3px solid var(--hb-line-strong); border-left: 3px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
} }
.hb-opp-card--high { .hb-opp-card--high {
@ -316,7 +329,7 @@
} }
.hb-opp-card--low { .hb-opp-card--low {
border-left-color: var(--hb-subtle); border-left-color: var(--hb-line-strong);
} }
.hb-opp-card__head { .hb-opp-card__head {
@ -345,11 +358,11 @@
justify-content: space-between; justify-content: space-between;
gap: var(--hb-space-5); gap: var(--hb-space-5);
margin-bottom: var(--hb-space-4); margin-bottom: var(--hb-space-4);
padding: var(--hb-space-5); padding: var(--hb-space-6);
border: 1px solid color-mix(in srgb, var(--hb-brand) 30%, var(--hb-line)); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl); border-radius: var(--hb-radius-lg);
background: linear-gradient(135deg, var(--hb-brand-soft), var(--hb-surface)); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
} }
.hb-radar-intro > div { .hb-radar-intro > div {
@ -476,10 +489,17 @@
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--hb-space-3); gap: var(--hb-space-3);
padding: var(--hb-space-3) var(--hb-space-4); padding: var(--hb-space-4);
border: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line)); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-brand-soft); background: var(--hb-surface-solid);
}
.hb-radar-slot-grid {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-2);
margin-top: var(--hb-space-3);
} }
.hb-radar-schedule > div { .hb-radar-schedule > div {
@ -531,10 +551,10 @@
gap: var(--hb-space-4); gap: var(--hb-space-4);
margin-bottom: var(--hb-space-5); margin-bottom: var(--hb-space-5);
padding: var(--hb-space-5); padding: var(--hb-space-5);
border: 1px solid var(--hb-line-strong); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
} }
.hb-radar-setup__head { .hb-radar-setup__head {
@ -622,10 +642,13 @@
} }
.hb-opp-card__text { .hb-opp-card__text {
margin: 0;
font-size: var(--hb-text-base); font-size: var(--hb-text-base);
color: var(--hb-ink); color: var(--hb-ink);
line-height: 1.65; line-height: 1.65;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
} }
.hb-opp-card__secondary-actions { .hb-opp-card__secondary-actions {
@ -697,11 +720,16 @@
.hb-opp-drawer { .hb-opp-drawer {
position: fixed; position: fixed;
z-index: 20; z-index: 30;
inset: 0 0 0 auto; top: var(--hb-header-offset, calc(var(--hb-topbar-height) + env(safe-area-inset-top, 0px)));
right: 0;
bottom: var(--hb-dock-offset, 0px);
left: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(34rem, 100vw); width: min(34rem, 100vw);
max-height: 100dvh;
overflow: hidden;
border-left: 1px solid var(--hb-line-strong); border-left: 1px solid var(--hb-line-strong);
background: var(--hb-surface-solid); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float); box-shadow: var(--hb-shadow-float);
@ -712,6 +740,7 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--hb-space-3); gap: var(--hb-space-3);
flex-shrink: 0;
padding: var(--hb-space-5); padding: var(--hb-space-5);
border-bottom: 1px solid var(--hb-line); border-bottom: 1px solid var(--hb-line);
} }
@ -725,7 +754,10 @@
.hb-opp-drawer__body { .hb-opp-drawer__body {
display: grid; display: grid;
gap: var(--hb-space-4); gap: var(--hb-space-4);
flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
padding: var(--hb-space-5); padding: var(--hb-space-5);
} }
@ -1090,8 +1122,9 @@
flex-direction: column; flex-direction: column;
gap: var(--hb-space-2); gap: var(--hb-space-2);
padding: var(--hb-space-5); padding: var(--hb-space-5);
border: 1px dashed var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius); border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
} }
.radar-card { .radar-card {

View File

@ -1,6 +1,7 @@
/** /**
* 巡樓 · Lapras Harbor Desk UI * 巡樓 · Lapras Harbor Desk UI
* pale blue 主色 / cream 次色 / burnt orange 點綴Pokémon Palette * pale blue 主色 / cream 次色 / burnt orange 點綴Pokémon Palette
* 紙感畫布hairline 卡片pill CTA8px 工具鈕緊字距大標
* 字體Inter拉丁+ Noto Sans TC中文可變字重 400700 * 字體Inter拉丁+ Noto Sans TC中文可變字重 400700
* *
* 唯一真相顏色字級間距只在這個檔案定義其他 css tsx 一律吃 var() * 唯一真相顏色字級間距只在這個檔案定義其他 css tsx 一律吃 var()
@ -21,87 +22,94 @@
* 混出來一定是濁褐要調淡就跟 surfacelinetransparent 要一深一淺的 * 混出來一定是濁褐要調淡就跟 surfacelinetransparent 要一深一淺的
* 同一顏色就用 X X-deep * 同一顏色就用 X X-deep
* 2. 中性色bg / surface / line / muted / subtle全部維持 H180-191 的冷青 * 2. 中性色bg / surface / line / muted / subtle全部維持 H180-191 的冷青
* ink 與主色同溫palette 原本的 muted H=44 的暖褐疊在偏冷的白底上 * ink 與主色同溫暖色只出現在刻意的強調上warningaccent方案金標
* 會整片發黃發濁暖色只出現在刻意的強調上warningaccent方案金標 * cream--hb-magic與暖橘只當裝飾點綴不畫 CTA
*/ */
:root { :root {
color-scheme: light; color-scheme: light;
--hb-bg: #fcfdfd; --hb-bg: #fcfdfd;
--hb-surface: rgba(255, 255, 255, 0.9); --hb-surface: #ffffff;
--hb-surface-solid: #ffffff; --hb-surface-solid: #ffffff;
--hb-surface-muted: #f2f6f6; --hb-surface-muted: #f2f6f6;
--hb-ink: #152628; --hb-ink: #152628;
--hb-text: #152628; --hb-text: #152628;
--hb-fg: #152628; --hb-fg: #152628;
--hb-ink-secondary: #33474a; --hb-ink-secondary: #33474a;
--hb-muted: #566869; /* 5.8:1 於 bg、5.4:1 於 surface-muted */ --hb-muted: #566869;
--hb-subtle: #748687; /* 3.8:1 — 裝飾/大字限定 */ --hb-subtle: #748687;
--hb-line: color-mix(in srgb, #e0e9eb 88%, transparent); --hb-line: #e0e9eb;
--hb-line-strong: #e0e9eb; --hb-line-strong: #e0e9eb;
--hb-scrim: rgb(21 38 40 / 0.55); --hb-scrim: rgb(21 38 40 / 0.55);
--hb-brand: #8bc5cd; --hb-brand: #8bc5cd;
--hb-brand-hover: #76b8c1; --hb-brand-hover: #76b8c1;
--hb-brand-soft: color-mix(in srgb, #8bc5cd 20%, #ffffff); --hb-brand-soft: color-mix(in srgb, #8bc5cd 20%, #ffffff);
--hb-brand-on: #0a0a0a; /* 10.3:1 於 brand */ --hb-brand-on: #0a0a0a;
--hb-brand-deep: #2d5f66; /* 7.0:1 於 bg主色當文字時用 */ --hb-on-tint: #0a0a0a;
--hb-magic: #fee5a1; /* 次色奶油黃:只當淡底與裝飾 */ --hb-brand-deep: #2d5f66;
--hb-magic-glow: rgb(139 197 205 / 0.2); --hb-magic: #fee5a1;
--hb-magic-glow: rgb(139 197 205 / 0.16);
--hb-focus: #2d5f66; --hb-focus: #2d5f66;
--hb-success: #2f7d3f; /* 純綠 H=135與 brand-deep 的青藍明顯分開 */ --hb-success: #2f7d3f;
--hb-success-soft: #eaf4ec; --hb-success-soft: #eaf4ec;
--hb-success-on: #ffffff; /* 5.2:1 */ --hb-success-on: #ffffff;
--hb-danger: #ef4444; --hb-danger: #ef4444;
--hb-danger-soft: #fdecec; --hb-danger-soft: #fdecec;
--hb-danger-on: #0a0a0a; /* 5.3:1白字只有 3.8:1 */ --hb-danger-on: #0a0a0a;
--hb-danger-deep: #c0392b; /* 5.3:1 於 bg */ --hb-danger-deep: #c0392b;
--hb-warning: #b96b1c; --hb-warning: #b96b1c;
--hb-warning-soft: #fbf1e3; --hb-warning-soft: #fbf1e3;
--hb-warning-on: #0a0a0a; /* 4.9:1白字只有 4.1:1 */ --hb-warning-on: #0a0a0a;
--hb-warning-deep: #a35d16; /* 5.1:1 於白卡 */ --hb-warning-deep: #a35d16;
--hb-accent-warm: #b96b1c; /* 與 warning 同色palette 只有這一個暖點綴 */ --hb-accent-warm: #b96b1c;
/* 畫布極光:主色的暗一階,只用來鋪背景 */
--hb-brand-dim: #6f9ba3; --hb-brand-dim: #6f9ba3;
--hb-aurora-1: color-mix(in srgb, #8bc5cd 20%, transparent); --hb-aurora-1: color-mix(in srgb, #8bc5cd 10%, transparent);
--hb-aurora-2: color-mix(in srgb, #8bc5cd 11%, transparent); --hb-aurora-2: color-mix(in srgb, #8bc5cd 6%, transparent);
--hb-aurora-3: color-mix(in srgb, #6f9ba3 12%, transparent); --hb-aurora-3: color-mix(in srgb, #6f9ba3 6%, transparent);
/* 付費方案的金色字光(裝飾用,不當文字色) */
--hb-gold: #d9a531; --hb-gold: #d9a531;
--hb-gold-bright: #fee5a1; --hb-gold-bright: #fee5a1;
--hb-radius: 0.8rem; --hb-radius-xs: 0.25rem;
--hb-radius-lg: 1rem; --hb-radius-sm: 0.3125rem;
--hb-radius-xl: 1.15rem; --hb-radius: 0.5rem;
--hb-radius-lg: 0.75rem;
--hb-radius-xl: 1rem;
--hb-radius-pill: 9999px; --hb-radius-pill: 9999px;
--hb-shadow-card: --hb-shadow-card: none;
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent), --hb-shadow-soft:
0 1px 2px rgb(21 38 40 / 0.04), rgb(21 38 40 / 0.01) 0 0.175px 1.041px,
0 9px 28px rgb(21 38 40 / 0.06); rgb(21 38 40 / 0.02) 0 0.8px 2.925px,
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow); rgb(21 38 40 / 0.027) 0 2.025px 7.847px,
--hb-shadow-float: 0 16px 48px rgb(21 38 40 / 0.12); rgb(21 38 40 / 0.04) 0 4px 18px;
--hb-shadow-glow: 0 0 24px rgb(139 197 205 / 0.4); --hb-shadow-float:
rgb(21 38 40 / 0.015) 0 0.7px 2.2px,
rgb(21 38 40 / 0.02) 0 2.4px 7px,
rgb(21 38 40 / 0.03) 0 6px 18px,
rgb(21 38 40 / 0.04) 0 12px 32px,
rgb(21 38 40 / 0.05) 0 23px 52px;
--hb-shadow-glow: 0 0 0 3px var(--hb-brand-soft);
/* Latin → Inter中文只走 Noto Sans TC400700含 500600650。 */
--hb-font-sans: "Inter", "Noto Sans TC", system-ui, -apple-system, "Segoe UI", sans-serif; --hb-font-sans: "Inter", "Noto Sans TC", system-ui, -apple-system, "Segoe UI", sans-serif;
--hb-font-en: var(--hb-font-sans); --hb-font-en: var(--hb-font-sans);
--hb-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; --hb-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 字階只有這 7 之間沒有中間值 0.8 / 0.82 / 0.85 併存看不出差別 --hb-text-2xs: 0.75rem;
只會讓整頁失去節奏輸入框維持 16px 避免 iOS 聚焦時縮放 */ --hb-text-xs: 0.8125rem;
--hb-text-2xs: 0.75rem; /* 12px · badge極小 meta */ --hb-text-sm: 0.875rem;
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */ --hb-text-base: 1rem;
--hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */ --hb-text-lg: 1.125rem;
--hb-text-base: 1rem; /* 16px · 內文 */ --hb-text-xl: 1.375rem;
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */ --hb-text-2xl: 1.75rem;
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */ --hb-text-display: clamp(2.25rem, 4.8vw, 3.375rem);
--hb-text-2xl: 1.75rem; /* 28px · 登入/定價大標 */ --hb-text-input: 1rem;
--hb-text-input: 1rem; /* 16px · 表單 */ --hb-track-display: -0.035em;
--hb-track-heading: -0.025em;
--hb-space-1: 0.25rem; --hb-space-1: 0.25rem;
--hb-space-2: 0.5rem; --hb-space-2: 0.5rem;
@ -111,12 +119,12 @@
--hb-space-6: 1.5rem; --hb-space-6: 1.5rem;
--hb-space-8: 2rem; --hb-space-8: 2rem;
--hb-space-10: 2.5rem; --hb-space-10: 2.5rem;
--hb-gap-tight: 0.5rem; /* 列內小元件 */ --hb-gap-tight: 0.5rem;
--hb-gap-inline: 0.625rem; /* 同列按鈕、chip */ --hb-gap-inline: 0.625rem;
--hb-gap-stack: 0.8rem; /* 表單欄位、stack 預設 */ --hb-gap-stack: 0.8rem;
--hb-gap-block: 1.05rem; /* 卡片內大段 */ --hb-gap-block: 1.05rem;
--hb-gap-section: 1.3rem; /* 頁面區塊與區塊 */ --hb-gap-section: 1.3rem;
--hb-gap-page: 1.75rem; /* 頁面上下大留白(桌面) */ --hb-gap-page: 1.75rem;
--hb-touch: 2.75rem; --hb-touch: 2.75rem;
--hb-sidebar-width: 15rem; --hb-sidebar-width: 15rem;
@ -125,60 +133,62 @@
} }
/** /**
* Dark冷灰藍畫布暖褐色作為 muted palette --muted 就是暖色 * Dark冷灰藍畫布主色仍是淺藍當文字走更亮的 --hb-brand-deep
* 深底下亮色本身對比就夠所以 -deep 反而是更亮的一階
*/ */
[data-theme="dark"] { [data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--hb-bg: #141a1a; --hb-bg: #141a1a;
--hb-surface: rgba(27, 38, 39, 0.9); --hb-surface: #1b2627;
--hb-surface-solid: #1b2627; --hb-surface-solid: #1b2627;
--hb-surface-muted: #243132; --hb-surface-muted: #243132;
--hb-ink: #f4f6f6; --hb-ink: #f4f6f6;
--hb-text: #f4f6f6; --hb-text: #f4f6f6;
--hb-fg: #f4f6f6; --hb-fg: #f4f6f6;
--hb-ink-secondary: #d3dcdc; --hb-ink-secondary: #d3dcdc;
--hb-muted: #a9b7b7; /* 8.5:1 */ --hb-muted: #a9b7b7;
--hb-subtle: #8b9a9a; /* 6.0:1 */ --hb-subtle: #8b9a9a;
--hb-line: color-mix(in srgb, #2d4143 92%, transparent); --hb-line: #2d4143;
--hb-line-strong: #2d4143; --hb-line-strong: #2d4143;
--hb-scrim: rgb(0 0 0 / 0.66); --hb-scrim: rgb(0 0 0 / 0.66);
--hb-brand: #89c5cd; --hb-brand: #89c5cd;
--hb-brand-hover: #a3d4da; --hb-brand-hover: #a3d4da;
--hb-brand-soft: color-mix(in srgb, #89c5cd 14%, #1b2627); --hb-brand-soft: color-mix(in srgb, #89c5cd 14%, #1b2627);
--hb-brand-on: #0a0a0a; /* 10.3:1 */ --hb-brand-on: #0a0a0a;
--hb-on-tint: #0a0a0a;
--hb-brand-deep: #b7e3e8; --hb-brand-deep: #b7e3e8;
--hb-magic: #fee59f; --hb-magic: #fee59f;
--hb-magic-glow: rgb(137 197 205 / 0.18); --hb-magic-glow: rgb(137 197 205 / 0.16);
--hb-focus: #89c5cd; --hb-focus: #89c5cd;
--hb-success: #74d189; /* 9.4:1純綠 */ --hb-success: #74d189;
--hb-success-soft: #16251a; --hb-success-soft: #16251a;
--hb-success-on: #0a0a0a; --hb-success-on: #0a0a0a;
--hb-danger: #dc2626; --hb-danger: #dc2626;
--hb-danger-soft: #341919; --hb-danger-soft: #341919;
--hb-danger-on: #ffffff; /* 4.8:1 —— 深底的紅維持白字 */ --hb-danger-on: #ffffff;
--hb-danger-deep: #f87171; /* 5.6:1 於卡片;#ef4444 只有 4.1:1 */ --hb-danger-deep: #f87171;
--hb-warning: #e18c37; /* 6.7:1 */ --hb-warning: #e18c37;
--hb-warning-soft: #2f2416; --hb-warning-soft: #2f2416;
--hb-warning-on: #0a0a0a; --hb-warning-on: #0a0a0a;
--hb-warning-deep: #e18c37; /* 深底不必再加深 */ --hb-warning-deep: #e18c37;
--hb-accent-warm: #e18c37; --hb-accent-warm: #e18c37;
--hb-brand-dim: #7fb0b8; --hb-brand-dim: #7fb0b8;
--hb-aurora-1: color-mix(in srgb, #89c5cd 14%, transparent); --hb-aurora-1: color-mix(in srgb, #89c5cd 10%, transparent);
--hb-aurora-2: color-mix(in srgb, #89c5cd 7%, transparent); --hb-aurora-2: color-mix(in srgb, #89c5cd 5%, transparent);
--hb-aurora-3: color-mix(in srgb, #7fb0b8 8%, transparent); --hb-aurora-3: color-mix(in srgb, #7fb0b8 6%, transparent);
--hb-gold: #e1b84f; --hb-gold: #e1b84f;
--hb-gold-bright: #fee59f; --hb-gold-bright: #fee59f;
--hb-shadow-card: --hb-shadow-card: none;
0 0 0 1px color-mix(in srgb, #2d4143 90%, transparent), --hb-shadow-soft:
0 10px 36px rgb(0 0 0 / 0.45); rgba(0, 0, 0, 0.22) 0 1px 2px,
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow); rgba(0, 0, 0, 0.16) 0 4px 16px;
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55); --hb-shadow-float:
--hb-shadow-glow: 0 0 32px rgb(137 197 205 / 0.28); rgba(0, 0, 0, 0.26) 0 8px 24px,
rgba(0, 0, 0, 0.34) 0 20px 48px;
--hb-shadow-glow: 0 0 0 3px var(--hb-brand-soft);
} }

View File

@ -7,14 +7,14 @@
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
gap: var(--hb-space-2); gap: var(--hb-space-2);
min-height: 2.65rem; min-height: var(--hb-touch);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: var(--hb-radius); border-radius: var(--hb-radius);
border: 1px solid transparent; border: 1px solid transparent;
font-weight: 600; font-weight: 500;
font-size: var(--hb-text-sm); font-size: var(--hb-text-base);
line-height: 1.35; line-height: 1.5;
letter-spacing: -0.01em; letter-spacing: 0;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
@ -39,46 +39,60 @@
} }
/* /*
* 主按鈕選中的 tab側欄與 dock 的選中項共用同一個配方實心 --hb-brand + * CTAHarbor 主色填滿 + pillutilityghost 才走 8px
* --hb-brand-on主色是淺藍漸層到 --hb-brand-deep 會讓近黑字在深端失去對比 * cream暖橘只當裝飾不畫按鈕
* 所以這裡不用漸層深淺兩個主題也不需要各寫一份
*/ */
.hb-btn--primary { .hb-btn--primary {
background: var(--hb-brand); background: var(--hb-brand);
color: var(--hb-brand-on); color: var(--hb-brand-on);
box-shadow: var(--hb-shadow-soft);
border-color: transparent; border-color: transparent;
border-radius: var(--hb-radius-pill);
} }
.hb-btn--primary:hover:not(:disabled) { .hb-btn--primary:hover:not(:disabled) {
transform: translateY(-0.5px);
background: var(--hb-brand-hover); background: var(--hb-brand-hover);
box-shadow: var(--hb-shadow-glow), var(--hb-shadow-soft); }
.hb-btn--primary:active:not(:disabled) {
background: var(--hb-brand-hover);
transform: scale(0.96);
} }
.hb-btn--secondary { .hb-btn--secondary {
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid)); background: var(--hb-surface-solid);
color: var(--hb-ink); color: var(--hb-ink);
border-color: transparent; border-color: transparent;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 6%, transparent); border-radius: var(--hb-radius-pill);
box-shadow: var(--hb-shadow-soft);
} }
.hb-btn--secondary:hover:not(:disabled) { .hb-btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid)); background: var(--hb-surface-solid);
color: var(--hb-ink); color: var(--hb-ink);
} }
.hb-btn--secondary:active:not(:disabled) {
transform: scale(0.96);
}
.hb-btn--ghost { .hb-btn--ghost {
background: color-mix(in srgb, var(--hb-surface-solid) 70%, transparent); background: var(--hb-surface-solid);
color: var(--hb-ink-secondary); color: var(--hb-ink);
border-color: var(--hb-line-strong, var(--hb-line)); border-color: var(--hb-line);
backdrop-filter: blur(8px); border-radius: var(--hb-radius);
padding: 0.25rem 0.875rem;
} }
.hb-btn--ghost:hover:not(:disabled) { .hb-btn--ghost:hover:not(:disabled) {
background: var(--hb-brand-soft); background: var(--hb-surface-muted);
color: var(--hb-brand-deep); color: var(--hb-ink);
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line)); }
@media (prefers-reduced-motion: reduce) {
.hb-btn--primary:active:not(:disabled),
.hb-btn--secondary:active:not(:disabled) {
transform: none;
}
} }
.hb-btn--danger { .hb-btn--danger {
@ -187,9 +201,9 @@
.hb-select { .hb-select {
width: 100%; width: 100%;
min-height: 2.8rem; min-height: 2.8rem;
padding: 0.65rem 0.95rem; padding: 0.4rem 0.65rem;
border: 1px solid var(--hb-line-strong, var(--hb-line)); border: 1px solid var(--hb-line-strong);
border-radius: var(--hb-radius); border-radius: var(--hb-radius-xs);
background: var(--hb-surface-solid, var(--hb-surface)); background: var(--hb-surface-solid, var(--hb-surface));
color: var(--hb-ink); color: var(--hb-ink);
font-size: var(--hb-text-input, 1rem); font-size: var(--hb-text-input, 1rem);
@ -201,8 +215,8 @@
.hb-input:focus, .hb-input:focus,
.hb-textarea:focus, .hb-textarea:focus,
.hb-select:focus { .hb-select:focus {
border-color: color-mix(in srgb, var(--hb-brand) 65%, var(--hb-line)); border-color: var(--hb-line-strong);
box-shadow: 0 0 0 3px var(--hb-brand-soft); box-shadow: var(--hb-shadow-soft);
} }
.hb-input::placeholder, .hb-input::placeholder,
@ -226,13 +240,11 @@
} }
.hb-card { .hb-card {
background: var(--hb-surface); background: var(--hb-surface-solid);
backdrop-filter: blur(12px) saturate(125%); border: 1px solid var(--hb-line);
-webkit-backdrop-filter: blur(12px) saturate(125%); border-radius: var(--hb-radius-lg);
border: 1px solid color-mix(in srgb, var(--hb-brand) 14%, var(--hb-line)); box-shadow: none;
border-radius: var(--hb-radius-xl); padding: var(--hb-space-6);
box-shadow: var(--hb-shadow-card);
padding: var(--hb-space-4);
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
margin: 0; margin: 0;
@ -247,7 +259,7 @@
@media (min-width: 600px) { @media (min-width: 600px) {
.hb-card { .hb-card {
padding: var(--hb-space-5); padding: var(--hb-space-6);
} }
} }
@ -255,8 +267,8 @@
margin: 0; margin: 0;
font-size: var(--hb-text-lg); font-size: var(--hb-text-lg);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: var(--hb-track-heading);
line-height: 1.35; line-height: 1.27;
} }
.hb-card__body { .hb-card__body {
@ -278,11 +290,11 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
max-width: 100%; max-width: 100%;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.5rem;
border-radius: var(--hb-radius-pill); border-radius: var(--hb-radius-pill);
font-size: var(--hb-text-xs); font-size: var(--hb-text-2xs);
font-weight: 650; font-weight: 600;
line-height: 1.35; line-height: 1.33;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
letter-spacing: 0.01em; letter-spacing: 0.01em;
@ -296,9 +308,9 @@
} }
.hb-badge--brand { .hb-badge--brand {
background: var(--hb-brand-soft); background: var(--hb-surface-solid);
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
border-color: color-mix(in srgb, var(--hb-brand) 28%, transparent); border-color: var(--hb-line);
} }
.hb-badge--success { .hb-badge--success {
@ -324,10 +336,10 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: var(--hb-gap-stack); gap: var(--hb-gap-stack);
padding: var(--hb-space-6) var(--hb-space-5); padding: var(--hb-space-6);
border: 1px dashed var(--hb-line); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
margin: 0; margin: 0;
} }
@ -350,12 +362,12 @@
gap: 0.25rem; gap: 0.25rem;
margin: 0 0 var(--hb-gap-stack); margin: 0 0 var(--hb-gap-stack);
padding: 0.28rem; padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 85%, transparent); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-pill); border-radius: var(--hb-radius-pill);
background: color-mix(in srgb, var(--hb-surface) 70%, var(--hb-brand-soft)); background: var(--hb-surface-muted);
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-brand) 8%, transparent); box-shadow: none;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
@ -422,28 +434,21 @@
/* segment 樣式的 tab 是「白片浮起」,選中只換字色。 */ /* segment 樣式的 tab 是「白片浮起」,選中只換字色。 */
.hb-tab.is-active { .hb-tab.is-active {
background: var(--hb-surface); background: var(--hb-surface-solid);
color: var(--hb-brand-deep); color: var(--hb-ink);
box-shadow: 0 1px 4px color-mix(in srgb, var(--hb-brand-deep) 18%, transparent); box-shadow: var(--hb-shadow-soft);
} }
/* 一般 tab 走與側欄選單相同的實心主色塊。 */
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active { .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
background: var(--hb-brand); background: var(--hb-surface-solid);
color: var(--hb-brand-on); color: var(--hb-brand-deep);
box-shadow: 0 2px 8px color-mix(in srgb, var(--hb-brand-deep) 24%, transparent); box-shadow: var(--hb-shadow-soft);
} }
[data-theme="dark"] .hb-badge--brand { [data-theme="dark"] .hb-badge--brand {
background: color-mix(in srgb, var(--hb-brand) 14%, var(--hb-surface-solid)); background: var(--hb-surface-solid);
color: var(--hb-brand-deep);
border-color: color-mix(in srgb, var(--hb-brand) 28%, transparent);
}
[data-theme="dark"] .hb-nav__item--active,
[data-theme="dark"] .hb-dock__item--active {
background: color-mix(in srgb, var(--hb-brand) 14%, transparent);
color: var(--hb-brand-deep); color: var(--hb-brand-deep);
border-color: var(--hb-line);
} }
.hb-grid-2 { .hb-grid-2 {
@ -905,10 +910,10 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0.25rem 0; padding: 0.25rem 0;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent); border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl); border-radius: var(--hb-radius-lg);
background: var(--hb-surface); background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-card); box-shadow: none;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;