Skip to content

Commit 5813c76

Browse files
committed
feat: add QueryDrillDown component
1 parent 5863c59 commit 5813c76

File tree

1 file changed

+163
-3
lines changed

1 file changed

+163
-3
lines changed
Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1-
// Stub — will be fully implemented in Task 4
1+
import { useQuery } from "@tanstack/react-query"
2+
import { getAuthToken } from "@/clients/auth-helper"
3+
4+
// ── Types ────────────────────────────────────────────────────────────────────
5+
6+
interface QueryDrillDownResponse {
7+
queries: Array<{
8+
prompt_text: string
9+
brand_rank_today: number
10+
brand_rank_7d_ago: number
11+
rank_change: number
12+
top_competitor_today: string | null
13+
top_competitor_rank: number | null
14+
}>
15+
}
16+
217
interface Props {
318
signal_type: string
419
brand_id: number
@@ -7,6 +22,151 @@ interface Props {
722
action_type: string
823
}
924

10-
export default function QueryDrillDown(_props: Props) {
11-
return null
25+
// ── API helpers ──────────────────────────────────────────────────────────────
26+
27+
const API_BASE_URL = import.meta.env.VITE_API_URL ?? ""
28+
const API_PREFIX = "/api/v1"
29+
30+
async function fetchQueryDrillDown(
31+
signal_type: string,
32+
brand_id: number,
33+
segment: string,
34+
date: string,
35+
action_type: string,
36+
): Promise<QueryDrillDownResponse> {
37+
const token = getAuthToken()
38+
const params = new URLSearchParams({
39+
brand_id: String(brand_id),
40+
segment,
41+
date,
42+
action_type,
43+
})
44+
const url = `${API_BASE_URL}${API_PREFIX}/insights/signal/${encodeURIComponent(signal_type)}/queries?${params.toString()}`
45+
const res = await fetch(url, {
46+
headers: {
47+
...(token ? { Authorization: `Bearer ${token}` } : {}),
48+
"Content-Type": "application/json",
49+
},
50+
})
51+
if (!res.ok) {
52+
throw new Error(`Failed to fetch query drill-down: ${res.status}`)
53+
}
54+
return res.json() as Promise<QueryDrillDownResponse>
55+
}
56+
57+
// ── Rank change helpers ──────────────────────────────────────────────────────
58+
59+
function rankChangeColor(change: number): string {
60+
if (change < 0) return "text-red-400"
61+
if (change > 0) return "text-green-400"
62+
return "text-slate-500"
63+
}
64+
65+
function rankChangeLabel(change: number): string {
66+
if (change > 0) return `+${change}`
67+
if (change < 0) return String(change)
68+
return "0"
69+
}
70+
71+
// ── Main component ───────────────────────────────────────────────────────────
72+
73+
export default function QueryDrillDown({
74+
signal_type,
75+
brand_id,
76+
segment,
77+
date,
78+
action_type,
79+
}: Props) {
80+
const { data, isLoading, isError } = useQuery<QueryDrillDownResponse>({
81+
queryKey: ["query-drilldown", signal_type, brand_id, segment, date, action_type],
82+
queryFn: () => fetchQueryDrillDown(signal_type, brand_id, segment, date, action_type),
83+
enabled: true,
84+
})
85+
86+
if (isLoading) {
87+
return (
88+
<div className="flex items-center gap-2 py-3 text-slate-500 text-xs">
89+
<svg
90+
className="animate-spin h-3.5 w-3.5 text-slate-400"
91+
xmlns="http://www.w3.org/2000/svg"
92+
fill="none"
93+
viewBox="0 0 24 24"
94+
aria-hidden="true"
95+
>
96+
<circle
97+
className="opacity-25"
98+
cx="12"
99+
cy="12"
100+
r="10"
101+
stroke="currentColor"
102+
strokeWidth="4"
103+
/>
104+
<path
105+
className="opacity-75"
106+
fill="currentColor"
107+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
108+
/>
109+
</svg>
110+
Loading queries…
111+
</div>
112+
)
113+
}
114+
115+
if (isError) {
116+
return (
117+
<div className="py-3 text-xs text-red-400">
118+
Failed to load queries. Please try again.
119+
</div>
120+
)
121+
}
122+
123+
if (!data || data.queries.length === 0) {
124+
return (
125+
<div className="py-3 text-xs text-slate-500">
126+
No query data available.
127+
</div>
128+
)
129+
}
130+
131+
return (
132+
<div className="rounded-lg border border-slate-700 overflow-hidden">
133+
<table className="w-full text-xs">
134+
<thead>
135+
<tr className="border-b border-slate-700 bg-slate-800/60">
136+
<th className="px-3 py-2 text-left text-slate-500 font-medium">Query</th>
137+
<th className="px-3 py-2 text-right text-slate-500 font-medium">Brand Rank</th>
138+
<th className="px-3 py-2 text-right text-slate-500 font-medium">7d Ago</th>
139+
<th className="px-3 py-2 text-right text-slate-500 font-medium">Change</th>
140+
<th className="px-3 py-2 text-left text-slate-500 font-medium">Top Competitor</th>
141+
</tr>
142+
</thead>
143+
<tbody>
144+
{data.queries.map((q, i) => (
145+
<tr
146+
// biome-ignore lint/suspicious/noArrayIndexKey: stable list from API
147+
key={i}
148+
className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors"
149+
>
150+
<td
151+
className="px-3 py-2 text-slate-300 max-w-[200px] truncate"
152+
title={q.prompt_text}
153+
>
154+
{q.prompt_text}
155+
</td>
156+
<td className="px-3 py-2 text-right text-slate-400">{q.brand_rank_today}</td>
157+
<td className="px-3 py-2 text-right text-slate-400">{q.brand_rank_7d_ago}</td>
158+
<td className={`px-3 py-2 text-right font-semibold ${rankChangeColor(q.rank_change)}`}>
159+
{rankChangeLabel(q.rank_change)}
160+
</td>
161+
<td className="px-3 py-2 text-slate-400 max-w-[140px] truncate">
162+
{q.top_competitor_today
163+
? `${q.top_competitor_today}${q.top_competitor_rank !== null ? ` (#${q.top_competitor_rank})` : ""}`
164+
: <span className="text-slate-600"></span>}
165+
</td>
166+
</tr>
167+
))}
168+
</tbody>
169+
</table>
170+
</div>
171+
)
12172
}

0 commit comments

Comments
 (0)