Compare commits
2 Commits
main
...
feat/apple
| Author | SHA1 | Date |
|---|---|---|
|
|
e4569d1c2a | |
|
|
2f1336b932 |
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 層,取文字長度 20–800 的最近祖先
|
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] || "";
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,17 @@ type (
|
||||||
ProductId string `json:"product_id,optional"`
|
ProductId string `json:"product_id,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hours are 0–23 in Asia/Taipei. Empty GET means default [6] (台北 06:00).
|
||||||
|
// int64:go-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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ func (l *ListWatchesLogic) ListWatches(req *types.ListWatchesReq) (resp *types.W
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// profile_exists 讓雷達頁能在建訂閱之前就先引導建檔,而不是等 POST 被拒。
|
// profile_exists 讓雷達頁顯示「服務檔案可之後再補」,不再當硬門檻。
|
||||||
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
|
hasProfile, err := l.svcCtx.Radar.HasServiceProfile(l.ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -129,30 +129,21 @@ func (e *m1Env) sweepCandidates(t *testing.T) []string {
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP-01:新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。
|
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsAllowed(t *testing.T) {
|
||||||
func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) {
|
|
||||||
env := newM1Env(t, 42, 5)
|
env := newM1Env(t, 42, 5)
|
||||||
|
|
||||||
_, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
|
w, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{
|
||||||
Terms: []string{"婚攝 推薦"},
|
Terms: []string{"婚攝 推薦"},
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("SP-01: active watch was created without a service profile")
|
t.Fatalf("SP-01: active watch without profile: %v", err)
|
||||||
}
|
}
|
||||||
envelope := assertStatus(t, err, http.StatusBadRequest, 400100)
|
if w.Status != "active" {
|
||||||
if !strings.Contains(envelope.Message, "service-profile") {
|
t.Fatalf("SP-01: status = %q, want active", w.Status)
|
||||||
t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message)
|
|
||||||
}
|
}
|
||||||
// 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。
|
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 1 {
|
||||||
if got := env.list(t, ""); got.Pagination.Total != 0 {
|
t.Fatalf("SP-01: list = %+v, want one active watch", got)
|
||||||
t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。
|
|
||||||
env.createWatch(t, "婚攝 推薦", false)
|
|
||||||
if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 {
|
|
||||||
t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,20 +39,18 @@ func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext,
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP-01:沒建服務檔案就建 active 訂閱 → 400100,訊息要指向服務檔案而不是只說「失敗」。
|
func TestCreateActiveWatchWithoutProfileSucceeds(t *testing.T) {
|
||||||
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
|
|
||||||
ctx, svcCtx := watchCtx(t, 42, 5, false)
|
ctx, svcCtx := watchCtx(t, 42, 5, false)
|
||||||
|
|
||||||
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
|
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
|
||||||
Terms: []string{"婚攝 推薦"},
|
Terms: []string{"婚攝 推薦"},
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("active watch created without a service profile")
|
t.Fatalf("active watch without profile: %v", err)
|
||||||
}
|
}
|
||||||
env := assertStatus(t, err, http.StatusBadRequest, 400100)
|
if w.Status != "active" {
|
||||||
if !strings.Contains(env.Message, "service-profile") {
|
t.Fatalf("status = %q, want active", w.Status)
|
||||||
t.Fatalf("message must point at the service profile, got %q", env.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ func CostPreview(p *domain.CostPreview) *types.CostPreviewPublic {
|
||||||
ServiceProfile 把 domain 檔案轉成 API 形狀。
|
ServiceProfile 把 domain 檔案轉成 API 形狀。
|
||||||
|
|
||||||
p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。
|
p 為 nil 代表使用者還沒建檔:回 exists=false 的空殼,而不是 404 —— 表單本來就要能開空的。
|
||||||
但 exists 這個欄位必須誠實,訂閱閘門(SP-01)與前端引導都看它。
|
但 exists 這個欄位必須誠實,前端用它決定要不要顯示「之後再補」提示。
|
||||||
*/
|
*/
|
||||||
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
|
func ServiceProfile(p *domain.ServiceProfile) *types.ServiceProfilePublic {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 0–23 (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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ func TestGenericWatchKeepsProfileGateAndAssignIsOneWay(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) {
|
if _, err := svc.ResumeWatch(ctx, 42, w.ID); err != nil {
|
||||||
t.Fatalf("generic watch without profile err=%v", err)
|
t.Fatalf("generic watch without profile should resume: %v", err)
|
||||||
}
|
}
|
||||||
assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1")
|
assigned, err := svc.AssignWatchProduct(ctx, 42, w.ID, "b1", "p1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,7 @@ func New(repo domain.Repository) *Service {
|
||||||
GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。
|
GetServiceProfile 未建檔時回 domain.ErrNotFound,由呼叫端決定怎麼表達。
|
||||||
|
|
||||||
HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能
|
HTTP 層會把它翻成 exists=false 的 200(表單本來就要能開空的),但 usecase 不能
|
||||||
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來,而 SP-01 的
|
自己回一個零值檔案 —— 那樣「沒建檔」與「建了一份空的」就分不出來。
|
||||||
訂閱閘門正是靠這個差別。
|
|
||||||
*/
|
*/
|
||||||
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
|
func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domain.ServiceProfile, error) {
|
||||||
if ownerUID <= 0 {
|
if ownerUID <= 0 {
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ func TestUpsertRequiresAtLeastOneService(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「沒建檔」與「建了一份空的」必須分得出來:SP-01 的訂閱閘門靠這個差別。
|
// 「沒建檔」與「建了一份空的」必須分得出來:列表的 profile_exists 靠這個差別。
|
||||||
func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) {
|
func TestGetReportsNotFoundBeforeFirstUpsert(t *testing.T) {
|
||||||
svc := newTestService()
|
svc := newTestService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,17 @@ func productSuggestPrompt(p *ProductContextSnapshot, limit int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
suggestPrompt 用服務檔案組建議關鍵字的提示。
|
suggestPrompt 組建議關鍵字的提示。
|
||||||
|
|
||||||
素材全部來自使用者自己填的服務檔案:服務項目、價格區間、案例、地區、禁語。
|
有服務檔案就帶服務項目、價格、案例、地區、禁語;沒有也能給通用求助短詞。
|
||||||
沒有服務檔案就不呼叫 AI(見 SuggestWatchTerms)—— 沒有依據的建議只是猜測,
|
|
||||||
而使用者會把它當成系統的判斷。
|
|
||||||
*/
|
*/
|
||||||
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("你是台灣本地服務業的行銷助理。根據以下服務檔案,提出可用於社群平台搜尋的關鍵字,")
|
b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字,")
|
||||||
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
|
b.WriteString("目標是找到「正在找這類服務的人」發的貼文,不是找同業的宣傳文。\n\n")
|
||||||
|
if p == nil {
|
||||||
|
b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助/求推薦短搜尋詞。\n")
|
||||||
|
} else {
|
||||||
b.WriteString("服務項目:\n")
|
b.WriteString("服務項目:\n")
|
||||||
for _, s := range p.Services {
|
for _, s := range p.Services {
|
||||||
b.WriteString("- " + s.Name)
|
b.WriteString("- " + s.Name)
|
||||||
|
|
@ -95,6 +95,7 @@ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
|
||||||
if p.ToneNote != "" {
|
if p.ToneNote != "" {
|
||||||
b.WriteString("語氣備註:" + p.ToneNote + "\n")
|
b.WriteString("語氣備註:" + p.ToneNote + "\n")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if len(extra) > 0 {
|
if len(extra) > 0 {
|
||||||
// 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。
|
// 既有痛點關鍵字工具的產出當素材,不另建第二套關鍵字引擎(T514 決策)。
|
||||||
b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n")
|
b.WriteString("既有痛點關鍵字(可參考、可調整):" + strings.Join(extra, "、") + "\n")
|
||||||
|
|
@ -123,10 +124,11 @@ type PainTermSource interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
SuggestWatchTerms 依服務檔案回關鍵字建議(RW-03)。
|
SuggestWatchTerms 回關鍵字建議(RW-03)。
|
||||||
|
|
||||||
不自動寫入任何 watch:使用者逐條採用才有意義,也才看得懂每個詞是為什麼在那裡。
|
服務檔案是加分項,不是門檻。AI 不可用時改走通用短詞,避免「建議關鍵字」變成下一扇牆。
|
||||||
計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5),不新增第五個 meter。
|
不自動寫入任何 watch:使用者逐條採用才有意義。
|
||||||
|
計費走既有 ai_copy meter,source 標 radar.suggest(spec §5.5);fallback 不扣點。
|
||||||
*/
|
*/
|
||||||
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
|
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
|
||||||
if ownerUID <= 0 {
|
if ownerUID <= 0 {
|
||||||
|
|
@ -140,15 +142,12 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := s.Repo.GetServiceProfile(ctx, ownerUID)
|
profile, err := s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%w: service profile required before suggesting keywords; fill in /api/v1/radar/service-profile first",
|
|
||||||
domain.ErrValidation,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
profile = nil
|
||||||
|
}
|
||||||
|
|
||||||
var extra []string
|
var extra []string
|
||||||
if s.PainTerms != nil {
|
if s.PainTerms != nil {
|
||||||
|
|
@ -166,18 +165,69 @@ func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit i
|
||||||
}
|
}
|
||||||
defer charge.Settle(ctx, &err)
|
defer charge.Settle(ctx, &err)
|
||||||
|
|
||||||
raw, err := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
|
raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
|
||||||
if err != nil {
|
var out []domain.WatchTermSuggestion
|
||||||
return nil, err
|
if aiErr == nil {
|
||||||
|
out = domain.CleanSuggestions(parseSuggestions(raw), limit)
|
||||||
|
} else {
|
||||||
|
logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr)
|
||||||
}
|
}
|
||||||
out := domain.CleanSuggestions(parseSuggestions(raw), limit)
|
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
// 空清單會被讀成「你的服務沒有關鍵字可監控」,那是錯的訊息。
|
out = genericSuggestFallback(profile, extra, limit)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
if aiErr != nil {
|
||||||
|
return nil, aiErr
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation)
|
return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議,請稍後再試", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
if aiErr != nil {
|
||||||
|
// 沒真正用到模型:退點,避免「系統自己給的詞還收一次」。
|
||||||
|
charge.Release(ctx)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion {
|
||||||
|
raw := make([]domain.WatchTermSuggestion, 0, limit+8)
|
||||||
|
add := func(term, reason, usage string) {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if term == "" || reason == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
|
||||||
|
}
|
||||||
|
if p != nil {
|
||||||
|
for _, item := range p.Services {
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, term := range domain.SearchableTermVariants(name, true) {
|
||||||
|
add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, term := range extra {
|
||||||
|
basis := strings.TrimSpace(term)
|
||||||
|
if basis == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, v := range domain.SearchableTermVariants(basis, true) {
|
||||||
|
add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude)
|
||||||
|
add("有人知道", "口語求助句,能找到正在發問的人", domain.SuggestUsageInclude)
|
||||||
|
add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude)
|
||||||
|
add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude)
|
||||||
|
add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude)
|
||||||
|
add("徵才", "招募文不是客人", domain.SuggestUsageExclude)
|
||||||
|
add("接案", "同業供給文,不是需求", domain.SuggestUsageExclude)
|
||||||
|
return domain.CleanSuggestions(raw, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product
|
// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product
|
||||||
// snapshot. If the AI provider is unavailable, the catalog's own structured
|
// snapshot. If the AI provider is unavailable, the catalog's own structured
|
||||||
// fields are used as a deterministic, traceable fallback.
|
// fields are used as a deterministic, traceable fallback.
|
||||||
|
|
|
||||||
|
|
@ -119,21 +119,23 @@ func TestSuggestDoesNotCreateWatches(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。
|
func TestSuggestWorksWithoutServiceProfile(t *testing.T) {
|
||||||
func TestSuggestRequiresServiceProfile(t *testing.T) {
|
|
||||||
svc := New(repository.NewMemory())
|
svc := New(repository.NewMemory())
|
||||||
ai := &stubAI{reply: suggestReply}
|
ai := &stubAI{reply: suggestReply}
|
||||||
svc.AI = ai
|
svc.AI = ai
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
|
list, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
t.Fatalf("suggest without profile: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "service-profile") {
|
if len(list) == 0 {
|
||||||
t.Fatalf("error must point at the service profile, got %q", err)
|
t.Fatal("want generic suggestions when profile is missing")
|
||||||
}
|
}
|
||||||
if ai.calls != 0 {
|
if ai.calls != 1 {
|
||||||
t.Fatal("AI was called without a service profile")
|
t.Fatalf("AI calls = %d, want 1", ai.calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") {
|
||||||
|
t.Fatal("prompt should say the profile is missing instead of inventing one")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,22 +198,29 @@ func TestSuggestToleratesProseAroundJSON(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」。
|
// 模型回空話時改走通用短詞,不要讓「建議關鍵字」整顆按鈕壞掉。
|
||||||
func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) {
|
func TestSuggestFallsBackOnUnusableReply(t *testing.T) {
|
||||||
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
|
svc, _, ctx := suggestService(t, "我不知道要建議什麼")
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
t.Fatalf("unusable AI reply should fall back: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("fallback returned nothing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSuggestSurfacesAIFailure(t *testing.T) {
|
func TestSuggestFallsBackWhenAIFails(t *testing.T) {
|
||||||
svc, ai, ctx := suggestService(t, "")
|
svc, ai, ctx := suggestService(t, "")
|
||||||
ai.err = errors.New("provider down")
|
ai.err = errors.New("provider down")
|
||||||
|
|
||||||
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
t.Fatal("AI failure was swallowed")
|
if err != nil {
|
||||||
|
t.Fatalf("AI failure should fall back: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("fallback returned nothing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,34 +271,48 @@ func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳。
|
// AI 失敗改走 fallback:使用者仍拿到詞,但沒真正呼叫模型,所以要退點。
|
||||||
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
|
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
|
||||||
svc, ai, ctx := suggestService(t, "")
|
svc, ai, ctx := suggestService(t, "")
|
||||||
ai.err = errors.New("provider down")
|
ai.err = errors.New("provider down")
|
||||||
usage := platformUsage(42)
|
usage := platformUsage(42)
|
||||||
svc.Usage = usage
|
svc.Usage = usage
|
||||||
|
|
||||||
if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil {
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
t.Fatal("AI failure was swallowed")
|
if err != nil {
|
||||||
|
t.Fatalf("AI failure should fall back: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("fallback returned nothing")
|
||||||
}
|
}
|
||||||
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list events: %v", err)
|
t.Fatalf("list events: %v", err)
|
||||||
}
|
}
|
||||||
if len(events) != 0 {
|
if len(events) != 0 {
|
||||||
t.Fatalf("charged %d events for a failed call", len(events))
|
t.Fatalf("charged %d events for a fallback call", len(events))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSuggestWithoutAIClientAsksForKey(t *testing.T) {
|
func TestSuggestWithoutAIClientUsesFallback(t *testing.T) {
|
||||||
svc := New(repository.NewMemory())
|
svc := New(repository.NewMemory())
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
|
|
||||||
t.Fatalf("seed profile: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
list, err := svc.SuggestWatchTerms(ctx, 42, 0)
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err)
|
t.Fatalf("missing AI client should fall back: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("fallback returned nothing")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, item := range list {
|
||||||
|
if item.Term == "求推薦" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("fallback missing 求推薦: %+v", list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
|
||||||
// for regional/freshness context when present.
|
// for regional/freshness context when present.
|
||||||
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
} else {
|
} else {
|
||||||
profile, err = s.Repo.GetServiceProfile(ctx, ownerUID)
|
profile, _ = s.Repo.GetServiceProfile(ctx, ownerUID)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: service profile required for sweep", domain.ErrValidation)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume: if a sweep already exists for this job, reuse it.
|
// Resume: if a sweep already exists for this job, reuse it.
|
||||||
|
|
|
||||||
|
|
@ -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 lock(value = 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"apps/backend/internal/module/radar/domain"
|
"apps/backend/internal/module/radar/domain"
|
||||||
|
|
@ -47,12 +46,6 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
|
||||||
}
|
}
|
||||||
start, end := domain.UTCDayBounds(domain.NowNano())
|
start, end := domain.UTCDayBounds(domain.NowNano())
|
||||||
|
|
||||||
// Empty-state diagnostics:只有「真的沒建檔」才算 no_profile,其他 DB 錯誤要往上丟。
|
|
||||||
_, profileErr := s.Repo.GetServiceProfile(ctx, ownerUID)
|
|
||||||
noProfile := errors.Is(profileErr, domain.ErrNotFound)
|
|
||||||
if profileErr != nil && !noProfile {
|
|
||||||
return nil, profileErr
|
|
||||||
}
|
|
||||||
watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50})
|
watches, _, err := s.Repo.ListWatches(ctx, ownerUID, domain.WatchListFilter{Page: 1, PageSize: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -148,7 +141,7 @@ func (s *Service) GetTodayFiltered(ctx context.Context, ownerUID int64, productF
|
||||||
if productFiltered {
|
if productFiltered {
|
||||||
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
|
out.EmptyReason, out.EmptyHint = "no_eligible_product_match", "今日沒有符合所選產品且達到可跟進門檻的商機。"
|
||||||
} else {
|
} else {
|
||||||
out.EmptyReason, out.EmptyHint = emptyReason(noProfile, len(watches), len(active), lastSwept, latestFail, start)
|
out.EmptyReason, out.EmptyHint = emptyReason(len(watches), len(active), lastSwept, latestFail, start)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|
@ -173,10 +166,7 @@ func todayHasEligibleProduct(o *domain.Opportunity, f domain.OpportunityListFilt
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyReason(noProfile bool, watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
|
func emptyReason(watchCount, activeCount int, lastSwept int64, fail string, dayStart int64) (reason, hint string) {
|
||||||
if noProfile {
|
|
||||||
return "no_profile", "先完成服務檔案,雷達才能判定適不適合你的服務。"
|
|
||||||
}
|
|
||||||
if watchCount == 0 {
|
if watchCount == 0 {
|
||||||
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
|
return "no_watch", "建立至少一組關鍵字訂閱,明天早晨就會開始巡。"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestGetTodayEmptyNoProfile(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.EmptyReason != "no_profile" {
|
if got.EmptyReason != "no_watch" {
|
||||||
t.Fatalf("empty_reason = %q, want no_profile", got.EmptyReason)
|
t.Fatalf("empty_reason = %q, want no_watch", got.EmptyReason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ func (s *Service) PauseWatch(ctx context.Context, ownerUID int64, id string) (*d
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
ResumeWatch 回到 active,因此要再過一次配額與服務檔案閘:暫停期間方案可能已降級,
|
ResumeWatch 回到 active,因此要再過一次配額閘:暫停期間方案可能已降級,
|
||||||
不重驗就會讓人靠「暫停再恢復」繞過上限。
|
不重驗就會讓人靠「暫停再恢復」繞過上限。
|
||||||
*/
|
*/
|
||||||
func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
|
func (s *Service) ResumeWatch(ctx context.Context, ownerUID int64, id string) (*domain.RadarWatch, error) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (s *Service) MaxDailyOpportunities(ctx context.Context, ownerUID int64) (in
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
assertCanActivateForWatch 是「變成 active」的兩道閘(SP-01、RW-01)。
|
assertCanActivateForWatch 是「變成 active」的配額閘(RW-01)。
|
||||||
|
|
||||||
exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive,
|
exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不會被算進 CountActive,
|
||||||
帶進來只為了在訊息與計算上表達清楚。
|
帶進來只為了在訊息與計算上表達清楚。
|
||||||
|
|
@ -78,42 +78,8 @@ exceptWatchID 是正在恢復的那一筆:它目前不是 active,所以不
|
||||||
既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。
|
既有超額者不強制降級(spec §3.1):這裡只擋「再多一個」。
|
||||||
*/
|
*/
|
||||||
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
|
func (s *Service) assertCanActivateForWatch(ctx context.Context, ownerUID int64, exceptWatchID string, productWatch bool) error {
|
||||||
if productWatch {
|
_ = productWatch
|
||||||
return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID)
|
return s.assertCanActivateQuota(ctx, ownerUID, exceptWatchID)
|
||||||
}
|
|
||||||
hasProfile, err := s.HasServiceProfile(ctx, ownerUID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !hasProfile {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: service profile required before activating a radar watch; fill in /api/v1/radar/service-profile first",
|
|
||||||
domain.ErrValidation,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxActive, err := s.MaxActiveWatches(ctx, ownerUID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
active, err := s.Repo.CountActiveWatches(ctx, ownerUID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if exceptWatchID != "" {
|
|
||||||
if w, err := s.Repo.GetWatch(ctx, exceptWatchID); err == nil && w.Status == domain.WatchActive {
|
|
||||||
active--
|
|
||||||
} else if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if active >= int64(maxActive) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: active watch limit reached (%d of %d on your plan); pause an existing watch or upgrade your plan",
|
|
||||||
domain.ErrValidation, active, maxActive,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error {
|
func (s *Service) assertCanActivateQuota(ctx context.Context, ownerUID int64, exceptWatchID string) error {
|
||||||
|
|
|
||||||
|
|
@ -237,21 +237,20 @@ func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP-01:沒有服務檔案,判定沒有比對基準,所以不准有 active 訂閱。
|
// 服務檔案是選項:沒建檔也能用關鍵字開巡邏。
|
||||||
func TestActiveWatchRequiresServiceProfile(t *testing.T) {
|
func TestActiveWatchDoesNotRequireServiceProfile(t *testing.T) {
|
||||||
svc := New(repository.NewMemory())
|
svc := New(repository.NewMemory())
|
||||||
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
|
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := svc.CreateWatch(ctx, 42, watchInput())
|
w, err := svc.CreateWatch(ctx, 42, watchInput())
|
||||||
if !errors.Is(err, domain.ErrValidation) {
|
if err != nil {
|
||||||
t.Fatalf("err = %v, want ErrValidation", err)
|
t.Fatalf("create active without profile: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "service-profile") {
|
if w.Status != domain.WatchActive {
|
||||||
t.Fatalf("error must point at the service profile, got %q", err)
|
t.Fatalf("status = %q, want active", w.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 但可以先建成 paused 把關鍵字備好。
|
|
||||||
paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}})
|
paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create paused without profile: %v", err)
|
t.Fatalf("create paused without profile: %v", err)
|
||||||
|
|
@ -259,15 +258,8 @@ func TestActiveWatchRequiresServiceProfile(t *testing.T) {
|
||||||
if paused.Status != domain.WatchPaused {
|
if paused.Status != domain.WatchPaused {
|
||||||
t.Fatalf("status = %q, want paused", paused.Status)
|
t.Fatalf("status = %q, want paused", paused.Status)
|
||||||
}
|
}
|
||||||
// 建檔後才能開起來。
|
|
||||||
if _, err := svc.ResumeWatch(ctx, 42, paused.ID); !errors.Is(err, domain.ErrValidation) {
|
|
||||||
t.Fatalf("resume without profile: err = %v, want ErrValidation", err)
|
|
||||||
}
|
|
||||||
if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
|
|
||||||
t.Fatalf("upsert profile: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil {
|
if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil {
|
||||||
t.Fatalf("resume after profile exists: %v", err)
|
t.Fatalf("resume without profile: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
|
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
|
||||||
|
import type { NavGroupKey, NavItem } from "../../lib/nav";
|
||||||
import { AppIcon } from "../ui/AppIcons";
|
import { AppIcon } from "../ui/AppIcons";
|
||||||
|
|
||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { active } = useFirstRun();
|
const { active } = useFirstRun();
|
||||||
|
const [openAdvanced, setOpenAdvanced] = useState<NavGroupKey | null>(null);
|
||||||
const groups = active
|
const groups = active
|
||||||
? navGroupedItemsByKeys(firstRunNavKeys)
|
? navGroupedItemsByKeys(firstRunNavKeys)
|
||||||
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
|
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
|
||||||
|
|
||||||
return (
|
function renderItem(item: NavItem) {
|
||||||
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
|
const current = isNavActive(pathname, item);
|
||||||
<p className="hb-sidebar__label">{t("nav.navigate")}</p>
|
|
||||||
{groups.map(({ group, items }) => (
|
|
||||||
<div className="hb-nav__group" key={group.key}>
|
|
||||||
<p className="hb-nav__group-label">{t(group.labelKey)}</p>
|
|
||||||
<nav className="hb-nav">
|
|
||||||
{items.map((item) => {
|
|
||||||
const active = isNavActive(pathname, item);
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`hb-nav__item${active ? " hb-nav__item--active" : ""}`}
|
className={`hb-nav__item${current ? " hb-nav__item--active" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="hb-nav__ico" aria-hidden>
|
<span className="hb-nav__ico" aria-hidden>
|
||||||
<AppIcon name={item.key} size={18} />
|
<AppIcon name={item.key} size={18} />
|
||||||
|
|
@ -33,10 +29,33 @@ export function SidebarNav() {
|
||||||
<span>{t(item.labelKey)}</span>
|
<span>{t(item.labelKey)}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</nav>
|
|
||||||
|
return (
|
||||||
|
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
|
||||||
|
<p className="hb-sidebar__label">{t("nav.navigate")}</p>
|
||||||
|
{groups.map(({ group, items }) => {
|
||||||
|
// 人在進階頁裡的時候要看得到自己在哪,不能還要先展開才知道。
|
||||||
|
const insideGroup = items.some((item) => isNavActive(pathname, item));
|
||||||
|
const expanded = !group.advanced || openAdvanced === group.key || insideGroup;
|
||||||
|
return (
|
||||||
|
<div className="hb-nav__group" key={group.key}>
|
||||||
|
{group.advanced ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hb-nav__group-toggle"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setOpenAdvanced(expanded ? null : group.key)}
|
||||||
|
>
|
||||||
|
{t(group.labelKey)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="hb-nav__group-label">{t(group.labelKey)}</p>
|
||||||
|
)}
|
||||||
|
{expanded ? <nav className="hb-nav">{items.map(renderItem)}</nav> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { KEYS } from "../../data/mock/keys";
|
||||||
|
import type { Opportunity } from "../../domain/types";
|
||||||
|
import { I18nProvider } from "../../i18n/I18nContext";
|
||||||
|
import { OpportunityInboxCard } from "./OpportunityInboxCard";
|
||||||
|
|
||||||
|
function opp(): Opportunity {
|
||||||
|
return {
|
||||||
|
id: "o1",
|
||||||
|
source: "threads",
|
||||||
|
external_id: "x",
|
||||||
|
permalink: "https://www.threads.net/@a/post/x",
|
||||||
|
author_handle: "buyer",
|
||||||
|
text: "台北有人推薦美甲嗎",
|
||||||
|
posted_at: Date.now() * 1e6,
|
||||||
|
status: "qualified",
|
||||||
|
intent_score: 80,
|
||||||
|
intent_band: "high",
|
||||||
|
reasons: [],
|
||||||
|
region_match: "unknown",
|
||||||
|
freshness_hours: 1,
|
||||||
|
matched_terms: [],
|
||||||
|
created_at: Date.now() * 1e6,
|
||||||
|
review_state: "pending",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpportunityInboxCard simple", () => {
|
||||||
|
it("only offers reply or skip", () => {
|
||||||
|
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<OpportunityInboxCard
|
||||||
|
opportunity={opp()}
|
||||||
|
simple
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onAccept={vi.fn()}
|
||||||
|
onComplete={onComplete}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onRestore={vi.fn()}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "回他" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "先跳過" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "留下" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "丟掉" })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "先跳過" }));
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" }), { reason: "pain_mismatch" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("回他同時把人加進名單,否則名單永遠是空的", () => {
|
||||||
|
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||||
|
const onAccept = vi.fn();
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<OpportunityInboxCard
|
||||||
|
opportunity={opp()}
|
||||||
|
simple
|
||||||
|
onOpen={vi.fn()}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onComplete={onComplete}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
onRestore={vi.fn()}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "回他" }));
|
||||||
|
expect(window.open).toHaveBeenCalledWith("https://www.threads.net/@a/post/x", "_blank", "noopener,noreferrer");
|
||||||
|
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ id: "o1" }));
|
||||||
|
expect(onComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,9 +22,11 @@ type Props = {
|
||||||
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
|
onRemove: (opportunity: Opportunity, input: { reason: OpportunityRemovalReason; note?: string }) => void;
|
||||||
onRestore: (opportunity: Opportunity) => void;
|
onRestore: (opportunity: Opportunity) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
/** 今日:只留回他/先跳過 */
|
||||||
|
simple?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false }: Props) {
|
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false, simple = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [removeOpen, setRemoveOpen] = useState(false);
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
||||||
|
|
@ -53,6 +55,55 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
||||||
setNote("");
|
setNote("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (simple) {
|
||||||
|
return (
|
||||||
|
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
||||||
|
<div className="hb-opp-card__source">
|
||||||
|
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
|
||||||
|
<span>{formatTimeAgo(opportunity.posted_at)}</span>
|
||||||
|
{/* 「回他」會自己開分頁,但瀏覽器可能攔彈窗,留一條手動入口。 */}
|
||||||
|
{opportunity.permalink ? (
|
||||||
|
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.card.openOriginal")}</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="hb-opp-card__text">{opportunity.text}</p>
|
||||||
|
{evidence ? <p className="hb-opp-card__evidence">{evidence}</p> : null}
|
||||||
|
<div className="hb-opp-card__actions" aria-label={t("radar.card.actionsAria")}>
|
||||||
|
{pending ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (opportunity.permalink) window.open(opportunity.permalink, "_blank", "noopener,noreferrer");
|
||||||
|
// 回他就是這條線的正向動作:同時進名單,否則追蹤與成交統計拿不到人。
|
||||||
|
if (accepted) onComplete(opportunity);
|
||||||
|
else onAccept(opportunity);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("today.simple.reply")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpen(opportunity)}>
|
||||||
|
{t("radar.reply.writeForMe")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onRemove(opportunity, { reason: "pain_mismatch" })}
|
||||||
|
>
|
||||||
|
{t("today.simple.skip")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
<article className={`hb-opp-card hb-opp-card--${opportunity.intent_band}`} data-testid={`opportunity-card-${opportunity.id}`}>
|
||||||
<div className="hb-opp-card__meta">
|
<div className="hb-opp-card__meta">
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,12 @@ export function ProductWatchForm({ brands, products, brandId, productId, disable
|
||||||
const product = products.find((p) => p.id === productId);
|
const product = products.find((p) => p.id === productId);
|
||||||
return (
|
return (
|
||||||
<div className="hb-radar-product-context">
|
<div className="hb-radar-product-context">
|
||||||
<Select name="radar-watch-brand" label={t("radar.inbox.brand")} value={brandId} disabled={disabled} required onChange={(e) => onBrandChange(e.target.value)}>
|
<Select name="radar-watch-brand" label={t("radar.inbox.brand")} value={brandId} disabled={disabled} onChange={(e) => onBrandChange(e.target.value)}>
|
||||||
<option value="">{t("radar.watches.pickBrand")}</option>
|
<option value="">{t("radar.watches.skipBrand")}</option>
|
||||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} required onChange={(e) => onProductChange(e.target.value)}>
|
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} onChange={(e) => onProductChange(e.target.value)}>
|
||||||
<option value="">{t("radar.watches.pickProduct")}</option>
|
<option value="">{t("radar.watches.skipProduct")}</option>
|
||||||
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<ProductContextReadiness brand={brand} product={product} />
|
<ProductContextReadiness brand={brand} product={product} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { SERVICE_AREAS } from "./serviceAreas";
|
||||||
|
import { Button, Textarea } from "../ui";
|
||||||
|
import { useRepos } from "../../data/DataContext";
|
||||||
|
import type { Brand, BrandProduct } from "../../domain/types";
|
||||||
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
|
import { useFormatApiError } from "../../lib/apiErrors";
|
||||||
|
import { expandIncludeTerms } from "../../lib/threadsTerm";
|
||||||
|
import { ProductWatchForm } from "./ProductWatchForm";
|
||||||
|
|
||||||
|
function splitTerms(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/[\n,、]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** firstSweepTriggered:後端已排第一輪,前端不該再叫使用者按「立即巡邏」。 */
|
||||||
|
onCreated: (result: { firstSweepTriggered: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 今日空狀態:關鍵字就能開工,品牌/產品是選項。 */
|
||||||
|
export function QuickWatchStart({ onCreated }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const repos = useRepos();
|
||||||
|
const formatError = useFormatApiError();
|
||||||
|
const [terms, setTerms] = useState("");
|
||||||
|
const [regions, setRegions] = useState<string[]>([]);
|
||||||
|
const [brandId, setBrandId] = useState("");
|
||||||
|
const [productId, setProductId] = useState("");
|
||||||
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
|
const [products, setProducts] = useState<BrandProduct[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const scout = (repos as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scout) return;
|
||||||
|
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
|
||||||
|
}, [scout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scout || !brandId) {
|
||||||
|
setProducts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
|
||||||
|
}, [scout, brandId]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const nextTerms = expandIncludeTerms(splitTerms(terms));
|
||||||
|
if (!nextTerms.length) {
|
||||||
|
setError(t("radar.watches.threadsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((brandId && !productId) || (!brandId && productId)) {
|
||||||
|
setError(t("radar.watches.needBrandProductShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await repos.radar.createWatch({
|
||||||
|
terms: nextTerms,
|
||||||
|
regions,
|
||||||
|
enabled: true,
|
||||||
|
brand_id: brandId || undefined,
|
||||||
|
product_id: productId || undefined,
|
||||||
|
});
|
||||||
|
onCreated({ firstSweepTriggered: Boolean(created.first_sweep_triggered) });
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="hb-radar-form hb-stack"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="hb-radar-section__hint">{t("radar.start.hint")}</p>
|
||||||
|
<Textarea
|
||||||
|
name="radar-start-terms"
|
||||||
|
label={t("radar.start.terms")}
|
||||||
|
hint={t("radar.watches.termsHint")}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
value={terms}
|
||||||
|
onChange={(e) => setTerms(e.target.value)}
|
||||||
|
placeholder={t("radar.start.termsPh")}
|
||||||
|
/>
|
||||||
|
{brands.length ? (
|
||||||
|
<ProductWatchForm
|
||||||
|
brands={brands}
|
||||||
|
products={products}
|
||||||
|
brandId={brandId}
|
||||||
|
productId={productId}
|
||||||
|
onBrandChange={(id) => {
|
||||||
|
setBrandId(id);
|
||||||
|
setProductId("");
|
||||||
|
}}
|
||||||
|
onProductChange={(id) => {
|
||||||
|
setProductId(id);
|
||||||
|
const product = products.find((p) => p.id === id);
|
||||||
|
if (!product || terms.trim()) return;
|
||||||
|
const seeded = [...product.pain_points, ...product.match_tags].filter(Boolean);
|
||||||
|
if (seeded.length) setTerms(seeded.join("\n"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<p className="hb-radar-section__hint">{t("radar.start.regions")}</p>
|
||||||
|
<div className="hb-radar-area-grid">
|
||||||
|
{SERVICE_AREAS.map((area) => {
|
||||||
|
const on = regions.includes(area.code);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={area.code}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
className={`hb-radar-chip${on ? " is-active" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
setRegions((cur) => (on ? cur.filter((c) => c !== area.code) : [...cur, area.code]))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{area.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="hb-form-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{busy ? t("radar.start.saving") : t("radar.start.submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useRepos } from "../../data/DataContext";
|
||||||
|
import type { Opportunity, ReplyVariant, ReplyVariantKind, ThreadsAccount } from "../../domain/types";
|
||||||
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
|
import { useFormatApiError } from "../../lib/apiErrors";
|
||||||
|
import { Button, Select } from "../ui";
|
||||||
|
|
||||||
|
/** 公開留言是主線;其餘版本按需生成,避免一次燒五倍點數(需求決策 #6)。 */
|
||||||
|
const SECONDARY_VARIANTS: ReplyVariantKind[] = ["dm", "no_sales", "professional", "humorous"];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
opportunity: Opportunity;
|
||||||
|
/** 送出或標記後讓上層重讀,卡片狀態才跟得上。 */
|
||||||
|
onChanged?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回覆草稿:產生、複製、標記已送出。
|
||||||
|
*
|
||||||
|
* 「回什麼」是這條線最花時間的一步,所以它必須待在看到人的地方,
|
||||||
|
* 而不是另一個頁面。
|
||||||
|
*/
|
||||||
|
export function ReplyComposer({ opportunity, onChanged }: Props) {
|
||||||
|
const repos = useRepos();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const formatError = useFormatApiError();
|
||||||
|
const [reply, setReply] = useState<ReplyVariant | undefined>(opportunity.default_reply);
|
||||||
|
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
||||||
|
const [accountId, setAccountId] = useState("");
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReply(opportunity.default_reply);
|
||||||
|
}, [opportunity.id, opportunity.default_reply]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void repos.accounts
|
||||||
|
.list()
|
||||||
|
.then((list) => {
|
||||||
|
const usable = list.filter((a) => a.is_usable);
|
||||||
|
setAccounts(usable);
|
||||||
|
setAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || ""));
|
||||||
|
})
|
||||||
|
.catch(() => setAccounts([]));
|
||||||
|
}, [repos.accounts]);
|
||||||
|
|
||||||
|
async function generate(variant: ReplyVariantKind) {
|
||||||
|
setBusy(variant);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
setReply(await repos.radar.createReply(opportunity.id, variant));
|
||||||
|
setMessage(t("radar.today.msg.replyReady"));
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!reply?.text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reply.text);
|
||||||
|
setMessage(t("radar.today.msg.copied"));
|
||||||
|
} catch {
|
||||||
|
setError(t("radar.today.msg.copyFail"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markUsed(channel: "outbox" | "manual_copy") {
|
||||||
|
if (!reply?.id) {
|
||||||
|
setError(t("radar.today.msg.needReply"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(`mark-${channel}`);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const res = await repos.radar.markReplyUsed(
|
||||||
|
opportunity.id,
|
||||||
|
reply.id,
|
||||||
|
channel,
|
||||||
|
channel === "outbox" ? accountId : undefined,
|
||||||
|
);
|
||||||
|
setReply(res.reply);
|
||||||
|
setMessage(res.health_advice || t(channel === "outbox" ? "radar.today.msg.sent" : "radar.today.msg.marked"));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDm = reply?.variant === "dm";
|
||||||
|
const canSendOutbox = accounts.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hb-reply-variants">
|
||||||
|
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
|
||||||
|
<div className="hb-radar-actions">
|
||||||
|
<Button type="button" disabled={Boolean(busy)} onClick={() => void generate("public_comment")}>
|
||||||
|
{busy === "public_comment" ? t("radar.today.msg.replyWorking") : t("radar.reply.writeForMe")}
|
||||||
|
</Button>
|
||||||
|
{SECONDARY_VARIANTS.map((variant) => (
|
||||||
|
<Button
|
||||||
|
key={variant}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
onClick={() => void generate(variant)}
|
||||||
|
>
|
||||||
|
{t(`radar.today.reply.variant.${variant}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reply?.text ? (
|
||||||
|
<>
|
||||||
|
<pre className="radar-card__reply">{reply.text}</pre>
|
||||||
|
<div className="hb-radar-actions">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => void copy()}>
|
||||||
|
{t("radar.today.reply.copy")}
|
||||||
|
</Button>
|
||||||
|
{reply.used_at ? (
|
||||||
|
<span className="hb-radar-section__hint">
|
||||||
|
{reply.sent_channel === "outbox" && reply.outbox_id
|
||||||
|
? t("radar.today.reply.usedOutbox")
|
||||||
|
: t("radar.today.reply.used")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy === "mark-manual_copy"}
|
||||||
|
onClick={() => void markUsed("manual_copy")}
|
||||||
|
>
|
||||||
|
{t("radar.today.reply.markCopy")}
|
||||||
|
</Button>
|
||||||
|
{/* 私訊版一律人工送出:平台沒有合規的自動私訊路徑。 */}
|
||||||
|
{!isDm ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={busy === "mark-outbox" || !canSendOutbox}
|
||||||
|
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
|
||||||
|
onClick={() => void markUsed("outbox")}
|
||||||
|
>
|
||||||
|
{t("radar.today.reply.markOutbox")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!isDm && !canSendOutbox ? (
|
||||||
|
<span className="hb-radar-section__hint">
|
||||||
|
{t("radar.today.reply.needAccount")} <Link to="/app/crew">{t("nav.crew")}</Link>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!isDm && accounts.length > 1 ? (
|
||||||
|
<Select
|
||||||
|
name={`reply-account-${opportunity.id}`}
|
||||||
|
label={t("radar.reply.sendAs")}
|
||||||
|
value={accountId}
|
||||||
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
|
>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>@{a.username}</option>)}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="hb-form-error" role="alert">{error}</p> : null}
|
||||||
|
{message ? <p className="hb-radar-section__hint" role="status">{message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ const map: DemandMap = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("巡樓必填欄位", () => {
|
describe("巡樓必填欄位", () => {
|
||||||
it("在品牌與產品標籤旁顯示星號", () => {
|
it("品牌與產品是選項,不必先填", () => {
|
||||||
wrap(
|
wrap(
|
||||||
<ProductWatchForm
|
<ProductWatchForm
|
||||||
brands={[brand]}
|
brands={[brand]}
|
||||||
|
|
@ -58,8 +58,8 @@ describe("巡樓必填欄位", () => {
|
||||||
|
|
||||||
for (const name of ["品牌", "產品"]) {
|
for (const name of ["品牌", "產品"]) {
|
||||||
const field = screen.getByLabelText(name, { exact: false }) as HTMLSelectElement;
|
const field = screen.getByLabelText(name, { exact: false }) as HTMLSelectElement;
|
||||||
expect(field.required).toBe(true);
|
expect(field.required).toBe(false);
|
||||||
expect(field.closest("label")?.querySelector(".hb-field__required")?.textContent).toBe("*");
|
expect(field.closest("label")?.querySelector(".hb-field__required")).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ type Props = {
|
||||||
const SUGGEST_LIMIT = 8;
|
const SUGGEST_LIMIT = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 關鍵字建議:讀服務檔案問 AI,逐條或全部採用。
|
* 關鍵字建議:有服務檔案或產品就依它問 AI,沒有也給通用求助短詞。
|
||||||
*
|
*
|
||||||
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
* 這裡只給候選詞,不會自己建立訂閱——建立與否由使用者在表單按下儲存決定。
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 })}`,
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
/** 自動巡邏時段(台北當地小時 0–23) */
|
||||||
|
export type RadarSchedule = {
|
||||||
|
hours: number[];
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** 雷達訂閱:常駐關鍵字監控 */
|
/** 雷達訂閱:常駐關鍵字監控 */
|
||||||
export type RadarWatch = {
|
export type RadarWatch = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { I18nProvider } from "../i18n/I18nContext";
|
||||||
import { FirstRunBar } from "../components/layout/FirstRunBar";
|
import { FirstRunBar } from "../components/layout/FirstRunBar";
|
||||||
import { SidebarNav } from "../components/layout/SidebarNav";
|
import { SidebarNav } from "../components/layout/SidebarNav";
|
||||||
import { FirstRunProvider } from "./FirstRunContext";
|
import { FirstRunProvider } from "./FirstRunContext";
|
||||||
|
import { isFirstRunAllowedPath } from "./paths";
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
const harness = vi.hoisted(() => ({
|
||||||
member: null as Member | null,
|
member: null as Member | null,
|
||||||
|
|
@ -84,6 +85,12 @@ describe("FirstRunBar", () => {
|
||||||
expect(screen.queryByRole("link", { name: "創作" })).toBeNull();
|
expect(screen.queryByRole("link", { name: "創作" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a new member open patrol setup without being sent back", () => {
|
||||||
|
expect(isFirstRunAllowedPath("/app/radar/watches")).toBe(true);
|
||||||
|
expect(isFirstRunAllowedPath("/app/radar/opportunities")).toBe(true);
|
||||||
|
expect(isFirstRunAllowedPath("/app/studio")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("sends people back to connect if they open another feature", async () => {
|
it("sends people back to connect if they open another feature", async () => {
|
||||||
renderBar("/app/studio");
|
renderBar("/app/studio");
|
||||||
expect(await screen.findByText("crew-page")).toBeTruthy();
|
expect(await screen.findByText("crew-page")).toBeTruthy();
|
||||||
|
|
@ -100,6 +107,7 @@ describe("FirstRunBar", () => {
|
||||||
harness.member = member({ onboarding_status: "completed" });
|
harness.member = member({ onboarding_status: "completed" });
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
|
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
|
||||||
expect(screen.getByRole("link", { name: "創作" })).toBeTruthy();
|
expect(screen.getByRole("link", { name: "名單" })).toBeTruthy();
|
||||||
|
expect(screen.getByRole("link", { name: "今日" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,6 @@ export function isFirstRunAllowedPath(pathname: string): boolean {
|
||||||
if (pathname.startsWith("/app/crew")) return true;
|
if (pathname.startsWith("/app/crew")) return true;
|
||||||
if (pathname.startsWith("/app/settings")) return true;
|
if (pathname.startsWith("/app/settings")) return true;
|
||||||
if (pathname.startsWith("/app/profile")) return true;
|
if (pathname.startsWith("/app/profile")) return true;
|
||||||
|
if (pathname.startsWith("/app/radar")) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const en: MessageDict = {
|
||||||
"nav.today": "Today",
|
"nav.today": "Today",
|
||||||
"nav.crew": "Accounts",
|
"nav.crew": "Accounts",
|
||||||
"nav.studio": "Studio",
|
"nav.studio": "Studio",
|
||||||
"nav.radar": "Demand",
|
"nav.radar": "Patrol",
|
||||||
"nav.crm": "CRM",
|
"nav.crm": "CRM",
|
||||||
"nav.scout": "Topics",
|
"nav.scout": "Topics",
|
||||||
"nav.outbox": "Outbox",
|
"nav.outbox": "Outbox",
|
||||||
|
|
@ -29,9 +29,9 @@ export const en: MessageDict = {
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.logout": "Log out",
|
"nav.logout": "Log out",
|
||||||
"nav.navigate": "Navigate",
|
"nav.navigate": "Navigate",
|
||||||
"navGroup.workflow": "Workflow",
|
"navGroup.workflow": "Today",
|
||||||
"navGroup.accounts": "Accounts & brands",
|
"navGroup.publish": "Post",
|
||||||
"navGroup.growth": "Growth tools",
|
"navGroup.advanced": "Advanced",
|
||||||
|
|
||||||
"workspace.label": "Workspace",
|
"workspace.label": "Workspace",
|
||||||
"workspace.default": "Default",
|
"workspace.default": "Default",
|
||||||
|
|
@ -71,11 +71,11 @@ export const en: MessageDict = {
|
||||||
"help.page.generic.tips": "Help never changes your data; open and close anytime.",
|
"help.page.generic.tips": "Help never changes your data; open and close anytime.",
|
||||||
|
|
||||||
"help.page.today.title": "Today",
|
"help.page.today.title": "Today",
|
||||||
"help.page.today.what": "Daily dashboard: opportunities, patrol queue, outbox pulse, and account health so you know what to do first.",
|
"help.page.today.what": "Everyone worth replying to today. We find them daily; you only decide who to answer.",
|
||||||
"help.page.today.step1": "Check the opportunities summary; open Radar if there are leads.",
|
"help.page.today.step1": "Read the cards top to bottom — one line tells you what they are asking.",
|
||||||
"help.page.today.step2": "Clear pending patrol replies.",
|
"help.page.today.step2": "Press Reply to open the original post and save them to your list.",
|
||||||
"help.page.today.step3": "Track failed or active Outbox items from here.",
|
"help.page.today.step3": "Stuck on wording? Press Write my reply for a draft you can copy. Press Skip for anyone who isn't your customer.",
|
||||||
"help.page.today.tips": "When there are no opportunities, the summary guides you to Opportunity policy or watches.",
|
"help.page.today.tips": "No cards yet? Add the keywords customers search. Change what we look for under \"What to look for\".",
|
||||||
|
|
||||||
"help.page.crew.title": "Accounts (Crew)",
|
"help.page.crew.title": "Accounts (Crew)",
|
||||||
"help.page.crew.what": "Connected Threads accounts, health, and usability. Publishing and outreach start from accounts here.",
|
"help.page.crew.what": "Connected Threads accounts, health, and usability. Publishing and outreach start from accounts here.",
|
||||||
|
|
@ -101,15 +101,15 @@ export const en: MessageDict = {
|
||||||
"help.page.radar_today.title": "Demand patrol",
|
"help.page.radar_today.title": "Demand patrol",
|
||||||
"help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
|
"help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
|
||||||
"help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
|
"help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
|
||||||
"help.page.radar_today.step2": "Read why it was recommended. Keep a fit, discard the rest.",
|
"help.page.radar_today.step2": "Read why it was recommended; the same drawer drafts the reply, switches the primary product, and overrides the band.",
|
||||||
"help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
|
"help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
|
||||||
"help.page.radar_today.tips": "Immediate and daily patrol can both stay on. Turning one off does not hide the other.",
|
"help.page.radar_today.tips": "Need more input? Use Explore now or Manual import. Immediate and daily patrol can both stay on.",
|
||||||
|
|
||||||
"help.page.radar_watches.title": "Patrol setup",
|
"help.page.radar_watches.title": "Patrol setup",
|
||||||
"help.page.radar_watches.what": "Pick a product and keywords. Daily patrol runs on a schedule; you can also run one immediately. Results land on Demand.",
|
"help.page.radar_watches.what": "Add keywords customers search. Daily patrol runs on a schedule; you can also run one immediately. Results land on Today.",
|
||||||
"help.page.radar_watches.step1": "Choose brand and product, then fill the pain map.",
|
"help.page.radar_watches.step1": "Type short customer search words. Brand and service profile can wait.",
|
||||||
"help.page.radar_watches.step2": "Add terms buyers type and excludes.",
|
"help.page.radar_watches.step2": "Add excludes if needed, or pick a brand to seed product terms.",
|
||||||
"help.page.radar_watches.step3": "Leave daily patrol on, or hit Run now.",
|
"help.page.radar_watches.step3": "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 Patrol’s manual scan).",
|
"today.simple.reply": "Reply",
|
||||||
"today.radar.goProfile": "Fill service profile",
|
"today.simple.skip": "Skip",
|
||||||
"today.radar.goWatches": "Add keyword watches",
|
"today.simple.left": "{n} left",
|
||||||
|
"today.simple.findAgain": "Find more",
|
||||||
|
"today.simple.setup": "What to look for",
|
||||||
|
"today.simple.emptyDone": "You're done with this batch",
|
||||||
|
"today.simple.replied": "Original opened — reply there.",
|
||||||
|
"today.simple.repliedSaved": "Original opened and saved to your list. Update the status there after you reply.",
|
||||||
|
"today.simple.viewAll": "See all results",
|
||||||
|
"today.simple.skipped": "Skipped.",
|
||||||
"firstRun.title": "Connect a Threads account first",
|
"firstRun.title": "Connect a Threads account first",
|
||||||
"firstRun.subtitle": "After you connect, the rest of the app unlocks. Tap the button to open Accounts.",
|
"firstRun.subtitle": "After you connect, the rest of the app unlocks. Tap the button to open Accounts.",
|
||||||
"firstRun.skip": "Skip — I'll look around",
|
"firstRun.skip": "Skip — I'll look around",
|
||||||
|
|
@ -2302,7 +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 Patrol’s one-off scan).",
|
|
||||||
"radar.today.link.watches": "Demand watches",
|
"radar.today.link.watches": "Demand watches",
|
||||||
"radar.today.link.crm": "CRM board",
|
"radar.today.link.crm": "CRM board",
|
||||||
"radar.today.stats.total": "Found today",
|
|
||||||
"radar.today.stats.high": "High intent",
|
|
||||||
"radar.today.stats.mid": "Mid intent",
|
|
||||||
"radar.today.stats.low": "Low intent",
|
|
||||||
"radar.today.truncated": "Daily cap reached; {n} lower-intent leads were not listed",
|
|
||||||
"radar.today.lastSwept": "Last sweep: {at}",
|
|
||||||
"radar.today.band.high": "High",
|
"radar.today.band.high": "High",
|
||||||
"radar.today.band.mid": "Mid",
|
"radar.today.band.mid": "Mid",
|
||||||
"radar.today.band.low": "Low",
|
"radar.today.band.low": "Low",
|
||||||
"radar.today.status.accepted": "Added to CRM",
|
|
||||||
"radar.today.status.dismissed": "Dismissed",
|
|
||||||
"radar.today.status.qualified": "Open",
|
|
||||||
"radar.today.status.rejected": "Rejected",
|
|
||||||
"radar.today.status.judging": "Judging",
|
|
||||||
"radar.today.regionUnknown": "Region unknown",
|
|
||||||
"radar.today.group.high": "High intent",
|
|
||||||
"radar.today.group.mid": "Mid intent",
|
|
||||||
"radar.today.group.low": "Low intent",
|
|
||||||
"radar.today.group.empty": "None in this band",
|
|
||||||
"radar.today.group.expand": "Expand",
|
|
||||||
"radar.today.group.collapse": "Collapse",
|
|
||||||
"radar.today.action.open": "Original",
|
"radar.today.action.open": "Original",
|
||||||
"radar.today.action.accept": "Add to CRM",
|
|
||||||
"radar.today.action.dismiss": "Dismiss",
|
|
||||||
"radar.today.action.reply": "Generate reply",
|
|
||||||
"radar.today.action.hideReply": "Hide reply",
|
|
||||||
"radar.today.action.reasons": "Why this score",
|
|
||||||
"radar.today.action.hideReasons": "Hide reasons",
|
|
||||||
"radar.today.action.override": "Override band",
|
"radar.today.action.override": "Override band",
|
||||||
"radar.today.reply.hint": "Pick a variant. DM is copy-only — never auto-sent.",
|
"radar.today.reply.hint": "Pick a variant. DM is copy-only — never auto-sent.",
|
||||||
"radar.today.reply.copy": "Copy draft",
|
"radar.today.reply.copy": "Copy draft",
|
||||||
|
|
@ -2353,32 +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 Patrol’s manual scan).",
|
|
||||||
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
|
|
||||||
"radar.today.empty.reason.not_swept_yet": "Daily patrol hasn’t finished yet; you can also hit Run now on Demand.",
|
|
||||||
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
|
|
||||||
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
|
|
||||||
"radar.today.msg.accepted": "Added to CRM",
|
|
||||||
"radar.today.msg.dismissed": "Dismissed",
|
|
||||||
"radar.today.msg.replyReady": "Reply draft ready",
|
"radar.today.msg.replyReady": "Reply draft ready",
|
||||||
|
"radar.today.msg.replyWorking": "Thinking…",
|
||||||
|
"radar.reply.writeForMe": "Write my reply",
|
||||||
|
"radar.reply.sendAs": "Send as",
|
||||||
"radar.today.msg.overridden": "Band updated",
|
"radar.today.msg.overridden": "Band updated",
|
||||||
"radar.today.msg.copied": "Copied to clipboard",
|
"radar.today.msg.copied": "Copied to clipboard",
|
||||||
"radar.today.msg.copyFail": "Could not copy — select the text manually",
|
"radar.today.msg.copyFail": "Could not copy — select the text manually",
|
||||||
"radar.today.msg.marked": "Marked as sent/copied",
|
"radar.today.msg.marked": "Marked as sent/copied",
|
||||||
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
|
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
|
||||||
"radar.today.msg.needReply": "Generate a reply draft first",
|
"radar.today.msg.needReply": "Generate a reply draft first",
|
||||||
"radar.today.sendAccount": "Send from",
|
|
||||||
"radar.today.reply.markCopy": "Mark as copied & sent",
|
"radar.today.reply.markCopy": "Mark as copied & sent",
|
||||||
"radar.today.reply.markOutbox": "Send now (Outbox)",
|
"radar.today.reply.markOutbox": "Send now (Outbox)",
|
||||||
"radar.today.reply.needAccount": "Connect a Threads account first to send",
|
"radar.today.reply.needAccount": "Connect a Threads account first to send",
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:00(UTC 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": "目前沒有產品匹配",
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export const primaryNav: NavItem[] = [
|
||||||
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
|
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
|
||||||
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
|
||||||
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
|
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
|
||||||
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
|
/** 進階:訂閱關鍵字/每日巡邏設定。開工在今日收件匣。 */
|
||||||
{ key: "radar", path: "/app/radar", labelKey: "nav.radar", label: "商機", en: "Demand" },
|
{ key: "radar", path: "/app/radar/watches", labelKey: "nav.radar", label: "巡邏", en: "Patrol" },
|
||||||
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
|
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
|
||||||
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
|
||||||
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
|
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
|
||||||
|
|
@ -65,40 +65,42 @@ export const primaryNav: NavItem[] = [
|
||||||
/** 第一次工作導覽期間只留這條線需要的入口 */
|
/** 第一次工作導覽期間只留這條線需要的入口 */
|
||||||
export const firstRunNavKeys: NavKey[] = ["today", "crew"];
|
export const firstRunNavKeys: NavKey[] = ["today", "crew"];
|
||||||
|
|
||||||
/** 手機底欄固定 4 格(主流程;radar 進主四格,話題移入更多) */
|
/** 手機底欄:今日清名單、名單、帳號,其餘進更多 */
|
||||||
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"];
|
export const mobileDockPrimaryKeys: NavKey[] = ["today", "crm", "crew"];
|
||||||
|
|
||||||
/** 手機底欄「更多」內項目 */
|
/** 手機底欄「更多」=進階功能(舊網址都還在) */
|
||||||
export const mobileDockMoreKeys: NavKey[] = [
|
export const mobileDockMoreKeys: NavKey[] = [
|
||||||
"crew",
|
"radar",
|
||||||
|
"studio",
|
||||||
"scout",
|
"scout",
|
||||||
|
"outbox",
|
||||||
"jobs",
|
"jobs",
|
||||||
"brands",
|
"brands",
|
||||||
"policy",
|
"policy",
|
||||||
"crm",
|
|
||||||
"insights",
|
"insights",
|
||||||
"utm",
|
"utm",
|
||||||
// 暫隱藏:playbooks(市集)、benchmark(基準)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export type NavGroupKey = "workflow" | "accounts" | "growth";
|
export type NavGroupKey = "workflow" | "publish" | "advanced";
|
||||||
|
|
||||||
export type NavGroup = {
|
export type NavGroup = {
|
||||||
key: NavGroupKey;
|
key: NavGroupKey;
|
||||||
/** i18n key,例如 navGroup.workflow */
|
/** i18n key,例如 navGroup.workflow */
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
keys: NavKey[];
|
keys: NavKey[];
|
||||||
|
/** 側欄預設收合:設定類的頁面設一次就好,不該每天佔著視線。 */
|
||||||
|
advanced?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 側欄二級分類:主流程(商機→名單)→ 帳號品牌 → 成長工具;話題放創作側(workflow 內 studio 旁) */
|
/** 側欄:每天走的那條線放最上面,設定與報表收進進階 */
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] },
|
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "crm", "crew"] },
|
||||||
{ key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands", "policy"] },
|
{ key: "publish", labelKey: "navGroup.publish", keys: ["studio", "outbox"] },
|
||||||
{
|
{
|
||||||
key: "growth",
|
key: "advanced",
|
||||||
labelKey: "navGroup.growth",
|
labelKey: "navGroup.advanced",
|
||||||
// 暫隱藏:benchmark(基準)、playbooks(市集)
|
advanced: true,
|
||||||
keys: ["insights", "utm"],
|
keys: ["radar", "scout", "brands", "policy", "jobs", "insights", "utm"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -118,12 +120,17 @@ export function navGroupedItemsByKeys(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNavActive(pathname: string, item: NavItem): boolean {
|
export function isNavActive(pathname: string, item: NavItem): boolean {
|
||||||
if (item.path === "/app/today") {
|
if (item.key === "today") {
|
||||||
return pathname === "/app" || pathname === "/app/today";
|
return (
|
||||||
|
pathname === "/app" ||
|
||||||
|
pathname === "/app/today" ||
|
||||||
|
pathname === "/app/radar" ||
|
||||||
|
pathname === "/app/radar/today" ||
|
||||||
|
pathname.startsWith("/app/radar/opportunities")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 雷達有 today/watches 兩個子頁,側欄以 /app/radar 前綴對齊。
|
|
||||||
if (item.key === "radar") {
|
if (item.key === "radar") {
|
||||||
return pathname === "/app/radar" || pathname.startsWith("/app/radar/");
|
return pathname.startsWith("/app/radar/watches");
|
||||||
}
|
}
|
||||||
if (item.key === "crm") {
|
if (item.key === "crm") {
|
||||||
return pathname === "/app/crm" || pathname.startsWith("/app/crm/");
|
return pathname === "/app/crm" || pathname.startsWith("/app/crm/");
|
||||||
|
|
@ -148,9 +155,9 @@ export function pathForNotification(n: {
|
||||||
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
|
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
|
||||||
if (n.ref_type === "contact") return "/app/crm/followups";
|
if (n.ref_type === "contact") return "/app/crm/followups";
|
||||||
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
|
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
|
||||||
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar";
|
if (n.ref_type === "opportunity" && n.ref_id) return "/app/today";
|
||||||
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
|
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
|
||||||
if (n.ref_type === "radar") return "/app/radar";
|
if (n.ref_type === "radar") return "/app/today";
|
||||||
// 任務/系統/無 ref:任務中心
|
// 任務/系統/無 ref:任務中心
|
||||||
return "/app/jobs";
|
return "/app/jobs";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,15 @@ describe("pageHelp", () => {
|
||||||
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
|
||||||
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
|
||||||
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
|
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
|
||||||
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar");
|
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar/watches");
|
||||||
|
expect(resolvePageHelpId("/app/today")).toBe("today");
|
||||||
|
expect(resolvePageHelpId("/app/radar/opportunities")).toBe("radar_today");
|
||||||
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
|
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
|
||||||
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
|
||||||
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");
|
||||||
expect(resolvePageHelpId("/app/policy")).toBe("policy");
|
expect(resolvePageHelpId("/app/policy")).toBe("policy");
|
||||||
expect(resolvePageHelpId("/app/usage/plans")).toBe("usage_plans");
|
expect(resolvePageHelpId("/app/usage/plans")).toBe("usage_plans");
|
||||||
expect(resolvePageHelpId("/app/today")).toBe("today");
|
expect(resolvePageHelpId("/app")).toBe("today");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back outside /app", () => {
|
it("falls back outside /app", () => {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ export type PageHelpRelated = {
|
||||||
/** 最長前綴優先;含查詢字串的路徑先去掉 search/hash。 */
|
/** 最長前綴優先;含查詢字串的路徑先去掉 search/hash。 */
|
||||||
const RULES: { prefix: string; id: PageHelpId }[] = [
|
const RULES: { prefix: string; id: PageHelpId }[] = [
|
||||||
{ prefix: "/app/radar/watches", id: "radar_watches" },
|
{ prefix: "/app/radar/watches", id: "radar_watches" },
|
||||||
{ prefix: "/app/radar/today", id: "radar_today" },
|
|
||||||
{ prefix: "/app/radar", id: "radar_today" },
|
{ prefix: "/app/radar", id: "radar_today" },
|
||||||
{ prefix: "/app/crm/followups", id: "crm_followups" },
|
{ prefix: "/app/crm/followups", id: "crm_followups" },
|
||||||
{ prefix: "/app/crm/stats", id: "crm_stats" },
|
{ prefix: "/app/crm/stats", id: "crm_stats" },
|
||||||
|
|
@ -62,6 +61,7 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
|
||||||
{ prefix: "/app/usage/checkout", id: "usage_checkout" },
|
{ prefix: "/app/usage/checkout", id: "usage_checkout" },
|
||||||
{ prefix: "/app/usage", id: "usage" },
|
{ prefix: "/app/usage", id: "usage" },
|
||||||
{ prefix: "/app/users", id: "admin_users" },
|
{ prefix: "/app/users", id: "admin_users" },
|
||||||
|
// 今日是簡化收件匣,進階巡邏頁(/app/radar/*)另有一份說明。
|
||||||
{ prefix: "/app/today", id: "today" },
|
{ prefix: "/app/today", id: "today" },
|
||||||
{ prefix: "/app", id: "today" },
|
{ prefix: "/app", id: "today" },
|
||||||
];
|
];
|
||||||
|
|
@ -69,31 +69,31 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
|
||||||
/** 每個說明可連到的相關頁(可選) */
|
/** 每個說明可連到的相關頁(可選) */
|
||||||
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
|
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
|
||||||
today: [
|
today: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/radar/watches", labelKey: "nav.radar" },
|
||||||
{ path: "/app/scout", labelKey: "nav.scout" },
|
{ path: "/app/crm", labelKey: "nav.crm" },
|
||||||
{ path: "/app/outbox", labelKey: "nav.outbox" },
|
{ path: "/app/crew", labelKey: "nav.crew" },
|
||||||
],
|
],
|
||||||
radar_today: [
|
radar_today: [
|
||||||
{ path: "/app/radar/watches", labelKey: "radar.today.link.watches" },
|
{ path: "/app/radar/watches", labelKey: "radar.today.link.watches" },
|
||||||
{ path: "/app/crm", labelKey: "nav.crm" },
|
{ path: "/app/crm", labelKey: "nav.crm" },
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/crew", labelKey: "nav.crew" },
|
||||||
],
|
],
|
||||||
radar_watches: [
|
radar_watches: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
{ path: "/app/policy", labelKey: "nav.policy" },
|
{ path: "/app/brands", labelKey: "nav.brands" },
|
||||||
],
|
],
|
||||||
crm_board: [
|
crm_board: [
|
||||||
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
|
||||||
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
],
|
],
|
||||||
crm_followups: [
|
crm_followups: [
|
||||||
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
|
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
],
|
],
|
||||||
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
|
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
|
||||||
scout: [
|
scout: [
|
||||||
{ path: "/app/radar", labelKey: "nav.radar" },
|
{ path: "/app/today", labelKey: "nav.today" },
|
||||||
{ path: "/app/brands", labelKey: "nav.brands" },
|
{ path: "/app/brands", labelKey: "nav.brands" },
|
||||||
],
|
],
|
||||||
brands: [
|
brands: [
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ export function CrmBoardPage() {
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("crm.board.title")} />
|
<PageHeader title={t("crm.board.title")} />
|
||||||
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
|
<Link className="hb-btn hb-btn--secondary" to="/app/today">
|
||||||
{t("crm.board.link.today")}
|
{t("crm.board.link.today")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
||||||
|
|
@ -262,7 +262,7 @@ export function CrmBoardPage() {
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t("crm.board.empty")}
|
title={t("crm.board.empty")}
|
||||||
description={t("crm.board.emptyHint")}
|
description={t("crm.board.emptyHint")}
|
||||||
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar">{t("crm.board.link.today")}</Link>}
|
action={<Link className="hb-btn hb-btn--secondary" to="/app/today">{t("crm.board.link.today")}</Link>}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "../data/live/http";
|
import { ApiError } from "../data/live/http";
|
||||||
import { KEYS } from "../data/mock/keys";
|
import { KEYS } from "../data/mock/keys";
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
import { I18nProvider } from "../i18n/I18nContext";
|
||||||
import type { Opportunity, RadarToday } from "../domain/types";
|
import type { Opportunity } from "../domain/types";
|
||||||
import { RadarTodayPage } from "./RadarTodayPage";
|
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
||||||
|
|
||||||
const backend = vi.hoisted(() => ({
|
const backend = vi.hoisted(() => ({
|
||||||
today: null as RadarToday | null,
|
|
||||||
error: null as unknown,
|
error: null as unknown,
|
||||||
calls: [] as Array<Record<string, string | undefined>>,
|
|
||||||
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
|
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -67,64 +65,76 @@ function sampleOpportunity(): Opportunity {
|
||||||
primary_product_fit_score: 100,
|
primary_product_fit_score: 100,
|
||||||
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
|
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
|
||||||
created_at: Date.now() * 1e6,
|
created_at: Date.now() * 1e6,
|
||||||
|
review_state: "pending",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("../data/DataContext", () => ({
|
vi.mock("../data/DataContext", () => ({
|
||||||
useRepos: () => ({
|
useRepos: (() => {
|
||||||
|
const repos = {
|
||||||
accounts: { async list() { return []; } },
|
accounts: { async list() { return []; } },
|
||||||
|
jobs: { async get(id: string) { return { id, template_type: "radar_sweep", status: "succeeded", progress_summary: "", progress_percent: 100, created_at: 1, updated_at: 1 }; } },
|
||||||
scout: {
|
scout: {
|
||||||
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
||||||
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }, { id: "p2", brand_id: "b1", label: "修護乳霜", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
|
async listProducts() {
|
||||||
|
return [
|
||||||
|
{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 },
|
||||||
|
{ id: "p2", brand_id: "b1", label: "修護乳霜", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 },
|
||||||
|
];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
radar: {
|
radar: {
|
||||||
async getToday(filter?: { brand_id?: string; product_id?: string; fit_band?: string }) {
|
async listOpportunities() {
|
||||||
backend.calls.push(filter ?? {});
|
|
||||||
if (backend.error) throw backend.error;
|
if (backend.error) throw backend.error;
|
||||||
if (!backend.today) throw new Error("missing live fixture");
|
return { list: [sampleOpportunity()], total: 1 };
|
||||||
return backend.today;
|
|
||||||
},
|
},
|
||||||
|
async listWatches() {
|
||||||
|
return { list: [], total: 0, active_count: 0, max_active: 5, profile_exists: true };
|
||||||
|
},
|
||||||
|
async getToday() {
|
||||||
|
return { stats: { total: 0, high: 0, mid: 0, low: 0 }, high: [], mid: [], low: [], truncated_count: 0, last_swept_at: Date.now() * 1e6 };
|
||||||
|
},
|
||||||
|
async listSweeps() { return { list: [], total: 0 }; },
|
||||||
async setPrimaryProduct(id: string, productId: string, reason: string) {
|
async setPrimaryProduct(id: string, productId: string, reason: string) {
|
||||||
backend.primaryCalls.push({ id, productId, reason });
|
backend.primaryCalls.push({ id, productId, reason });
|
||||||
return backend.today?.high[0] ?? sampleOpportunity();
|
return { ...sampleOpportunity(), primary_product_id: productId, primary_product_overridden: true };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
|
return () => repos;
|
||||||
|
})(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/app/radar/today?brand_id=b1&product_id=p2&fit_band=strong"]}>
|
<MemoryRouter initialEntries={["/app/radar/opportunities"]}>
|
||||||
<I18nProvider><RadarTodayPage /></I18nProvider>
|
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||||
backend.today = { stats: { total: 1, high: 1, mid: 0, low: 0 }, high: [sampleOpportunity()], mid: [], low: [], truncated_count: 0 };
|
|
||||||
backend.error = null;
|
backend.error = null;
|
||||||
backend.calls = [];
|
|
||||||
backend.primaryCalls = [];
|
backend.primaryCalls = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("live product radar flow", () => {
|
describe("多產品商機的證據與主推覆寫", () => {
|
||||||
it("keeps URL filters, renders multi-product evidence, and sends primary override", async () => {
|
it("抽屜列出每個產品的匹配證據,並能改主推", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByText("敏感肌使用者求推薦:泛紅不適,想了解日常修護。"))
|
fireEvent.click(await screen.findByRole("button", { name: "為什麼推薦" }));
|
||||||
.toBeTruthy();
|
|
||||||
expect(backend.calls[0]).toEqual({ brand_id: "b1", product_id: "p2", fit_band: "strong" });
|
expect(await screen.findByText("對應泛紅不適")).toBeTruthy();
|
||||||
expect(screen.getAllByText("舒緩精華").length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/修護乳霜/).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 2 個產品匹配" }));
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
|
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
|
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
|
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
|
||||||
|
|
||||||
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
|
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps API failures visible instead of showing zero results", async () => {
|
it("API 失敗時說出原因,不顯示成功的零結果", async () => {
|
||||||
backend.today = null;
|
|
||||||
backend.error = new ApiError("服務暫時不可用", 501010, 503);
|
backend.error = new ApiError("服務暫時不可用", 501010, 503);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
import { PageHeader } from "../components/layout/PageHeader";
|
||||||
|
import { ExplorePanel } from "../components/radar/ExplorePanel";
|
||||||
|
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
|
||||||
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
|
||||||
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
|
||||||
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
||||||
|
import { QuickWatchStart } from "../components/radar/QuickWatchStart";
|
||||||
import { Button, EmptyState, Select } from "../components/ui";
|
import { Button, EmptyState, Select } from "../components/ui";
|
||||||
import { useRepos } from "../data/DataContext";
|
import { useRepos } from "../data/DataContext";
|
||||||
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
|
import type { Brand, BrandProduct, IntentBand, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
import { useI18n } from "../i18n/I18nContext";
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
import { useFormatApiError } from "../lib/apiErrors";
|
||||||
import { sanitizeReviewCopy } from "../lib/reviewCopy";
|
import { sanitizeReviewCopy } from "../lib/reviewCopy";
|
||||||
|
|
@ -24,6 +27,8 @@ function normalizeSort(value: string | null): string {
|
||||||
export function RadarOpportunitiesPage() {
|
export function RadarOpportunitiesPage() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const simple = pathname.startsWith("/app/today");
|
||||||
const formatError = useFormatApiError();
|
const formatError = useFormatApiError();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [list, setList] = useState<Opportunity[]>([]);
|
const [list, setList] = useState<Opportunity[]>([]);
|
||||||
|
|
@ -50,6 +55,12 @@ export function RadarOpportunitiesPage() {
|
||||||
const [watchTotal, setWatchTotal] = useState(0);
|
const [watchTotal, setWatchTotal] = useState(0);
|
||||||
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
|
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
|
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
|
||||||
|
// 系統自己放寬的時間範圍不算「使用者下了篩選」,否則清完卡片會被告知去清一個他沒設過的篩選。
|
||||||
|
const [autoWidened, setAutoWidened] = useState(false);
|
||||||
|
// 第一組訂閱建立時後端已排了首輪巡邏,這個旗標讓空狀態說「正在找」而不是叫他再按一次。
|
||||||
|
const [firstSweepPending, setFirstSweepPending] = useState(false);
|
||||||
|
const [exploreOpen, setExploreOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -101,23 +112,18 @@ export function RadarOpportunitiesPage() {
|
||||||
if (canWiden && result.total === 0) {
|
if (canWiden && result.total === 0) {
|
||||||
const week = await query("7d");
|
const week = await query("7d");
|
||||||
if (week.total > 0) {
|
if (week.total > 0) {
|
||||||
|
// 不寫進網址:重新整理後會再自己放寬一次,網址留著反而讓下次進頁被當成使用者篩選。
|
||||||
setTimeScope("7d");
|
setTimeScope("7d");
|
||||||
|
setAutoWidened(true);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
|
||||||
next.set("time_scope", "7d");
|
|
||||||
next.delete("page");
|
|
||||||
setParams(next, { replace: true });
|
|
||||||
result = week;
|
result = week;
|
||||||
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
|
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
|
||||||
} else {
|
} else {
|
||||||
const all = await query("all");
|
const all = await query("all");
|
||||||
if (all.total > 0) {
|
if (all.total > 0) {
|
||||||
setTimeScope("all");
|
setTimeScope("all");
|
||||||
|
setAutoWidened(true);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
|
|
||||||
next.set("time_scope", "all");
|
|
||||||
next.delete("page");
|
|
||||||
setParams(next, { replace: true });
|
|
||||||
result = all;
|
result = all;
|
||||||
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
|
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -136,7 +142,7 @@ export function RadarOpportunitiesPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams, t]);
|
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, t]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
||||||
|
|
@ -170,7 +176,7 @@ export function RadarOpportunitiesPage() {
|
||||||
await load();
|
await load();
|
||||||
setError("");
|
setError("");
|
||||||
setNotice({
|
setNotice({
|
||||||
text: t("radar.inbox.msg.accepted"),
|
text: t(simple ? "today.simple.repliedSaved" : "radar.inbox.msg.accepted"),
|
||||||
contactId: result.contact_id,
|
contactId: result.contact_id,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -180,6 +186,43 @@ export function RadarOpportunitiesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 主推與覆寫都會改判定結果,所以做完要重讀,抽屜也換成新的那一筆。 */
|
||||||
|
async function patchOpportunity(
|
||||||
|
opportunity: Opportunity,
|
||||||
|
action: () => Promise<Opportunity>,
|
||||||
|
successText: string,
|
||||||
|
) {
|
||||||
|
setBusyId(opportunity.id);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const updated = await action();
|
||||||
|
setSelected((current) => (current?.id === updated.id ? updated : current));
|
||||||
|
await load();
|
||||||
|
setError("");
|
||||||
|
setNotice({ text: successText });
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatError(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrimary(opportunity: Opportunity, productId: string, reason: string) {
|
||||||
|
return patchOpportunity(
|
||||||
|
opportunity,
|
||||||
|
() => repos.radar.setPrimaryProduct(opportunity.id, productId, reason),
|
||||||
|
t("radar.today.msg.primarySet"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideBand(opportunity: Opportunity, band: IntentBand) {
|
||||||
|
return patchOpportunity(
|
||||||
|
opportunity,
|
||||||
|
() => repos.radar.overrideOpportunity(opportunity.id, { band }),
|
||||||
|
t("radar.today.msg.overridden"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
|
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
|
||||||
const deadline = Date.now() + 120_000;
|
const deadline = Date.now() + 120_000;
|
||||||
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
||||||
|
|
@ -274,6 +317,7 @@ export function RadarOpportunitiesPage() {
|
||||||
setProductId("");
|
setProductId("");
|
||||||
setSort("recommended");
|
setSort("recommended");
|
||||||
setTimeScope("today");
|
setTimeScope("today");
|
||||||
|
setAutoWidened(false);
|
||||||
setAdvancedOpen(false);
|
setAdvancedOpen(false);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams();
|
||||||
|
|
@ -287,11 +331,14 @@ export function RadarOpportunitiesPage() {
|
||||||
);
|
);
|
||||||
const scheduledOn = activeCount > 0;
|
const scheduledOn = activeCount > 0;
|
||||||
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
|
||||||
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
|
const filtered = Boolean(
|
||||||
|
advancedFilterCount || sort !== "recommended" || (timeScope !== "today" && !autoWidened),
|
||||||
|
);
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
||||||
if (filtered) {
|
// 今日頁沒有篩選 UI,講「清除篩選」等於指向一個不存在的按鈕。
|
||||||
|
if (filtered && !simple) {
|
||||||
return {
|
return {
|
||||||
title: t(
|
title: t(
|
||||||
reviewState === "pending"
|
reviewState === "pending"
|
||||||
|
|
@ -312,9 +359,15 @@ export function RadarOpportunitiesPage() {
|
||||||
}
|
}
|
||||||
if (watchTotal === 0) {
|
if (watchTotal === 0) {
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.noWatchesTitle"),
|
title: t("radar.start.title"),
|
||||||
description: t("radar.inbox.empty.noWatchesHint"),
|
description: t("radar.inbox.empty.noWatchesHint"),
|
||||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>,
|
action: <QuickWatchStart onCreated={({ firstSweepTriggered }) => {
|
||||||
|
setFirstSweepPending(firstSweepTriggered);
|
||||||
|
// 首輪有跑時,空狀態本身就會說「正在幫你找」,再加一條同樣的橫幅只是重複。
|
||||||
|
if (!firstSweepTriggered) setNotice({ text: t("radar.start.msg.scheduled") });
|
||||||
|
void load();
|
||||||
|
void loadPatrol();
|
||||||
|
}} />,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!scheduledOn) {
|
if (!scheduledOn) {
|
||||||
|
|
@ -325,6 +378,10 @@ export function RadarOpportunitiesPage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!lastSweptAt) {
|
if (!lastSweptAt) {
|
||||||
|
// 首輪已經在跑:再給一顆「立即巡邏」只會排第二輪,也讓使用者以為剛才沒成功。
|
||||||
|
if (firstSweepPending) {
|
||||||
|
return { title: t("radar.start.msg.searching"), description: t("radar.start.msg.searchingHint") };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.neverTitle"),
|
title: t("radar.inbox.empty.neverTitle"),
|
||||||
description: t("radar.inbox.empty.neverHint"),
|
description: t("radar.inbox.empty.neverHint"),
|
||||||
|
|
@ -361,11 +418,14 @@ export function RadarOpportunitiesPage() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: t("radar.inbox.empty.noFitTitle"),
|
title: simple ? t("today.simple.emptyDone") : t("radar.inbox.empty.noFitTitle"),
|
||||||
description: lastSweep
|
description: lastSweep
|
||||||
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
|
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
|
||||||
: t("radar.inbox.empty.noFitHint"),
|
: t("radar.inbox.empty.noFitHint"),
|
||||||
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>,
|
// 已經在近 7 天/全部時再給「看近 7 天」是死按鈕。
|
||||||
|
action: timeScope === "today"
|
||||||
|
? <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setAutoWidened(false); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,8 +433,20 @@ export function RadarOpportunitiesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("radar.inbox.title")} />
|
<PageHeader
|
||||||
|
title={simple ? t("nav.today") : t("radar.inbox.title")}
|
||||||
|
description={simple && total > 0 ? t("today.simple.left", { n: total }) : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{simple && watchTotal > 0 ? (
|
||||||
|
<div className="hb-today-simple-bar">
|
||||||
|
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
|
||||||
|
{sweeping ? t("radar.inbox.sweeping") : t("today.simple.findAgain")}
|
||||||
|
</Button>
|
||||||
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">{t("today.simple.setup")}</Link>
|
||||||
|
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("today.simple.viewAll")}</Link>
|
||||||
|
</div>
|
||||||
|
) : simple ? null : (
|
||||||
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
|
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
|
||||||
<div className="hb-radar-patrol__status">
|
<div className="hb-radar-patrol__status">
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -394,15 +466,36 @@ export function RadarOpportunitiesPage() {
|
||||||
<small>{t("radar.patrol.searchFallback")}</small>
|
<small>{t("radar.patrol.searchFallback")}</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
|
)}
|
||||||
|
{!simple && lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
|
||||||
|
|
||||||
|
{simple ? null : (
|
||||||
|
<>
|
||||||
|
<div className="hb-radar-utility-actions">
|
||||||
|
<span>{t("radar.today.needMore")}</span>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => { setExploreOpen((v) => !v); setImportOpen(false); }}>
|
||||||
|
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => { setImportOpen((v) => !v); setExploreOpen(false); }}>
|
||||||
|
{importOpen ? t("radar.import.close") : t("radar.import.open")}
|
||||||
|
</Button>
|
||||||
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm">{t("radar.today.link.crm")}</Link>
|
||||||
|
</div>
|
||||||
|
{exploreOpen ? <ExplorePanel onExplored={() => { void load(); void loadPatrol(); }} /> : null}
|
||||||
|
{importOpen ? <ManualImportPanel onImported={() => { void load(); void loadPatrol(); }} /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simple ? null : (
|
||||||
<section className="hb-radar-intro">
|
<section className="hb-radar-intro">
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("radar.inbox.introTitle")}</strong>
|
<strong>{t("radar.inbox.introTitle")}</strong>
|
||||||
<p>{t("radar.inbox.introBody")}</p>
|
<p>{t("radar.inbox.introBody")}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simple ? null : (
|
||||||
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
|
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
|
||||||
<div className="hb-radar-filter-panel__head">
|
<div className="hb-radar-filter-panel__head">
|
||||||
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
|
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
|
||||||
|
|
@ -420,7 +513,7 @@ export function RadarOpportunitiesPage() {
|
||||||
<div className="hb-radar-inbox-essential-filters">
|
<div className="hb-radar-inbox-essential-filters">
|
||||||
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
|
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
|
||||||
const value = e.target.value as OpportunityTimeScope;
|
const value = e.target.value as OpportunityTimeScope;
|
||||||
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
|
setTimeScope(value); setAutoWidened(false); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
|
||||||
}}>
|
}}>
|
||||||
<option value="today">{t("radar.inbox.time.today")}</option><option value="7d">{t("radar.inbox.time.7d")}</option><option value="all">{t("radar.inbox.time.all")}</option>
|
<option value="today">{t("radar.inbox.time.today")}</option><option value="7d">{t("radar.inbox.time.7d")}</option><option value="all">{t("radar.inbox.time.all")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -465,6 +558,7 @@ export function RadarOpportunitiesPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
||||||
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>{t("radar.inbox.goCrm")}</Link> : null}</div> : null}
|
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>{t("radar.inbox.goCrm")}</Link> : null}</div> : null}
|
||||||
|
|
@ -478,11 +572,12 @@ export function RadarOpportunitiesPage() {
|
||||||
{list.map((o) => <OpportunityInboxCard
|
{list.map((o) => <OpportunityInboxCard
|
||||||
key={o.id}
|
key={o.id}
|
||||||
opportunity={o}
|
opportunity={o}
|
||||||
|
simple={simple}
|
||||||
busy={busyId === o.id}
|
busy={busyId === o.id}
|
||||||
onOpen={setSelected}
|
onOpen={setSelected}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, simple ? t("today.simple.replied") : t("radar.inbox.msg.kept"))}
|
||||||
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, t("radar.inbox.msg.removed"))}
|
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, simple ? t("today.simple.skipped") : t("radar.inbox.msg.removed"))}
|
||||||
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
|
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
|
||||||
/>)}
|
/>)}
|
||||||
{total > PAGE_SIZE ? (
|
{total > PAGE_SIZE ? (
|
||||||
|
|
@ -500,6 +595,8 @@ export function RadarOpportunitiesPage() {
|
||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onAccept={(item) => void acceptOpportunity(item)}
|
onAccept={(item) => void acceptOpportunity(item)}
|
||||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
||||||
|
onSetPrimary={simple ? undefined : (item, productId, reason) => void setPrimary(item, productId, reason)}
|
||||||
|
onOverrideBand={simple ? undefined : (item, band) => void overrideBand(item, band)}
|
||||||
/> : null}
|
/> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { KEYS } from "../data/mock/keys";
|
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
|
||||||
import { translate } from "../lib/i18n/messages";
|
|
||||||
import type { Opportunity, RadarToday } from "../domain/types";
|
|
||||||
import { RadarTodayPage } from "./RadarTodayPage";
|
|
||||||
|
|
||||||
const t = (key: string, params?: Record<string, string | number>) => translate("zh-TW", key, params);
|
|
||||||
|
|
||||||
const backend = vi.hoisted(() => ({
|
|
||||||
today: null as RadarToday | null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function sampleOpp(id: string, band: "high" | "mid" | "low", score: number): Opportunity {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
source: "threads",
|
|
||||||
external_id: id,
|
|
||||||
permalink: `https://example.com/${id}`,
|
|
||||||
author_handle: "seeker",
|
|
||||||
text: `商機內文 ${id}`,
|
|
||||||
posted_at: Date.now() * 1e6,
|
|
||||||
status: "qualified",
|
|
||||||
intent_score: score,
|
|
||||||
intent_band: band,
|
|
||||||
reasons: [
|
|
||||||
{ dimension: "authenticity", score: 20, reason: "真" },
|
|
||||||
{ dimension: "intent", score: 20, reason: "想買" },
|
|
||||||
{ dimension: "region", score: 10, reason: "地區" },
|
|
||||||
{ dimension: "freshness", score: 10, reason: "新" },
|
|
||||||
{ dimension: "fit", score: 10, reason: "合" },
|
|
||||||
],
|
|
||||||
region_match: "unknown",
|
|
||||||
freshness_hours: 2,
|
|
||||||
matched_terms: ["水電"],
|
|
||||||
created_at: Date.now() * 1e6,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("../data/DataContext", () => ({
|
|
||||||
useRepos: () => ({
|
|
||||||
accounts: {
|
|
||||||
async list() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
radar: {
|
|
||||||
async getToday() {
|
|
||||||
if (!backend.today) throw new Error("no today");
|
|
||||||
return backend.today;
|
|
||||||
},
|
|
||||||
async acceptOpportunity() {
|
|
||||||
return { opportunity_id: "x", status: "accepted" };
|
|
||||||
},
|
|
||||||
async dismissOpportunity(id: string) {
|
|
||||||
return sampleOpp(id, "high", 80);
|
|
||||||
},
|
|
||||||
async createReply() {
|
|
||||||
return {
|
|
||||||
id: "r1",
|
|
||||||
opportunity_id: "h1",
|
|
||||||
variant: "public_comment" as const,
|
|
||||||
text: "草稿",
|
|
||||||
created_at: 1,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async markReplyUsed() {
|
|
||||||
return {
|
|
||||||
reply: {
|
|
||||||
id: "r1",
|
|
||||||
opportunity_id: "h1",
|
|
||||||
variant: "public_comment" as const,
|
|
||||||
text: "草稿",
|
|
||||||
used_at: 2,
|
|
||||||
sent_channel: "manual_copy" as const,
|
|
||||||
created_at: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async overrideOpportunity(id: string) {
|
|
||||||
return sampleOpp(id, "mid", 60);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderPage() {
|
|
||||||
return render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<I18nProvider>
|
|
||||||
<RadarTodayPage />
|
|
||||||
</I18nProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RadarTodayPage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
backend.today = null;
|
|
||||||
localStorage.setItem(
|
|
||||||
KEYS.uiPrefs,
|
|
||||||
JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows empty reason and CTA when total is 0", async () => {
|
|
||||||
backend.today = {
|
|
||||||
stats: { total: 0, high: 0, mid: 0, low: 0 },
|
|
||||||
high: [],
|
|
||||||
mid: [],
|
|
||||||
low: [],
|
|
||||||
truncated_count: 0,
|
|
||||||
empty_reason: "no_watch",
|
|
||||||
empty_hint: "建立訂閱",
|
|
||||||
};
|
|
||||||
renderPage();
|
|
||||||
expect(await screen.findByText("建立訂閱")).toBeTruthy();
|
|
||||||
expect(screen.getByRole("link", { name: t("radar.today.empty.goWatches") })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups high/mid and collapses low by default", async () => {
|
|
||||||
backend.today = {
|
|
||||||
stats: { total: 3, high: 1, mid: 1, low: 1 },
|
|
||||||
high: [sampleOpp("h1", "high", 90)],
|
|
||||||
mid: [sampleOpp("m1", "mid", 60)],
|
|
||||||
low: [sampleOpp("l1", "low", 30)],
|
|
||||||
truncated_count: 2,
|
|
||||||
};
|
|
||||||
renderPage();
|
|
||||||
expect(await screen.findByText("商機內文 h1")).toBeTruthy();
|
|
||||||
expect(screen.getByText("商機內文 m1")).toBeTruthy();
|
|
||||||
expect(screen.queryByText("商機內文 l1")).toBeNull();
|
|
||||||
expect(screen.getByText(t("radar.today.truncated", { n: 2 }), { exact: false })).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /低意向/ }));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("商機內文 l1")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,674 +0,0 @@
|
||||||
/**
|
|
||||||
* 今日商機頁(demand-radar T551/T552)。
|
|
||||||
* 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 empty_reason 與下一步。
|
|
||||||
*/
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
|
||||||
import { ExplorePanel } from "../components/radar/ExplorePanel";
|
|
||||||
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
|
|
||||||
import { Badge, Button, EmptyState, Select } from "../components/ui";
|
|
||||||
import type { BadgeTone } from "../components/ui";
|
|
||||||
import { useRepos } from "../data/DataContext";
|
|
||||||
import type {
|
|
||||||
Brand,
|
|
||||||
BrandProduct,
|
|
||||||
IntentBand,
|
|
||||||
Opportunity,
|
|
||||||
RadarEmptyReason,
|
|
||||||
RadarToday,
|
|
||||||
ReplyVariant,
|
|
||||||
ReplyVariantKind,
|
|
||||||
ThreadsAccount,
|
|
||||||
} from "../domain/types";
|
|
||||||
import { ProductMatchDetails } from "../components/radar/ProductMatchDetails";
|
|
||||||
import { PrimaryProductPicker } from "../components/radar/PrimaryProductPicker";
|
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
|
||||||
import { formatTimeAgo } from "../lib/time";
|
|
||||||
import "../styles/radar.css";
|
|
||||||
|
|
||||||
const REPLY_VARIANTS: ReplyVariantKind[] = [
|
|
||||||
"public_comment",
|
|
||||||
"dm",
|
|
||||||
"no_sales",
|
|
||||||
"professional",
|
|
||||||
"humorous",
|
|
||||||
];
|
|
||||||
|
|
||||||
function bandTone(band: string): BadgeTone {
|
|
||||||
if (band === "high") return "success";
|
|
||||||
if (band === "mid") return "warning";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTone(status: string): BadgeTone {
|
|
||||||
if (status === "accepted") return "brand";
|
|
||||||
if (status === "dismissed") return "neutral";
|
|
||||||
return "success";
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey?: string; label?: string } | null {
|
|
||||||
switch (reason) {
|
|
||||||
case "no_profile":
|
|
||||||
return { to: "/app/brands", labelKey: "radar.today.empty.goBrands" };
|
|
||||||
case "no_watch":
|
|
||||||
case "all_watches_paused":
|
|
||||||
case "not_swept_yet":
|
|
||||||
case "sweep_failed":
|
|
||||||
case "no_hit":
|
|
||||||
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
|
|
||||||
case "no_eligible_product_match":
|
|
||||||
return { to: "/app/radar/opportunities", labelKey: "radar.today.empty.goAll" };
|
|
||||||
default:
|
|
||||||
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function OppCard({
|
|
||||||
o,
|
|
||||||
reply,
|
|
||||||
busy,
|
|
||||||
canSendOutbox,
|
|
||||||
onAccept,
|
|
||||||
onDismiss,
|
|
||||||
onGenerateReply,
|
|
||||||
onCopyReply,
|
|
||||||
onMarkUsed,
|
|
||||||
onOverrideBand,
|
|
||||||
onSetPrimary,
|
|
||||||
}: {
|
|
||||||
o: Opportunity;
|
|
||||||
reply?: ReplyVariant;
|
|
||||||
busy: string;
|
|
||||||
/** false=沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */
|
|
||||||
canSendOutbox: boolean;
|
|
||||||
onAccept: () => void;
|
|
||||||
onDismiss: () => void;
|
|
||||||
onGenerateReply: (variant: ReplyVariantKind) => void;
|
|
||||||
onCopyReply: () => void;
|
|
||||||
onMarkUsed: (channel: "outbox" | "manual_copy") => void;
|
|
||||||
onOverrideBand: (band: IntentBand) => void;
|
|
||||||
onSetPrimary: (productId: string, reason: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [reasonsOpen, setReasonsOpen] = useState(false);
|
|
||||||
const [replyOpen, setReplyOpen] = useState(Boolean(reply?.text || o.default_reply?.text));
|
|
||||||
const [overrideOpen, setOverrideOpen] = useState(false);
|
|
||||||
const [productsOpen, setProductsOpen] = useState(false);
|
|
||||||
|
|
||||||
const activeReply = reply || o.default_reply;
|
|
||||||
const shownReply = activeReply?.text;
|
|
||||||
const isDm = activeReply?.variant === "dm";
|
|
||||||
const isDone = o.status === "accepted" || o.status === "dismissed";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={`hb-opp-card hb-opp-card--${o.intent_band}`}>
|
|
||||||
<header className="hb-opp-card__head">
|
|
||||||
<div className="hb-opp-card__meta">
|
|
||||||
<Badge tone={bandTone(o.intent_band)}>
|
|
||||||
{t(`radar.today.band.${o.intent_band}`)} · {o.intent_score}
|
|
||||||
</Badge>
|
|
||||||
{o.status !== "qualified" ? (
|
|
||||||
<Badge tone={statusTone(o.status)}>{t(`radar.today.status.${o.status}`)}</Badge>
|
|
||||||
) : null}
|
|
||||||
{o.region_detected ? (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
o.region_match === "mismatch"
|
|
||||||
? "hb-opp-region--mismatch"
|
|
||||||
: o.region_match === "unknown"
|
|
||||||
? "hb-opp-region--unknown"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{o.region_detected}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="hb-opp-region--unknown">{t("radar.today.regionUnknown")}</span>
|
|
||||||
)}
|
|
||||||
<span className="radar-card__meta">{formatTimeAgo(o.posted_at)}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p className="hb-opp-card__text">{o.text}</p>
|
|
||||||
<p className="radar-card__meta">
|
|
||||||
@{o.author_handle}
|
|
||||||
{o.matched_service ? ` · ${o.matched_service}` : ""}
|
|
||||||
{o.matched_terms?.length ? ` · ${o.matched_terms.join("、")}` : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{o.product_matches?.length ? (
|
|
||||||
<div className="hb-opp-products">
|
|
||||||
<div className="hb-opp-products__summary">
|
|
||||||
<div className="hb-opp-products__primary">
|
|
||||||
<span className="hb-opp-products__eyebrow">{t("radar.today.productEyebrow")}</span>
|
|
||||||
<strong>{o.primary_product_label || t("radar.today.noPrimary")}</strong>
|
|
||||||
{o.primary_product_fit_score != null ? <Badge tone="brand">{t("radar.today.fitScore", { n: o.primary_product_fit_score })}</Badge> : null}
|
|
||||||
{o.primary_product_overridden ? <Badge tone="warning">{t("radar.today.overridden")}</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
|
|
||||||
{productsOpen ? t("radar.today.hideEvidence") : t("radar.today.showMatches", { n: o.product_matches.length })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{productsOpen ? (
|
|
||||||
<div className="hb-opp-products__details">
|
|
||||||
{o.product_matches.map((m) => <ProductMatchDetails key={m.product_id} match={m} />)}
|
|
||||||
<PrimaryProductPicker matches={o.product_matches} currentId={o.primary_product_id} busy={busy === `primary-${o.id}`} onSet={onSetPrimary} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : <span className="hb-radar-section__hint">{t("radar.today.genericJudge")}</span>}
|
|
||||||
|
|
||||||
{shownReply ? (
|
|
||||||
<pre className="radar-card__reply">{shownReply}</pre>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{reasonsOpen ? (
|
|
||||||
<ul className="hb-opp-reasons">
|
|
||||||
{(o.reasons ?? []).map((r) => (
|
|
||||||
<li key={r.dimension} className="hb-opp-reason">
|
|
||||||
<strong>{t(`radar.today.dim.${r.dimension}`)}</strong>
|
|
||||||
<span>{r.score}</span>
|
|
||||||
<span>{r.reason}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{replyOpen ? (
|
|
||||||
<div className="hb-reply-variants">
|
|
||||||
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
|
|
||||||
<div className="hb-radar-actions">
|
|
||||||
{REPLY_VARIANTS.map((v) => (
|
|
||||||
<Button
|
|
||||||
key={v}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={busy === `reply-${o.id}`}
|
|
||||||
onClick={() => onGenerateReply(v)}
|
|
||||||
>
|
|
||||||
{t(`radar.today.reply.variant.${v}`)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{shownReply ? (
|
|
||||||
<Button type="button" variant="ghost" onClick={onCopyReply}>
|
|
||||||
{t("radar.today.reply.copy")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{activeReply && !activeReply.used_at ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={busy === `mark-${o.id}`}
|
|
||||||
onClick={() => onMarkUsed("manual_copy")}
|
|
||||||
>
|
|
||||||
{t("radar.today.reply.markCopy")}
|
|
||||||
</Button>
|
|
||||||
{!isDm ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={busy === `mark-${o.id}` || !canSendOutbox}
|
|
||||||
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
|
|
||||||
onClick={() => onMarkUsed("outbox")}
|
|
||||||
>
|
|
||||||
{t("radar.today.reply.markOutbox")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!isDm && !canSendOutbox && activeReply && !activeReply.used_at ? (
|
|
||||||
<span className="hb-radar-section__hint">
|
|
||||||
{t("radar.today.reply.needAccount")}{" "}
|
|
||||||
<Link to="/app/crew">{t("nav.crew")}</Link>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{activeReply?.used_at ? (
|
|
||||||
<span className="hb-radar-section__hint">
|
|
||||||
{activeReply.sent_channel === "outbox" && activeReply.outbox_id
|
|
||||||
? t("radar.today.reply.usedOutbox")
|
|
||||||
: t("radar.today.reply.used")}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{overrideOpen ? (
|
|
||||||
<div className="hb-radar-actions">
|
|
||||||
{(["high", "mid", "low"] as IntentBand[]).map((b) => (
|
|
||||||
<Button
|
|
||||||
key={b}
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={busy === `override-${o.id}` || o.intent_band === b}
|
|
||||||
onClick={() => onOverrideBand(b)}
|
|
||||||
>
|
|
||||||
{t(`radar.today.band.${b}`)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="radar-card__actions">
|
|
||||||
{!isDone ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={busy === `accept-${o.id}`}
|
|
||||||
onClick={onAccept}
|
|
||||||
>
|
|
||||||
{t("radar.today.action.accept")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={busy === `dismiss-${o.id}`}
|
|
||||||
onClick={onDismiss}
|
|
||||||
>
|
|
||||||
{t("radar.today.action.dismiss")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<a className="hb-btn hb-btn--secondary" href={o.permalink} target="_blank" rel="noreferrer">
|
|
||||||
{t("radar.today.action.open")}
|
|
||||||
</a>
|
|
||||||
<div className="hb-opp-card__secondary-actions">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => setReplyOpen((v) => !v)}>
|
|
||||||
{replyOpen ? t("radar.today.action.hideReply") : t("radar.today.action.reply")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" onClick={() => setReasonsOpen((v) => !v)}>
|
|
||||||
{reasonsOpen ? t("radar.today.action.hideReasons") : t("radar.today.action.reasons")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" onClick={() => setOverrideOpen((v) => !v)}>
|
|
||||||
{t("radar.today.action.override")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RadarTodayPage() {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const repos = useRepos();
|
|
||||||
const formatErr = useFormatApiError();
|
|
||||||
const [urlParams, setUrlParams] = useSearchParams();
|
|
||||||
const [data, setData] = useState<RadarToday | null>(null);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [lowOpen, setLowOpen] = useState(false);
|
|
||||||
const [busy, setBusy] = useState("");
|
|
||||||
const [replies, setReplies] = useState<Record<string, ReplyVariant>>({});
|
|
||||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
|
||||||
const [sendAccountId, setSendAccountId] = useState("");
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [exploreOpen, setExploreOpen] = useState(false);
|
|
||||||
const [filterBrand, setFilterBrand] = useState(urlParams.get("brand_id") || "");
|
|
||||||
const [filterProduct, setFilterProduct] = useState(urlParams.get("product_id") || "");
|
|
||||||
const [filterBand, setFilterBand] = useState(urlParams.get("fit_band") || "");
|
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
|
||||||
const [products, setProducts] = useState<BrandProduct[]>([]);
|
|
||||||
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scout) return;
|
|
||||||
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
|
|
||||||
}, [scout]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scout || !filterBrand) { setProducts([]); return; }
|
|
||||||
void scout.listProducts(filterBrand).then(setProducts).catch(() => setProducts([]));
|
|
||||||
}, [scout, filterBrand]);
|
|
||||||
|
|
||||||
function updateFilterParam(name: string, value: string) {
|
|
||||||
const next = new URLSearchParams(urlParams);
|
|
||||||
if (value) next.set(name, value); else next.delete(name);
|
|
||||||
setUrlParams(next, { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
const next = await repos.radar.getToday({ brand_id: filterBrand || undefined, product_id: filterProduct || undefined, fit_band: filterBand || undefined });
|
|
||||||
setData(next);
|
|
||||||
}, [repos.radar, filterBrand, filterProduct, filterBand]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
setLoading(true);
|
|
||||||
load()
|
|
||||||
.then(() => {
|
|
||||||
if (alive) setErr(null);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (alive) setErr(formatErr(e));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (alive) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [load]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// 一鍵送出要知道用哪個帳號;多數人只有一個已連帳號,預設選第一個可用的即可。
|
|
||||||
useEffect(() => {
|
|
||||||
void repos.accounts
|
|
||||||
.list()
|
|
||||||
.then((list) => {
|
|
||||||
const usable = list.filter((a) => a.is_usable);
|
|
||||||
setAccounts(usable);
|
|
||||||
setSendAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || ""));
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, [repos.accounts]);
|
|
||||||
|
|
||||||
async function run(key: string, action: () => Promise<void>, okMessage?: string) {
|
|
||||||
setBusy(key);
|
|
||||||
setErr(null);
|
|
||||||
setMsg(null);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
if (okMessage) setMsg(okMessage);
|
|
||||||
} catch (e) {
|
|
||||||
setErr(formatErr(e));
|
|
||||||
} finally {
|
|
||||||
setBusy("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function accept(id: string) {
|
|
||||||
await run(`accept-${id}`, async () => {
|
|
||||||
await repos.radar.acceptOpportunity(id);
|
|
||||||
await load();
|
|
||||||
}, t("radar.today.msg.accepted"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dismiss(id: string) {
|
|
||||||
await run(`dismiss-${id}`, async () => {
|
|
||||||
await repos.radar.dismissOpportunity(id);
|
|
||||||
await load();
|
|
||||||
}, t("radar.today.msg.dismissed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateReply(id: string, variant: ReplyVariantKind) {
|
|
||||||
await run(`reply-${id}`, async () => {
|
|
||||||
const r = await repos.radar.createReply(id, variant);
|
|
||||||
setReplies((m) => ({ ...m, [id]: r }));
|
|
||||||
}, t("radar.today.msg.replyReady"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markUsed(id: string, channel: "outbox" | "manual_copy") {
|
|
||||||
const r = replies[id] || data?.high.concat(data.mid, data.low).find((o) => o.id === id)?.default_reply;
|
|
||||||
if (!r?.id) {
|
|
||||||
setErr(t("radar.today.msg.needReply"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await run(`mark-${id}`, async () => {
|
|
||||||
const res = await repos.radar.markReplyUsed(
|
|
||||||
id,
|
|
||||||
r.id,
|
|
||||||
channel,
|
|
||||||
channel === "outbox" ? sendAccountId : undefined,
|
|
||||||
);
|
|
||||||
setReplies((m) => ({ ...m, [id]: res.reply }));
|
|
||||||
if (res.health_advice) setMsg(res.health_advice);
|
|
||||||
}, channel === "outbox" ? t("radar.today.msg.sent") : t("radar.today.msg.marked"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function overrideBand(id: string, band: IntentBand) {
|
|
||||||
await run(`override-${id}`, async () => {
|
|
||||||
await repos.radar.overrideOpportunity(id, { band });
|
|
||||||
await load();
|
|
||||||
}, t("radar.today.msg.overridden"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setPrimary(id: string, productId: string, reason: string) {
|
|
||||||
await run(`primary-${id}`, async () => {
|
|
||||||
await repos.radar.setPrimaryProduct(id, productId, reason);
|
|
||||||
await load();
|
|
||||||
}, t("radar.today.msg.primarySet"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyReply(text: string) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setMsg(t("radar.today.msg.copied"));
|
|
||||||
} catch {
|
|
||||||
setErr(t("radar.today.msg.copyFail"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = data ? emptyAction(data.empty_reason) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title={t("radar.today.title")} />
|
|
||||||
|
|
||||||
<section className="hb-radar-intro">
|
|
||||||
<div>
|
|
||||||
<strong>{t("radar.today.introTitle")}</strong>
|
|
||||||
<p>{t("radar.today.introBody")}</p>
|
|
||||||
</div>
|
|
||||||
<nav className="hb-radar-intro__actions" aria-label={t("radar.today.navAria")}>
|
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.today.manageWatches")}</Link>
|
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("radar.today.viewAll")}</Link>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="hb-radar-filter-panel" aria-label={t("radar.today.filterAria")}>
|
|
||||||
<div className="hb-radar-filter-panel__head">
|
|
||||||
<strong>{t("radar.today.filterTitle")}</strong>
|
|
||||||
<span>{t("radar.today.filterHint")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-radar-filter-grid">
|
|
||||||
{brands.length ? <Select name="radar-filter-brand" label={t("radar.inbox.brand")} value={filterBrand} onChange={(e) => { const value = e.target.value; setFilterBrand(value); setFilterProduct(""); const next = new URLSearchParams(urlParams); if (value) next.set("brand_id", value); else next.delete("brand_id"); next.delete("product_id"); setUrlParams(next, { replace: true }); }}><option value="">{t("radar.inbox.allBrands")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</Select> : null}
|
|
||||||
{products.length ? <Select name="radar-filter-product" label={t("radar.inbox.product")} value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">{t("radar.inbox.allProducts")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
|
|
||||||
<Select name="radar-filter-fit" label={t("radar.today.fit")} value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">{t("radar.today.allFit")}</option><option value="strong">{t("radar.today.fit.strong")}</option><option value="possible">{t("radar.today.fit.possible")}</option><option value="weak">{t("radar.today.fit.weak")}</option></Select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="hb-radar-utility-actions">
|
|
||||||
<span>{t("radar.today.needMore")}</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setExploreOpen((v) => !v);
|
|
||||||
if (!exploreOpen) setImportOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setImportOpen((v) => !v);
|
|
||||||
if (!importOpen) setExploreOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{importOpen ? t("radar.import.close") : t("radar.import.open")}
|
|
||||||
</Button>
|
|
||||||
<Link className="hb-btn hb-btn--ghost" to="/app/crm">
|
|
||||||
{t("radar.today.link.crm")}
|
|
||||||
</Link>
|
|
||||||
{accounts.length > 1 ? (
|
|
||||||
<Select
|
|
||||||
name="radar-send-account"
|
|
||||||
label={t("radar.today.sendAccount")}
|
|
||||||
value={sendAccountId}
|
|
||||||
onChange={(e) => setSendAccountId(e.target.value)}
|
|
||||||
>
|
|
||||||
{accounts.map((a) => (
|
|
||||||
<option key={a.id} value={a.id}>
|
|
||||||
@{a.username}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exploreOpen ? <ExplorePanel onExplored={() => void load()} /> : null}
|
|
||||||
{importOpen ? <ManualImportPanel onImported={() => void load()} /> : null}
|
|
||||||
|
|
||||||
{err ? (
|
|
||||||
<p className="hb-banner-error" role="alert">
|
|
||||||
{err}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{msg ? (
|
|
||||||
<p className="hb-banner-ok" role="status">
|
|
||||||
{msg}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loading && !data ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
|
|
||||||
|
|
||||||
{data ? (
|
|
||||||
<section className="hb-radar-page">
|
|
||||||
<div className="hb-radar-stats">
|
|
||||||
<div className="hb-radar-stat">
|
|
||||||
<span className="hb-radar-stat__value">{data.stats.total}</span>
|
|
||||||
<span className="hb-radar-stat__label">{t("radar.today.stats.total")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-radar-stat">
|
|
||||||
<span className="hb-radar-stat__value">{data.stats.high}</span>
|
|
||||||
<span className="hb-radar-stat__label">{t("radar.today.stats.high")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-radar-stat">
|
|
||||||
<span className="hb-radar-stat__value">{data.stats.mid}</span>
|
|
||||||
<span className="hb-radar-stat__label">{t("radar.today.stats.mid")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-radar-stat">
|
|
||||||
<span className="hb-radar-stat__value">{data.stats.low}</span>
|
|
||||||
<span className="hb-radar-stat__label">{t("radar.today.stats.low")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.truncated_count > 0 ? (
|
|
||||||
<p className="hb-radar-quota hb-radar-quota--full" role="status">
|
|
||||||
{t("radar.today.truncated", { n: data.truncated_count })}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data.last_swept_at ? (
|
|
||||||
<p className="hb-radar-section__hint">
|
|
||||||
{t("radar.today.lastSwept", { at: formatTimeAgo(data.last_swept_at) })}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data.stats.total === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={data.empty_hint || t("radar.today.empty.title")}
|
|
||||||
description={
|
|
||||||
data.empty_reason
|
|
||||||
? t(`radar.today.empty.reason.${data.empty_reason}`)
|
|
||||||
: t("radar.today.empty.fallback")
|
|
||||||
}
|
|
||||||
action={
|
|
||||||
action ? (
|
|
||||||
<Link className="hb-btn hb-btn--secondary" to={action.to}>
|
|
||||||
{action.labelKey ? t(action.labelKey) : action.label}
|
|
||||||
</Link>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="hb-radar-section">
|
|
||||||
<h2 className="hb-radar-section__title">
|
|
||||||
{t("radar.today.group.high")} ({data.high.length})
|
|
||||||
</h2>
|
|
||||||
{data.high.length === 0 ? (
|
|
||||||
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
|
|
||||||
) : (
|
|
||||||
data.high.map((o) => (
|
|
||||||
<OppCard
|
|
||||||
key={o.id}
|
|
||||||
o={o}
|
|
||||||
reply={replies[o.id]}
|
|
||||||
busy={busy}
|
|
||||||
canSendOutbox={Boolean(sendAccountId)}
|
|
||||||
onAccept={() => void accept(o.id)}
|
|
||||||
onDismiss={() => void dismiss(o.id)}
|
|
||||||
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
||||||
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
||||||
onCopyReply={() =>
|
|
||||||
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
||||||
}
|
|
||||||
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
||||||
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hb-radar-section">
|
|
||||||
<h2 className="hb-radar-section__title">
|
|
||||||
{t("radar.today.group.mid")} ({data.mid.length})
|
|
||||||
</h2>
|
|
||||||
{data.mid.length === 0 ? (
|
|
||||||
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
|
|
||||||
) : (
|
|
||||||
data.mid.map((o) => (
|
|
||||||
<OppCard
|
|
||||||
key={o.id}
|
|
||||||
o={o}
|
|
||||||
reply={replies[o.id]}
|
|
||||||
busy={busy}
|
|
||||||
canSendOutbox={Boolean(sendAccountId)}
|
|
||||||
onAccept={() => void accept(o.id)}
|
|
||||||
onDismiss={() => void dismiss(o.id)}
|
|
||||||
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
||||||
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
||||||
onCopyReply={() =>
|
|
||||||
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
||||||
}
|
|
||||||
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
||||||
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hb-radar-section">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hb-btn hb-btn--ghost"
|
|
||||||
onClick={() => setLowOpen((v) => !v)}
|
|
||||||
aria-expanded={lowOpen}
|
|
||||||
>
|
|
||||||
{t("radar.today.group.low")} ({data.low.length}) ·{" "}
|
|
||||||
{lowOpen ? t("radar.today.group.collapse") : t("radar.today.group.expand")}
|
|
||||||
</button>
|
|
||||||
{lowOpen
|
|
||||||
? data.low.map((o) => (
|
|
||||||
<OppCard
|
|
||||||
key={o.id}
|
|
||||||
o={o}
|
|
||||||
reply={replies[o.id]}
|
|
||||||
busy={busy}
|
|
||||||
canSendOutbox={Boolean(sendAccountId)}
|
|
||||||
onAccept={() => void accept(o.id)}
|
|
||||||
onDismiss={() => void dismiss(o.id)}
|
|
||||||
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
||||||
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
||||||
onCopyReply={() =>
|
|
||||||
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
||||||
}
|
|
||||||
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
||||||
onSetPrimary={(id, reason) => void setPrimary(o.id, id, reason)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { KEYS } from "../data/mock/keys";
|
||||||
|
import { I18nProvider } from "../i18n/I18nContext";
|
||||||
|
import { translate } from "../lib/i18n/messages";
|
||||||
|
import type { RadarWatch } from "../domain/types";
|
||||||
|
import { RadarWatchesPage } from "./RadarWatchesPage";
|
||||||
|
|
||||||
|
const t = (key: string, params?: Record<string, string | number>) => translate("zh-TW", key, params);
|
||||||
|
|
||||||
|
const backend = vi.hoisted(() => ({
|
||||||
|
watches: [] as RadarWatch[],
|
||||||
|
assigned: [] as Array<{ id: string; brandId: string; productId: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../data/DataContext", () => {
|
||||||
|
const repos = {
|
||||||
|
scout: {
|
||||||
|
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
||||||
|
async listProducts() {
|
||||||
|
return [{
|
||||||
|
id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "",
|
||||||
|
match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [],
|
||||||
|
created_at: 1, updated_at: 1,
|
||||||
|
}];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
radar: {
|
||||||
|
async listWatches() {
|
||||||
|
return {
|
||||||
|
list: backend.watches,
|
||||||
|
total: backend.watches.length,
|
||||||
|
active_count: backend.watches.filter((w) => w.status === "active").length,
|
||||||
|
max_active: 5,
|
||||||
|
profile_exists: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async updateWatch(id: string, patch: Record<string, unknown>) {
|
||||||
|
const w = backend.watches.find((x) => x.id === id)!;
|
||||||
|
Object.assign(w, patch);
|
||||||
|
return w;
|
||||||
|
},
|
||||||
|
async assignWatchProduct(id: string, brandId: string, productId: string) {
|
||||||
|
backend.assigned.push({ id, brandId, productId });
|
||||||
|
const w = backend.watches.find((x) => x.id === id)!;
|
||||||
|
Object.assign(w, { brand_id: brandId, product_id: productId, context_mode: "product" });
|
||||||
|
return w;
|
||||||
|
},
|
||||||
|
async getDemandMap() {
|
||||||
|
return { product_id: "p1", state: "empty", pains: [], scenarios: [], audiences: [], exclusions: [], updated_at: 1 };
|
||||||
|
},
|
||||||
|
async suggestWatchTerms() { return []; },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { useRepos: () => repos };
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<I18nProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<RadarWatchesPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||||
|
backend.assigned = [];
|
||||||
|
backend.watches = [{
|
||||||
|
id: "w1",
|
||||||
|
terms: ["敏感肌"],
|
||||||
|
exclude_terms: [],
|
||||||
|
regions: [],
|
||||||
|
status: "active",
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 1,
|
||||||
|
} as RadarWatch];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("舊訂閱補綁品牌與產品", () => {
|
||||||
|
it("選了品牌之後,產品選單不能跟著消失", async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: t("common.edit") }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(t("radar.inbox.brand"), { exact: false }), { target: { value: "b1" } });
|
||||||
|
|
||||||
|
const product = await screen.findByLabelText(t("radar.inbox.product"), { exact: false });
|
||||||
|
expect(product).toBeTruthy();
|
||||||
|
await waitFor(() => expect(screen.getByRole("option", { name: "舒緩精華" })).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("儲存時把補綁的品牌與產品一起寫回", async () => {
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: t("common.edit") }));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(t("radar.inbox.brand"), { exact: false }), { target: { value: "b1" } });
|
||||||
|
await waitFor(() => expect(screen.getByRole("option", { name: "舒緩精華" })).toBeTruthy());
|
||||||
|
fireEvent.change(screen.getByLabelText(t("radar.inbox.product"), { exact: false }), { target: { value: "p1" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: t("common.save") }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(backend.assigned).toEqual([{ id: "w1", brandId: "b1", productId: "p1" }]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,9 @@
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { Repos } from "../data/repos";
|
|
||||||
import { I18nProvider } from "../i18n/I18nContext";
|
|
||||||
import { TodayPage } from "./TodayPage";
|
import { TodayPage } from "./TodayPage";
|
||||||
|
|
||||||
const harness = vi.hoisted(() => ({
|
describe("TodayPage", () => {
|
||||||
repos: null as Repos | null,
|
it("is the demand inbox, not the old dashboard", () => {
|
||||||
legacyListPostsCalls: 0,
|
expect(TodayPage).toBe(RadarOpportunitiesPage);
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../data/DataContext", () => ({
|
|
||||||
useRepos: () => harness.repos,
|
|
||||||
useData: () => ({ repos: harness.repos, tick: 0, refresh: () => {} }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function buildRepos(): Repos {
|
|
||||||
return {
|
|
||||||
scout: {
|
|
||||||
async listPosts() {
|
|
||||||
harness.legacyListPostsCalls += 1;
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
async listRuns() { return { list: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } }; },
|
|
||||||
async listRunPosts() { throw new Error("not used"); },
|
|
||||||
},
|
|
||||||
accounts: { async list() { return []; } },
|
|
||||||
outbox: { async list() { return []; } },
|
|
||||||
inspiration: {
|
|
||||||
async listTrends() { return []; },
|
|
||||||
async refreshTrends() { return []; },
|
|
||||||
},
|
|
||||||
ownPosts: { async list() { return []; }, async sync() { return []; } },
|
|
||||||
mentions: { async list() { return []; } },
|
|
||||||
growth: { async getOutcomeSummary() { return null; }, async getLatestCheckup() { return null; } },
|
|
||||||
radar: { async getToday() { return null; } },
|
|
||||||
} as unknown as Repos;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TodayPage legacy Scout compatibility", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
harness.repos = buildRepos();
|
|
||||||
harness.legacyListPostsCalls = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("continues to load the legacy listPosts endpoint", async () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<I18nProvider>
|
|
||||||
<TodayPage />
|
|
||||||
</I18nProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(harness.legacyListPostsCalls).toBeGreaterThan(0));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,757 +1,2 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
/** 今日=商機收件匣。舊儀表板已拆進進階頁,這裡直接開工。 */
|
||||||
import { Link } from "react-router-dom";
|
export { RadarOpportunitiesPage as TodayPage } from "./RadarOpportunitiesPage";
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
|
||||||
import { Badge, Button, Card, EmptyState } from "../components/ui";
|
|
||||||
import { useData, useRepos } from "../data/DataContext";
|
|
||||||
import type {
|
|
||||||
MentionItem,
|
|
||||||
OutcomeSummary,
|
|
||||||
OutboxBundle,
|
|
||||||
OwnPost,
|
|
||||||
RadarToday,
|
|
||||||
ScoutPost,
|
|
||||||
ThreadsAccount,
|
|
||||||
TrendItem,
|
|
||||||
WeeklyCheckup,
|
|
||||||
} from "../domain/types";
|
|
||||||
import { useFirstRun } from "../firstRun/FirstRunContext";
|
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
|
||||||
import { useFormatApiError } from "../lib/apiErrors";
|
|
||||||
import { loadScoutToday } from "../lib/scoutToday";
|
|
||||||
|
|
||||||
function isPendingScout(p: ScoutPost): boolean {
|
|
||||||
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfLocalDayNano(): number {
|
|
||||||
const d = new Date();
|
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
return d.getTime() * 1_000_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountPulse = {
|
|
||||||
account: ThreadsAccount;
|
|
||||||
posts: number;
|
|
||||||
views: number;
|
|
||||||
likes: number;
|
|
||||||
replies: number;
|
|
||||||
topInsight?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 今日儀表板:海巡待回、今日目標、發送、話題、帳號成效。
|
|
||||||
* 全部接 live repos;話題可刷新;點話題進靈感 tab。
|
|
||||||
*/
|
|
||||||
export function TodayPage() {
|
|
||||||
const repos = useRepos();
|
|
||||||
const { tick, refresh } = useData();
|
|
||||||
const { t, locale } = useI18n();
|
|
||||||
const formatApiError = useFormatApiError();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [trendMessage, setTrendMessage] = useState("");
|
|
||||||
const [refreshingTrends, setRefreshingTrends] = useState(false);
|
|
||||||
const [syncingPosts, setSyncingPosts] = useState(false);
|
|
||||||
|
|
||||||
const [scoutPosts, setScoutPosts] = useState<ScoutPost[]>([]);
|
|
||||||
const [outbox, setOutbox] = useState<OutboxBundle[]>([]);
|
|
||||||
const [trends, setTrends] = useState<TrendItem[]>([]);
|
|
||||||
const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]);
|
|
||||||
const [mentions, setMentions] = useState<MentionItem[]>([]);
|
|
||||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
|
||||||
const [scoutDone, setScoutDone] = useState(0);
|
|
||||||
const [scoutGoal, setScoutGoal] = useState(8);
|
|
||||||
const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null);
|
|
||||||
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
|
|
||||||
const [radarToday, setRadarToday] = useState<RadarToday | null>(null);
|
|
||||||
const { active: firstRun } = useFirstRun();
|
|
||||||
|
|
||||||
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
|
|
||||||
|
|
||||||
const todayLabel = new Date().toLocaleDateString(dateLocale, {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
weekday: "short",
|
|
||||||
});
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const scoutLocal = loadScoutToday();
|
|
||||||
setScoutGoal(scoutLocal.goalValue);
|
|
||||||
|
|
||||||
const [posts, box, trendList, acc, owns, mentionList, outcomes, latestCheckup, radar] =
|
|
||||||
await Promise.all([
|
|
||||||
repos.scout.listPosts(),
|
|
||||||
repos.outbox.list(),
|
|
||||||
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
|
|
||||||
repos.accounts.list(),
|
|
||||||
repos.ownPosts.list().catch(() => [] as OwnPost[]),
|
|
||||||
repos.mentions.list().catch(() => [] as MentionItem[]),
|
|
||||||
repos.growth.getOutcomeSummary("week").catch(() => null),
|
|
||||||
repos.growth.getLatestCheckup().catch(() => null),
|
|
||||||
repos.radar.getToday().catch(() => null),
|
|
||||||
]);
|
|
||||||
setOutcomeSummary(outcomes);
|
|
||||||
setCheckup(latestCheckup);
|
|
||||||
setRadarToday(radar);
|
|
||||||
|
|
||||||
const usable = acc.filter((a) => a.is_usable);
|
|
||||||
setAccounts(usable.length ? usable : acc);
|
|
||||||
setScoutPosts(posts);
|
|
||||||
setOutbox(box);
|
|
||||||
setTrends(
|
|
||||||
[...trendList]
|
|
||||||
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
|
|
||||||
.slice(0, 12),
|
|
||||||
);
|
|
||||||
setOwnPosts(owns);
|
|
||||||
setMentions(mentionList);
|
|
||||||
|
|
||||||
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
|
|
||||||
const dayStart = startOfLocalDayNano();
|
|
||||||
const publishedN = posts.filter(
|
|
||||||
(p) =>
|
|
||||||
p.outreach_status === "published" &&
|
|
||||||
p.published_at != null &&
|
|
||||||
p.published_at >= dayStart,
|
|
||||||
).length;
|
|
||||||
setScoutDone(Math.max(scoutLocal.done, publishedN));
|
|
||||||
|
|
||||||
// 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
|
|
||||||
if (!owns.length && usable.length) {
|
|
||||||
void Promise.all(
|
|
||||||
usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
|
|
||||||
).then((chunks) => setOwnPosts(chunks.flat()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mentionList.length && usable[0]?.id) {
|
|
||||||
void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setError(formatApiError(e, "today.loadFail"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [repos, formatApiError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load, tick]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onStore = () => refresh();
|
|
||||||
window.addEventListener("harbor:store", onStore);
|
|
||||||
return () => window.removeEventListener("harbor:store", onStore);
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const dayStart = startOfLocalDayNano();
|
|
||||||
|
|
||||||
const pendingScout = useMemo(
|
|
||||||
() =>
|
|
||||||
scoutPosts
|
|
||||||
.filter(isPendingScout)
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.score || 0) - (a.score || 0)),
|
|
||||||
[scoutPosts],
|
|
||||||
);
|
|
||||||
|
|
||||||
const pendingMentions = useMemo(
|
|
||||||
() => mentions.filter((m) => m.status === "pending"),
|
|
||||||
[mentions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const failedOutbox = useMemo(
|
|
||||||
() => outbox.filter((o) => o.status === "partial_failed"),
|
|
||||||
[outbox],
|
|
||||||
);
|
|
||||||
const runningOutbox = useMemo(
|
|
||||||
() => outbox.filter((o) => o.status === "scheduling" || o.status === "active"),
|
|
||||||
[outbox],
|
|
||||||
);
|
|
||||||
const sentToday = useMemo(() => {
|
|
||||||
return outbox.filter((o) => {
|
|
||||||
if (o.status !== "completed") return false;
|
|
||||||
return o.updated_at >= dayStart;
|
|
||||||
}).length;
|
|
||||||
}, [outbox, dayStart]);
|
|
||||||
|
|
||||||
const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100));
|
|
||||||
|
|
||||||
const topicCards = useMemo(() => trends.slice(0, 6), [trends]);
|
|
||||||
|
|
||||||
const accountPulses = useMemo((): AccountPulse[] => {
|
|
||||||
const byAcc = new Map<string, OwnPost[]>();
|
|
||||||
for (const p of ownPosts) {
|
|
||||||
const list = byAcc.get(p.account_id) || [];
|
|
||||||
list.push(p);
|
|
||||||
byAcc.set(p.account_id, list);
|
|
||||||
}
|
|
||||||
const rows: AccountPulse[] = [];
|
|
||||||
for (const acc of accounts) {
|
|
||||||
const posts = byAcc.get(acc.id) || [];
|
|
||||||
if (!posts.length && accounts.length > 3) continue;
|
|
||||||
const views = posts.reduce((s, p) => s + (p.view_count || 0), 0);
|
|
||||||
const likes = posts.reduce((s, p) => s + (p.like_count || 0), 0);
|
|
||||||
const replies = posts.reduce((s, p) => s + (p.reply_count || 0), 0);
|
|
||||||
const top = posts.slice().sort((a, b) => (b.view_count || 0) - (a.view_count || 0))[0];
|
|
||||||
rows.push({
|
|
||||||
account: acc,
|
|
||||||
posts: posts.length,
|
|
||||||
views,
|
|
||||||
likes,
|
|
||||||
replies,
|
|
||||||
topInsight: top?.insight || top?.formula_summary,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!rows.length) {
|
|
||||||
for (const [accId, posts] of byAcc) {
|
|
||||||
const acc =
|
|
||||||
accounts.find((a) => a.id === accId) ||
|
|
||||||
({
|
|
||||||
id: accId,
|
|
||||||
username: accId,
|
|
||||||
display_name: accId,
|
|
||||||
connection: "connected",
|
|
||||||
is_usable: true,
|
|
||||||
avatar_color: "var(--hb-muted)",
|
|
||||||
} as ThreadsAccount);
|
|
||||||
rows.push({
|
|
||||||
account: acc,
|
|
||||||
posts: posts.length,
|
|
||||||
views: posts.reduce((s, p) => s + (p.view_count || 0), 0),
|
|
||||||
likes: posts.reduce((s, p) => s + (p.like_count || 0), 0),
|
|
||||||
replies: posts.reduce((s, p) => s + (p.reply_count || 0), 0),
|
|
||||||
topInsight: posts[0]?.insight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
.sort((a, b) => b.views - a.views || b.likes - a.likes)
|
|
||||||
.slice(0, 4);
|
|
||||||
}, [accounts, ownPosts]);
|
|
||||||
|
|
||||||
const pendingPreview = pendingScout.slice(0, 5);
|
|
||||||
|
|
||||||
const hasOutcome = Boolean(
|
|
||||||
outcomeSummary &&
|
|
||||||
(outcomeSummary.reach ||
|
|
||||||
outcomeSummary.conversations ||
|
|
||||||
outcomeSummary.follows_possible ||
|
|
||||||
outcomeSummary.follows_confirmed ||
|
|
||||||
outcomeSummary.conversions),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onRefreshTrends() {
|
|
||||||
setRefreshingTrends(true);
|
|
||||||
setError("");
|
|
||||||
setTrendMessage("");
|
|
||||||
try {
|
|
||||||
const list = await repos.inspiration.refreshTrends("all");
|
|
||||||
if (!list.length) {
|
|
||||||
throw new Error(t("today.trendsFail"));
|
|
||||||
}
|
|
||||||
setTrends(
|
|
||||||
[...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
|
|
||||||
);
|
|
||||||
setTrendMessage(t("today.trendsUpdated", { n: list.length }));
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setError(formatApiError(e, "today.trendsFail"));
|
|
||||||
} finally {
|
|
||||||
setRefreshingTrends(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSyncOwnPosts() {
|
|
||||||
const acc = accounts[0];
|
|
||||||
if (!acc) {
|
|
||||||
setError(t("today.needAccount"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSyncingPosts(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const list = await repos.ownPosts.sync(acc.id);
|
|
||||||
setOwnPosts((prev) => {
|
|
||||||
const others = prev.filter((p) => p.account_id !== acc.id);
|
|
||||||
return [...list, ...others];
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setError(formatApiError(e, "today.syncPostsFail"));
|
|
||||||
} finally {
|
|
||||||
setSyncingPosts(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function topicHref(label: string) {
|
|
||||||
const q = new URLSearchParams({ tab: "inspire", topic: label });
|
|
||||||
return `/app/studio?${q.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading && !scoutPosts.length && !trends.length && !outbox.length) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title={t("nav.today")} description={todayLabel} />
|
|
||||||
<p className="text-muted">{t("common.loading")}</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title={t("nav.today")} description={todayLabel} />
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="hb-form-error" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
|
||||||
{radarToday && radarToday.stats.total > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="hb-today-metrics" role="group" aria-label={t("today.radar.title")}>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.radar.total")}</span>
|
|
||||||
<span className="hb-today-metric__value">{radarToday.stats.total}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.radar.high")}</span>
|
|
||||||
<span className="hb-today-metric__value">{radarToday.stats.high}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.radar.mid")}</span>
|
|
||||||
<span className="hb-today-metric__value">{radarToday.stats.mid}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.radar.low")}</span>
|
|
||||||
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
|
|
||||||
{t("today.radar.open")}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
{radarToday?.empty_hint || t("today.radar.empty")}{" "}
|
|
||||||
<Link
|
|
||||||
to={
|
|
||||||
radarToday?.empty_reason === "no_profile"
|
|
||||||
? "/app/brands"
|
|
||||||
: "/app/radar/watches"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{radarToday?.empty_reason === "no_profile"
|
|
||||||
? t("radar.today.empty.goBrands")
|
|
||||||
: t("today.radar.goWatches")}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{firstRun ? null : <>
|
|
||||||
<Card title={t("today.outcome.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
|
||||||
<div className="hb-today-metrics" role="group" aria-label={t("today.outcome.title")}>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.outcome.reach")}</span>
|
|
||||||
<span className="hb-today-metric__value">{outcomeSummary?.reach ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.outcome.conversations")}</span>
|
|
||||||
<span className="hb-today-metric__value">{outcomeSummary?.conversations ?? 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.outcome.follows")}</span>
|
|
||||||
<span className="hb-today-metric__value">
|
|
||||||
{outcomeSummary?.follows_possible ?? 0}
|
|
||||||
{outcomeSummary?.follows_confirmed ? (
|
|
||||||
<span className="hb-today-metric__den">
|
|
||||||
{t("today.outcome.followsConfirmedHint", { n: outcomeSummary.follows_confirmed })}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="hb-today-metric__hint">{t("today.outcome.followsHint")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.outcome.conversions")}</span>
|
|
||||||
<span className="hb-today-metric__value">{outcomeSummary?.conversions ?? 0}</span>
|
|
||||||
{outcomeSummary?.conversion_amount ? (
|
|
||||||
<span className="hb-today-metric__hint">
|
|
||||||
${Math.round(outcomeSummary.conversion_amount)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!hasOutcome ? (
|
|
||||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
{t("today.outcome.emptyHint")}{" "}
|
|
||||||
<Link to="/app/scout">{t("today.goScout")}</Link>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{checkup ? (
|
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
|
||||||
<p className="text-muted" style={{ margin: 0 }}>
|
|
||||||
{t("today.checkup.prefix")}
|
|
||||||
{checkup.summary}
|
|
||||||
</p>
|
|
||||||
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1.2rem" }}>
|
|
||||||
{checkup.actions.slice(0, 3).map((a) => (
|
|
||||||
<li key={a.title}>
|
|
||||||
<Link
|
|
||||||
to={
|
|
||||||
a.deeplink === "scout"
|
|
||||||
? "/app/scout"
|
|
||||||
: a.deeplink === "studio_compose"
|
|
||||||
? "/app/studio?tab=compose"
|
|
||||||
: a.deeplink === "studio_inspire"
|
|
||||||
? "/app/studio?tab=inspire"
|
|
||||||
: a.deeplink === "crew_persona"
|
|
||||||
? "/app/crew"
|
|
||||||
: a.deeplink === "outbox"
|
|
||||||
? "/app/outbox"
|
|
||||||
: "/app/today"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{a.title}
|
|
||||||
</Link>
|
|
||||||
<span className="text-muted"> — {a.reason}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted" style={{ margin: "0.75rem 0 0", fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
{t("today.checkup.empty")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="hb-today-actions">
|
|
||||||
<Link to="/app/scout">
|
|
||||||
<Button type="button" variant={pendingScout.length ? "primary" : "ghost"}>
|
|
||||||
{pendingScout.length
|
|
||||||
? t("today.pendingRepliesN", { n: pendingScout.length })
|
|
||||||
: t("today.pendingReplies")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link to="/app/studio?tab=compose">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.newThread")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button type="button" variant="ghost" onClick={() => void load()} disabled={loading}>
|
|
||||||
{t("today.reload")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 數值列 */}
|
|
||||||
<div className="hb-today-metrics" role="group" aria-label={t("today.metricsAria")}>
|
|
||||||
<Link to="/app/scout" className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.metric.pending")}</span>
|
|
||||||
<span className="hb-today-metric__value">{pendingScout.length}</span>
|
|
||||||
<span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span>
|
|
||||||
</Link>
|
|
||||||
<div className="hb-today-metric" title={t("today.metric.doneGoalHint")}>
|
|
||||||
<span className="hb-today-metric__label">{t("today.metric.doneGoal")}</span>
|
|
||||||
<span className="hb-today-metric__value">
|
|
||||||
{scoutDone}
|
|
||||||
<span className="hb-today-metric__den">/{scoutGoal}</span>
|
|
||||||
</span>
|
|
||||||
<div className="hb-progress hb-progress--sm" aria-hidden>
|
|
||||||
<div className="hb-progress__bar" style={{ width: `${goalPct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link to="/app/outbox" className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.metric.sentToday")}</span>
|
|
||||||
<span className="hb-today-metric__value">{sentToday}</span>
|
|
||||||
<span className="hb-today-metric__hint">
|
|
||||||
{runningOutbox.length
|
|
||||||
? t("today.metric.running", { n: runningOutbox.length })
|
|
||||||
: t("today.metric.sentDone")}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/app/outbox"
|
|
||||||
className={`hb-today-metric${failedOutbox.length ? " is-alert" : ""}`}
|
|
||||||
>
|
|
||||||
<span className="hb-today-metric__label">{t("today.metric.failed")}</span>
|
|
||||||
<span className="hb-today-metric__value">{failedOutbox.length}</span>
|
|
||||||
<span className="hb-today-metric__hint">
|
|
||||||
{failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/app/studio?tab=mentions" className="hb-today-metric">
|
|
||||||
<span className="hb-today-metric__label">{t("today.metric.mentions")}</span>
|
|
||||||
<span className="hb-today-metric__value">{pendingMentions.length}</span>
|
|
||||||
<span className="hb-today-metric__hint">{t("today.metric.mentionsHint")}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 待回覆 · 海巡 */}
|
|
||||||
<Card title={t("today.pending.title", { n: pendingScout.length })}>
|
|
||||||
{pendingPreview.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t("today.pending.empty")}
|
|
||||||
action={
|
|
||||||
<Link to="/app/scout">
|
|
||||||
<Button type="button">{t("today.goScout")}</Button>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="hb-stack">
|
|
||||||
<ul className="hb-today-list">
|
|
||||||
{pendingPreview.map((p) => (
|
|
||||||
<li key={p.id}>
|
|
||||||
<Link to="/app/scout" className="hb-today-list__item">
|
|
||||||
<span className="hb-today-list__meta">
|
|
||||||
@{p.author}
|
|
||||||
{p.search_tag ? ` · ${p.search_tag}` : ""}
|
|
||||||
{typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
|
|
||||||
{p.outreach_status === "drafted" ? (
|
|
||||||
<>
|
|
||||||
{" · "}
|
|
||||||
<Badge tone="brand">{t("today.badge.drafted")}</Badge>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="hb-today-list__text">
|
|
||||||
{p.text.slice(0, 96)}
|
|
||||||
{p.text.length > 96 ? "…" : ""}
|
|
||||||
</span>
|
|
||||||
{p.opportunity ? (
|
|
||||||
<span className="hb-today-list__meta">{p.opportunity}</span>
|
|
||||||
) : null}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{pendingScout.length > pendingPreview.length ? (
|
|
||||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
{t("today.pending.more", { n: pendingScout.length - pendingPreview.length })}{" "}
|
|
||||||
<Link to="/app/scout">{t("today.pending.handle")}</Link>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Link to="/app/scout">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.pending.start")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 找話題 */}
|
|
||||||
<Card title={t("today.topics.title")}>
|
|
||||||
<div className="hb-today-actions" style={{ margin: "0 0 0.5rem" }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={refreshingTrends}
|
|
||||||
onClick={() => void onRefreshTrends()}
|
|
||||||
>
|
|
||||||
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
|
|
||||||
</Button>
|
|
||||||
{trendMessage ? (
|
|
||||||
<span className="text-muted" role="status">
|
|
||||||
{trendMessage}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{topicCards.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t("today.topics.empty")}
|
|
||||||
action={
|
|
||||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onRefreshTrends()}
|
|
||||||
disabled={refreshingTrends}
|
|
||||||
>
|
|
||||||
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
|
|
||||||
</Button>
|
|
||||||
<Link to="/app/studio?tab=inspire">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.goStudio")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="hb-stack">
|
|
||||||
<ul className="hb-today-list">
|
|
||||||
{topicCards.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<Link to={topicHref(item.label)} className="hb-today-list__item">
|
|
||||||
<span className="hb-today-list__meta">
|
|
||||||
{item.label}
|
|
||||||
{typeof item.heat === "number" && item.heat > 0 ? (
|
|
||||||
<Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge>
|
|
||||||
) : null}
|
|
||||||
{item.source_label ? (
|
|
||||||
<span className="text-muted"> · {item.source_label}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="hb-today-list__text">
|
|
||||||
{item.summary?.slice(0, 100) ||
|
|
||||||
item.samples?.[0] ||
|
|
||||||
t("today.topicAngle")}
|
|
||||||
{(item.summary?.length || 0) > 100 ? "…" : ""}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
|
||||||
<Link to="/app/studio?tab=inspire">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.moreInspire")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
{topicCards[0] ? (
|
|
||||||
<Link to={topicHref(topicCards[0].label)}>
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.useTopic")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 發送摘要 */}
|
|
||||||
<Card title={t("today.outbox.title")}>
|
|
||||||
{failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (
|
|
||||||
<p className="text-muted" style={{ margin: 0 }}>
|
|
||||||
{t("today.outbox.empty")}{" "}
|
|
||||||
<Link to="/app/studio?tab=compose">{t("today.newThread")}</Link>
|
|
||||||
{t("today.outbox.emptyMid")}{" "}
|
|
||||||
<Link to="/app/outbox">{t("nav.outbox")}</Link>
|
|
||||||
{t("today.outbox.emptyEnd")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="hb-stack" style={{ gap: "0.5rem" }}>
|
|
||||||
<p style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
{t("today.outbox.summary", {
|
|
||||||
sent: sentToday,
|
|
||||||
running: runningOutbox.length,
|
|
||||||
failed: failedOutbox.length,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{failedOutbox.slice(0, 2).map((o) => (
|
|
||||||
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
<Badge tone="danger">{t("today.badge.failed")}</Badge>{" "}
|
|
||||||
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
{runningOutbox.slice(0, 2).map((o) => (
|
|
||||||
<p key={o.id} style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
|
||||||
<Badge tone="brand">
|
|
||||||
{o.status === "scheduling"
|
|
||||||
? t("today.badge.scheduling")
|
|
||||||
: t("today.badge.sending")}
|
|
||||||
</Badge>{" "}
|
|
||||||
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
<Link to="/app/outbox">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.openOutbox")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 帳號成效 */}
|
|
||||||
<Card title={t("today.accounts.title")}>
|
|
||||||
{accountPulses.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t("today.accounts.empty")}
|
|
||||||
action={
|
|
||||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
|
||||||
{accounts.length > 0 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void onSyncOwnPosts()}
|
|
||||||
disabled={syncingPosts}
|
|
||||||
>
|
|
||||||
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Link to="/app/crew">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("nav.crew")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="hb-stack">
|
|
||||||
<ul className="hb-today-account-list">
|
|
||||||
{accountPulses.map((row) => (
|
|
||||||
<li key={row.account.id} className="hb-today-account">
|
|
||||||
<div className="hb-today-account__head">
|
|
||||||
<strong>@{row.account.username}</strong>
|
|
||||||
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
|
||||||
{t("today.postsCount", { n: row.posts })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="hb-today-account__stats">
|
|
||||||
<span>
|
|
||||||
{t("today.views")}{" "}
|
|
||||||
<strong>{row.views.toLocaleString(dateLocale)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t("today.likes")} <strong>{row.likes}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{t("today.repliesShort")} <strong>{row.replies}</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{row.topInsight ? (
|
|
||||||
<p className="hb-today-account__insight">{row.topInsight}</p>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
|
||||||
<Link to="/app/insights">
|
|
||||||
<Button type="button">{t("today.fullInsights")}</Button>
|
|
||||||
</Link>
|
|
||||||
<Link to="/app/studio?tab=posts">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.viewPosts")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => void onSyncOwnPosts()}
|
|
||||||
disabled={syncingPosts || !accounts.length}
|
|
||||||
>
|
|
||||||
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
|
|
||||||
</Button>
|
|
||||||
<Link to="/app/crew">
|
|
||||||
<Button type="button" variant="ghost">
|
|
||||||
{t("today.manageAccounts")}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)}>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:1);hover 只是淡底。
|
|
||||||
* 兩者一眼分得出來,也不必再靠一條被圓角切掉的 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 主 CTA、8px 工具鈕、緊字距大標。
|
||||||
* 字體:Inter(拉丁)+ Noto Sans TC(中文,可變字重 400–700)
|
* 字體:Inter(拉丁)+ Noto Sans TC(中文,可變字重 400–700)
|
||||||
*
|
*
|
||||||
* 唯一真相:顏色、字級、間距只在這個檔案定義。其他 css 與 tsx 一律吃 var(),
|
* 唯一真相:顏色、字級、間距只在這個檔案定義。其他 css 與 tsx 一律吃 var(),
|
||||||
|
|
@ -21,87 +22,94 @@
|
||||||
* 混出來一定是濁褐;要調淡就跟 surface/line/transparent 混。要一深一淺的
|
* 混出來一定是濁褐;要調淡就跟 surface/line/transparent 混。要一深一淺的
|
||||||
* 同一顏色就用 X 與 X-deep。
|
* 同一顏色就用 X 與 X-deep。
|
||||||
* 2. 中性色(bg / surface / line / muted / subtle)全部維持 H≈180-191 的冷青,
|
* 2. 中性色(bg / surface / line / muted / subtle)全部維持 H≈180-191 的冷青,
|
||||||
* 跟 ink 與主色同溫。palette 原本的 muted 是 H=44 的暖褐,疊在偏冷的白底上
|
* 跟 ink 與主色同溫。暖色只出現在刻意的強調上:warning、accent、方案金標。
|
||||||
* 會整片發黃發濁。暖色只出現在刻意的強調上:warning、accent、方案金標。
|
* 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 TC(400–700,含 500/600/650)。 */
|
|
||||||
--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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
* 主 CTA:Harbor 主色填滿 + 全 pill。utility/ghost 才走 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue