@yourbright/emdash-analytics-plugin 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.js +860 -0
- package/dist/index.js +1560 -0
- package/package.json +8 -5
- package/src/admin.tsx +0 -1138
- package/src/config-validation.ts +0 -153
- package/src/config.ts +0 -90
- package/src/constants.ts +0 -55
- package/src/content.ts +0 -133
- package/src/google.ts +0 -518
- package/src/index.ts +0 -270
- package/src/scoring.ts +0 -83
- package/src/sync.ts +0 -749
- package/src/types.ts +0 -193
package/src/admin.tsx
DELETED
|
@@ -1,1138 +0,0 @@
|
|
|
1
|
-
import type { PluginAdminExports } from "emdash";
|
|
2
|
-
import { apiFetch as baseFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
|
|
5
|
-
import { ADMIN_ROUTES, PLUGIN_ID } from "./constants.js";
|
|
6
|
-
import type {
|
|
7
|
-
AgentKeyRecord,
|
|
8
|
-
FreshnessState,
|
|
9
|
-
PageAggregateRecord,
|
|
10
|
-
PageKind,
|
|
11
|
-
PageListResponse,
|
|
12
|
-
PluginConfigSummary,
|
|
13
|
-
SiteSummary
|
|
14
|
-
} from "./types.js";
|
|
15
|
-
|
|
16
|
-
const API_BASE = `/_emdash/api/plugins/${PLUGIN_ID}`;
|
|
17
|
-
|
|
18
|
-
interface StatusResponse {
|
|
19
|
-
config: PluginConfigSummary | null;
|
|
20
|
-
summary: SiteSummary | null;
|
|
21
|
-
freshness: FreshnessState;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface OverviewResponse {
|
|
25
|
-
summary: SiteSummary | null;
|
|
26
|
-
freshness: FreshnessState;
|
|
27
|
-
topOpportunities: PageAggregateRecord[];
|
|
28
|
-
topUnmanaged: PageAggregateRecord[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface AgentKeyListItem extends Omit<AgentKeyRecord, "hash"> {}
|
|
32
|
-
|
|
33
|
-
interface AgentKeyCreateResponse {
|
|
34
|
-
key: string;
|
|
35
|
-
metadata: AgentKeyListItem;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface ConfigDraft {
|
|
39
|
-
siteOrigin: string;
|
|
40
|
-
ga4PropertyId: string;
|
|
41
|
-
gscSiteUrl: string;
|
|
42
|
-
serviceAccountJson: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface StoredConfigFields {
|
|
46
|
-
siteOrigin: string;
|
|
47
|
-
ga4PropertyId: string;
|
|
48
|
-
gscSiteUrl: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const EMPTY_CONFIG: ConfigDraft = {
|
|
52
|
-
siteOrigin: "",
|
|
53
|
-
ga4PropertyId: "",
|
|
54
|
-
gscSiteUrl: "",
|
|
55
|
-
serviceAccountJson: ""
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
function validateSettingsDraft(
|
|
59
|
-
draft: ConfigDraft,
|
|
60
|
-
stored: StoredConfigFields,
|
|
61
|
-
hasStoredServiceAccount: boolean
|
|
62
|
-
): string | null {
|
|
63
|
-
const siteOrigin = resolveDraftField(draft.siteOrigin, stored.siteOrigin);
|
|
64
|
-
const ga4PropertyId = resolveDraftField(draft.ga4PropertyId, stored.ga4PropertyId);
|
|
65
|
-
const gscSiteUrl = resolveDraftField(draft.gscSiteUrl, stored.gscSiteUrl);
|
|
66
|
-
const serviceAccountJson = draft.serviceAccountJson.trim();
|
|
67
|
-
|
|
68
|
-
if (!siteOrigin) return "Canonical Site Origin is required";
|
|
69
|
-
if (!isHttpUrl(siteOrigin)) return "Canonical Site Origin must be a valid http(s) URL";
|
|
70
|
-
if (!ga4PropertyId) return "GA4 Property ID is required";
|
|
71
|
-
if (!/^[0-9]+$/.test(ga4PropertyId)) return "GA4 Property ID must be numeric";
|
|
72
|
-
if (!gscSiteUrl) return "Search Console Property is required";
|
|
73
|
-
if (!isValidSearchConsoleProperty(gscSiteUrl)) {
|
|
74
|
-
return "Search Console Property must be a valid URL or sc-domain property";
|
|
75
|
-
}
|
|
76
|
-
if (!serviceAccountJson && !hasStoredServiceAccount) {
|
|
77
|
-
return "Service Account JSON is required on the first save";
|
|
78
|
-
}
|
|
79
|
-
if (serviceAccountJson) {
|
|
80
|
-
const serviceAccountError = validateServiceAccountJson(serviceAccountJson);
|
|
81
|
-
if (serviceAccountError) return serviceAccountError;
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function resolveDraftField(value: string, fallback: string): string {
|
|
87
|
-
const trimmed = value.trim();
|
|
88
|
-
return trimmed.length > 0 ? trimmed : fallback.trim();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function validateServiceAccountJson(value: string): string | null {
|
|
92
|
-
try {
|
|
93
|
-
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
94
|
-
const clientEmail = typeof parsed.client_email === "string" ? parsed.client_email.trim() : "";
|
|
95
|
-
const privateKey = typeof parsed.private_key === "string" ? parsed.private_key.trim() : "";
|
|
96
|
-
if (!clientEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clientEmail)) {
|
|
97
|
-
return "Service Account JSON must include a valid client_email";
|
|
98
|
-
}
|
|
99
|
-
if (!privateKey) {
|
|
100
|
-
return "Service Account JSON must include a private_key";
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
} catch {
|
|
104
|
-
return "Service Account JSON must be valid JSON";
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function isHttpUrl(value: string): boolean {
|
|
109
|
-
try {
|
|
110
|
-
const url = new URL(value);
|
|
111
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function isValidSearchConsoleProperty(value: string): boolean {
|
|
118
|
-
if (value.startsWith("sc-domain:")) {
|
|
119
|
-
return value.slice("sc-domain:".length).trim().length > 0;
|
|
120
|
-
}
|
|
121
|
-
return isHttpUrl(value);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function apiPost<T>(route: string, body?: unknown): Promise<Response> {
|
|
125
|
-
return baseFetch(`${API_BASE}/${route}`, {
|
|
126
|
-
method: "POST",
|
|
127
|
-
headers: { "Content-Type": "application/json" },
|
|
128
|
-
body: JSON.stringify(body ?? {})
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function apiGet<T>(route: string): Promise<Response> {
|
|
133
|
-
return baseFetch(`${API_BASE}/${route}`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function buildConfigPayload(draft: ConfigDraft): {
|
|
137
|
-
siteOrigin: string;
|
|
138
|
-
ga4PropertyId: string;
|
|
139
|
-
gscSiteUrl: string;
|
|
140
|
-
serviceAccountJson?: string;
|
|
141
|
-
} {
|
|
142
|
-
const payload = {
|
|
143
|
-
siteOrigin: draft.siteOrigin.trim(),
|
|
144
|
-
ga4PropertyId: draft.ga4PropertyId.trim(),
|
|
145
|
-
gscSiteUrl: draft.gscSiteUrl.trim()
|
|
146
|
-
};
|
|
147
|
-
const serviceAccountJson = draft.serviceAccountJson.trim();
|
|
148
|
-
|
|
149
|
-
if (!serviceAccountJson) {
|
|
150
|
-
return payload;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
...payload,
|
|
155
|
-
serviceAccountJson
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function Shell({
|
|
160
|
-
title,
|
|
161
|
-
description,
|
|
162
|
-
actions,
|
|
163
|
-
children
|
|
164
|
-
}: {
|
|
165
|
-
title: string;
|
|
166
|
-
description?: string;
|
|
167
|
-
actions?: React.ReactNode;
|
|
168
|
-
children: React.ReactNode;
|
|
169
|
-
}) {
|
|
170
|
-
return (
|
|
171
|
-
<div className="space-y-6">
|
|
172
|
-
<div className="flex flex-col gap-3 border-b border-border pb-4 md:flex-row md:items-end md:justify-between">
|
|
173
|
-
<div className="space-y-1">
|
|
174
|
-
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
175
|
-
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
176
|
-
</div>
|
|
177
|
-
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
|
178
|
-
</div>
|
|
179
|
-
{children}
|
|
180
|
-
</div>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function Section({
|
|
185
|
-
title,
|
|
186
|
-
subtitle,
|
|
187
|
-
children
|
|
188
|
-
}: {
|
|
189
|
-
title: string;
|
|
190
|
-
subtitle?: string;
|
|
191
|
-
children: React.ReactNode;
|
|
192
|
-
}) {
|
|
193
|
-
return (
|
|
194
|
-
<section className="rounded-xl border border-border bg-card p-5 shadow-sm">
|
|
195
|
-
<div className="mb-4 space-y-1">
|
|
196
|
-
<h2 className="text-base font-semibold">{title}</h2>
|
|
197
|
-
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null}
|
|
198
|
-
</div>
|
|
199
|
-
{children}
|
|
200
|
-
</section>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function StatCard({
|
|
205
|
-
label,
|
|
206
|
-
value,
|
|
207
|
-
note
|
|
208
|
-
}: {
|
|
209
|
-
label: string;
|
|
210
|
-
value: string;
|
|
211
|
-
note?: string;
|
|
212
|
-
}) {
|
|
213
|
-
return (
|
|
214
|
-
<div className="rounded-xl border border-border bg-background p-4">
|
|
215
|
-
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
|
216
|
-
<div className="mt-2 text-2xl font-semibold">{value}</div>
|
|
217
|
-
{note ? <div className="mt-1 text-xs text-muted-foreground">{note}</div> : null}
|
|
218
|
-
</div>
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function ErrorBanner({ message }: { message: string | null }) {
|
|
223
|
-
if (!message) return null;
|
|
224
|
-
return <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{message}</div>;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function SuccessBanner({ message }: { message: string | null }) {
|
|
228
|
-
if (!message) return null;
|
|
229
|
-
return (
|
|
230
|
-
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
|
231
|
-
{message}
|
|
232
|
-
</div>
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function Button({
|
|
237
|
-
children,
|
|
238
|
-
onClick,
|
|
239
|
-
disabled,
|
|
240
|
-
variant = "primary",
|
|
241
|
-
type = "button"
|
|
242
|
-
}: {
|
|
243
|
-
children: React.ReactNode;
|
|
244
|
-
onClick?: () => void;
|
|
245
|
-
disabled?: boolean;
|
|
246
|
-
variant?: "primary" | "secondary" | "danger";
|
|
247
|
-
type?: "button" | "submit";
|
|
248
|
-
}) {
|
|
249
|
-
const styles =
|
|
250
|
-
variant === "primary"
|
|
251
|
-
? "border text-white shadow-sm"
|
|
252
|
-
: variant === "danger"
|
|
253
|
-
? "border text-white shadow-sm"
|
|
254
|
-
: "border border-slate-300 bg-white text-slate-900 shadow-sm hover:bg-slate-50";
|
|
255
|
-
const style =
|
|
256
|
-
variant === "primary"
|
|
257
|
-
? {
|
|
258
|
-
backgroundColor: "var(--color-kumo-brand)",
|
|
259
|
-
borderColor: "var(--color-kumo-brand)"
|
|
260
|
-
}
|
|
261
|
-
: variant === "danger"
|
|
262
|
-
? {
|
|
263
|
-
backgroundColor: "var(--color-kumo-danger)",
|
|
264
|
-
borderColor: "var(--color-kumo-danger)"
|
|
265
|
-
}
|
|
266
|
-
: undefined;
|
|
267
|
-
return (
|
|
268
|
-
<button
|
|
269
|
-
type={type}
|
|
270
|
-
onClick={onClick}
|
|
271
|
-
disabled={disabled}
|
|
272
|
-
style={style}
|
|
273
|
-
className={`inline-flex min-h-11 items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${styles}`}
|
|
274
|
-
>
|
|
275
|
-
{children}
|
|
276
|
-
</button>
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function Input({
|
|
281
|
-
value,
|
|
282
|
-
onChange,
|
|
283
|
-
placeholder,
|
|
284
|
-
type = "text"
|
|
285
|
-
}: {
|
|
286
|
-
value: string;
|
|
287
|
-
onChange: (value: string) => void;
|
|
288
|
-
placeholder?: string;
|
|
289
|
-
type?: string;
|
|
290
|
-
}) {
|
|
291
|
-
return (
|
|
292
|
-
<input
|
|
293
|
-
type={type}
|
|
294
|
-
value={value}
|
|
295
|
-
onChange={(event) => onChange(event.target.value)}
|
|
296
|
-
placeholder={placeholder}
|
|
297
|
-
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-foreground"
|
|
298
|
-
/>
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function TextArea({
|
|
303
|
-
value,
|
|
304
|
-
onChange,
|
|
305
|
-
placeholder,
|
|
306
|
-
rows = 8
|
|
307
|
-
}: {
|
|
308
|
-
value: string;
|
|
309
|
-
onChange: (value: string) => void;
|
|
310
|
-
placeholder?: string;
|
|
311
|
-
rows?: number;
|
|
312
|
-
}) {
|
|
313
|
-
return (
|
|
314
|
-
<textarea
|
|
315
|
-
value={value}
|
|
316
|
-
onChange={(event) => onChange(event.target.value)}
|
|
317
|
-
placeholder={placeholder}
|
|
318
|
-
rows={rows}
|
|
319
|
-
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-foreground"
|
|
320
|
-
/>
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function Select({
|
|
325
|
-
value,
|
|
326
|
-
onChange,
|
|
327
|
-
options
|
|
328
|
-
}: {
|
|
329
|
-
value: string;
|
|
330
|
-
onChange: (value: string) => void;
|
|
331
|
-
options: Array<{ value: string; label: string }>;
|
|
332
|
-
}) {
|
|
333
|
-
return (
|
|
334
|
-
<select
|
|
335
|
-
value={value}
|
|
336
|
-
onChange={(event) => onChange(event.target.value)}
|
|
337
|
-
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-foreground"
|
|
338
|
-
>
|
|
339
|
-
{options.map((option) => (
|
|
340
|
-
<option key={option.value} value={option.value}>
|
|
341
|
-
{option.label}
|
|
342
|
-
</option>
|
|
343
|
-
))}
|
|
344
|
-
</select>
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function Field({
|
|
349
|
-
label,
|
|
350
|
-
hint,
|
|
351
|
-
children
|
|
352
|
-
}: {
|
|
353
|
-
label: string;
|
|
354
|
-
hint?: string;
|
|
355
|
-
children: React.ReactNode;
|
|
356
|
-
}) {
|
|
357
|
-
return (
|
|
358
|
-
<label className="space-y-2">
|
|
359
|
-
<div>
|
|
360
|
-
<div className="text-sm font-medium">{label}</div>
|
|
361
|
-
{hint ? <div className="text-xs text-muted-foreground">{hint}</div> : null}
|
|
362
|
-
</div>
|
|
363
|
-
{children}
|
|
364
|
-
</label>
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function MetricTable({
|
|
369
|
-
items,
|
|
370
|
-
emptyMessage
|
|
371
|
-
}: {
|
|
372
|
-
items: PageAggregateRecord[];
|
|
373
|
-
emptyMessage: string;
|
|
374
|
-
}) {
|
|
375
|
-
if (items.length === 0) {
|
|
376
|
-
return <div className="text-sm text-muted-foreground">{emptyMessage}</div>;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return (
|
|
380
|
-
<div className="overflow-x-auto">
|
|
381
|
-
<table className="min-w-full text-sm">
|
|
382
|
-
<thead className="text-left text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
|
383
|
-
<tr>
|
|
384
|
-
<th className="pb-3 pr-4">Page</th>
|
|
385
|
-
<th className="pb-3 pr-4">Type</th>
|
|
386
|
-
<th className="pb-3 pr-4">GSC Clicks</th>
|
|
387
|
-
<th className="pb-3 pr-4">GA Views</th>
|
|
388
|
-
<th className="pb-3 pr-4">Opportunity Score</th>
|
|
389
|
-
</tr>
|
|
390
|
-
</thead>
|
|
391
|
-
<tbody>
|
|
392
|
-
{items.map((item) => (
|
|
393
|
-
<tr key={item.urlPath} className="border-t border-border/80">
|
|
394
|
-
<td className="py-3 pr-4">
|
|
395
|
-
<div className="font-medium">{item.title}</div>
|
|
396
|
-
<div className="text-xs text-muted-foreground">{item.urlPath}</div>
|
|
397
|
-
</td>
|
|
398
|
-
<td className="py-3 pr-4">{pageKindLabel(item.pageKind)}</td>
|
|
399
|
-
<td className="py-3 pr-4">{formatInteger(item.gscClicks28d)}</td>
|
|
400
|
-
<td className="py-3 pr-4">{formatInteger(item.gaViews28d)}</td>
|
|
401
|
-
<td className="py-3 pr-4">
|
|
402
|
-
<span className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
|
|
403
|
-
{item.opportunityScore}
|
|
404
|
-
</span>
|
|
405
|
-
</td>
|
|
406
|
-
</tr>
|
|
407
|
-
))}
|
|
408
|
-
</tbody>
|
|
409
|
-
</table>
|
|
410
|
-
</div>
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function OverviewPage() {
|
|
415
|
-
const [status, setStatus] = React.useState<StatusResponse | null>(null);
|
|
416
|
-
const [overview, setOverview] = React.useState<OverviewResponse | null>(null);
|
|
417
|
-
const [loading, setLoading] = React.useState(true);
|
|
418
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
419
|
-
|
|
420
|
-
const load = React.useCallback(async () => {
|
|
421
|
-
setLoading(true);
|
|
422
|
-
setError(null);
|
|
423
|
-
try {
|
|
424
|
-
const [statusRes, overviewRes] = await Promise.all([
|
|
425
|
-
apiGet(ADMIN_ROUTES.STATUS),
|
|
426
|
-
apiGet(ADMIN_ROUTES.OVERVIEW)
|
|
427
|
-
]);
|
|
428
|
-
const [statusData, overviewData] = await Promise.all([
|
|
429
|
-
parseApiResponse<StatusResponse>(statusRes, "Failed to load status"),
|
|
430
|
-
parseApiResponse<OverviewResponse>(overviewRes, "Failed to load overview")
|
|
431
|
-
]);
|
|
432
|
-
setStatus(statusData);
|
|
433
|
-
setOverview(overviewData);
|
|
434
|
-
} catch (err) {
|
|
435
|
-
setError(err instanceof Error ? err.message : "Failed to load overview");
|
|
436
|
-
} finally {
|
|
437
|
-
setLoading(false);
|
|
438
|
-
}
|
|
439
|
-
}, []);
|
|
440
|
-
|
|
441
|
-
React.useEffect(() => {
|
|
442
|
-
void load();
|
|
443
|
-
}, [load]);
|
|
444
|
-
|
|
445
|
-
const summary = overview?.summary ?? status?.summary ?? null;
|
|
446
|
-
const freshness = overview?.freshness ?? status?.freshness ?? idleFreshness();
|
|
447
|
-
|
|
448
|
-
return (
|
|
449
|
-
<Shell
|
|
450
|
-
title="Content Insights"
|
|
451
|
-
description="Prioritize pages with the clearest opportunities using combined Search Console and GA4 data."
|
|
452
|
-
actions={<Button variant="secondary" onClick={() => void load()} disabled={loading}>Reload</Button>}
|
|
453
|
-
>
|
|
454
|
-
<ErrorBanner message={error} />
|
|
455
|
-
{!status?.config ? (
|
|
456
|
-
<Section title="Not Configured" subtitle="Save your Google connection settings first.">
|
|
457
|
-
<div className="text-sm text-muted-foreground">
|
|
458
|
-
After saving the configuration, run a manual sync to populate this dashboard.
|
|
459
|
-
</div>
|
|
460
|
-
</Section>
|
|
461
|
-
) : null}
|
|
462
|
-
<Section title="Freshness" subtitle="Track the latest sync and the effective source dates.">
|
|
463
|
-
<div className="grid gap-4 md:grid-cols-4">
|
|
464
|
-
<StatCard label="Last Sync" value={formatDateTime(freshness.lastSyncedAt)} note={statusLabel(freshness.lastStatus)} />
|
|
465
|
-
<StatCard label="GSC Final Date" value={freshness.lastGscDate || "-"} />
|
|
466
|
-
<StatCard label="GA Final Date" value={freshness.lastGaDate || "-"} />
|
|
467
|
-
<StatCard label="Service Account" value={status?.config?.serviceAccountEmail || "-"} />
|
|
468
|
-
</div>
|
|
469
|
-
</Section>
|
|
470
|
-
<Section title="KPI Snapshot" subtitle="Aggregated totals for the last 28 days across public pages.">
|
|
471
|
-
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">
|
|
472
|
-
<StatCard label="GSC Clicks" value={formatInteger(summary?.totals.gscClicks28d ?? 0)} />
|
|
473
|
-
<StatCard label="GSC Impressions" value={formatInteger(summary?.totals.gscImpressions28d ?? 0)} />
|
|
474
|
-
<StatCard label="GA Views" value={formatInteger(summary?.totals.gaViews28d ?? 0)} />
|
|
475
|
-
<StatCard label="GA Users" value={formatInteger(summary?.totals.gaUsers28d ?? 0)} />
|
|
476
|
-
<StatCard label="GA Sessions" value={formatInteger(summary?.totals.gaSessions28d ?? 0)} />
|
|
477
|
-
<StatCard label="Managed Opportunities" value={formatInteger(summary?.totals.managedOpportunities ?? 0)} />
|
|
478
|
-
</div>
|
|
479
|
-
</Section>
|
|
480
|
-
<div className="grid gap-6 xl:grid-cols-2">
|
|
481
|
-
<Section title="Top Opportunities" subtitle="Managed content only.">
|
|
482
|
-
<MetricTable items={overview?.topOpportunities ?? []} emptyMessage="No opportunities yet." />
|
|
483
|
-
</Section>
|
|
484
|
-
<Section title="Top Unmanaged Pages" subtitle="Public pages outside EmDash-managed content.">
|
|
485
|
-
<MetricTable items={overview?.topUnmanaged ?? []} emptyMessage="No unmanaged page data yet." />
|
|
486
|
-
</Section>
|
|
487
|
-
</div>
|
|
488
|
-
<Section title="Reporting Windows" subtitle="The agent API returns the same windows.">
|
|
489
|
-
<div className="grid gap-4 md:grid-cols-2">
|
|
490
|
-
<WindowCard label="GSC Current" value={summary?.window.gscCurrent} />
|
|
491
|
-
<WindowCard label="GSC Previous" value={summary?.window.gscPrevious} />
|
|
492
|
-
<WindowCard label="GA Current" value={summary?.window.gaCurrent} />
|
|
493
|
-
<WindowCard label="GA Previous" value={summary?.window.gaPrevious} />
|
|
494
|
-
</div>
|
|
495
|
-
</Section>
|
|
496
|
-
</Shell>
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function PagesPage() {
|
|
501
|
-
const [managed, setManaged] = React.useState<"all" | "managed" | "unmanaged">("all");
|
|
502
|
-
const [pageKind, setPageKind] = React.useState<"all" | PageKind>("all");
|
|
503
|
-
const [hasOpportunity, setHasOpportunity] = React.useState(false);
|
|
504
|
-
const [loading, setLoading] = React.useState(true);
|
|
505
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
506
|
-
const [pages, setPages] = React.useState<PageAggregateRecord[]>([]);
|
|
507
|
-
const [selected, setSelected] = React.useState<PageAggregateRecord | null>(null);
|
|
508
|
-
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
|
|
509
|
-
|
|
510
|
-
const load = React.useCallback(async () => {
|
|
511
|
-
setLoading(true);
|
|
512
|
-
setError(null);
|
|
513
|
-
try {
|
|
514
|
-
const response = await apiPost<PageListResponse>(ADMIN_ROUTES.LIST_PAGES, {
|
|
515
|
-
managed,
|
|
516
|
-
pageKind,
|
|
517
|
-
hasOpportunity,
|
|
518
|
-
limit: 100
|
|
519
|
-
});
|
|
520
|
-
const data = await parseApiResponse<PageListResponse>(response, "Failed to load page list");
|
|
521
|
-
setPages(data.items);
|
|
522
|
-
if (selected) {
|
|
523
|
-
const nextSelected = data.items.find((item) => item.urlPath === selected.urlPath) || null;
|
|
524
|
-
setSelected(nextSelected);
|
|
525
|
-
}
|
|
526
|
-
} catch (err) {
|
|
527
|
-
setError(err instanceof Error ? err.message : "Failed to load page list");
|
|
528
|
-
} finally {
|
|
529
|
-
setLoading(false);
|
|
530
|
-
}
|
|
531
|
-
}, [managed, pageKind, hasOpportunity, selected]);
|
|
532
|
-
|
|
533
|
-
React.useEffect(() => {
|
|
534
|
-
void load();
|
|
535
|
-
}, [load]);
|
|
536
|
-
|
|
537
|
-
React.useEffect(() => {
|
|
538
|
-
if (!selected?.contentId || selected.contentCollection !== "posts") {
|
|
539
|
-
setDetail(null);
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
let cancelled = false;
|
|
543
|
-
(async () => {
|
|
544
|
-
try {
|
|
545
|
-
const response = await apiPost<Record<string, unknown>>(ADMIN_ROUTES.CONTENT_CONTEXT, {
|
|
546
|
-
collection: selected.contentCollection,
|
|
547
|
-
id: selected.contentId
|
|
548
|
-
});
|
|
549
|
-
const data = await parseApiResponse<Record<string, unknown>>(response, "Failed to load details");
|
|
550
|
-
if (!cancelled) setDetail(data);
|
|
551
|
-
} catch {
|
|
552
|
-
if (!cancelled) setDetail(null);
|
|
553
|
-
}
|
|
554
|
-
})();
|
|
555
|
-
return () => {
|
|
556
|
-
cancelled = true;
|
|
557
|
-
};
|
|
558
|
-
}, [selected]);
|
|
559
|
-
|
|
560
|
-
return (
|
|
561
|
-
<Shell title="Pages" description="Explore all public pages and filter down to the content that needs attention.">
|
|
562
|
-
<ErrorBanner message={error} />
|
|
563
|
-
<Section title="Filters">
|
|
564
|
-
<div className="grid gap-4 md:grid-cols-4">
|
|
565
|
-
<Field label="Scope">
|
|
566
|
-
<Select
|
|
567
|
-
value={managed}
|
|
568
|
-
onChange={(value) => setManaged(value as typeof managed)}
|
|
569
|
-
options={[
|
|
570
|
-
{ value: "all", label: "All Pages" },
|
|
571
|
-
{ value: "managed", label: "Managed Only" },
|
|
572
|
-
{ value: "unmanaged", label: "Unmanaged Only" }
|
|
573
|
-
]}
|
|
574
|
-
/>
|
|
575
|
-
</Field>
|
|
576
|
-
<Field label="Page Type">
|
|
577
|
-
<Select
|
|
578
|
-
value={pageKind}
|
|
579
|
-
onChange={(value) => setPageKind(value as typeof pageKind)}
|
|
580
|
-
options={[
|
|
581
|
-
{ value: "all", label: "All Types" },
|
|
582
|
-
{ value: "blog_post", label: "Blog Post" },
|
|
583
|
-
{ value: "blog_archive", label: "Blog Archive" },
|
|
584
|
-
{ value: "tag", label: "Tag" },
|
|
585
|
-
{ value: "author", label: "Author" },
|
|
586
|
-
{ value: "landing", label: "Landing" },
|
|
587
|
-
{ value: "other", label: "Other" }
|
|
588
|
-
]}
|
|
589
|
-
/>
|
|
590
|
-
</Field>
|
|
591
|
-
<Field label="Opportunities Only">
|
|
592
|
-
<div className="flex h-10 items-center">
|
|
593
|
-
<input
|
|
594
|
-
type="checkbox"
|
|
595
|
-
checked={hasOpportunity}
|
|
596
|
-
onChange={(event) => setHasOpportunity(event.target.checked)}
|
|
597
|
-
className="h-4 w-4 rounded border-border"
|
|
598
|
-
/>
|
|
599
|
-
</div>
|
|
600
|
-
</Field>
|
|
601
|
-
<div className="flex items-end">
|
|
602
|
-
<Button variant="secondary" onClick={() => void load()} disabled={loading}>
|
|
603
|
-
Apply Filters
|
|
604
|
-
</Button>
|
|
605
|
-
</div>
|
|
606
|
-
</div>
|
|
607
|
-
</Section>
|
|
608
|
-
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.9fr)]">
|
|
609
|
-
<Section title="Page Table" subtitle={loading ? "Loading..." : `Showing ${pages.length} pages.`}>
|
|
610
|
-
<div className="overflow-x-auto">
|
|
611
|
-
<table className="min-w-full text-sm">
|
|
612
|
-
<thead className="text-left text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
|
613
|
-
<tr>
|
|
614
|
-
<th className="pb-3 pr-4">Page</th>
|
|
615
|
-
<th className="pb-3 pr-4">Managed</th>
|
|
616
|
-
<th className="pb-3 pr-4">GSC CTR</th>
|
|
617
|
-
<th className="pb-3 pr-4">GA Views</th>
|
|
618
|
-
<th className="pb-3 pr-4">Score</th>
|
|
619
|
-
</tr>
|
|
620
|
-
</thead>
|
|
621
|
-
<tbody>
|
|
622
|
-
{pages.map((page) => (
|
|
623
|
-
<tr
|
|
624
|
-
key={page.urlPath}
|
|
625
|
-
className={`cursor-pointer border-t border-border/80 transition hover:bg-accent/40 ${selected?.urlPath === page.urlPath ? "bg-accent/60" : ""}`}
|
|
626
|
-
onClick={() => setSelected(page)}
|
|
627
|
-
>
|
|
628
|
-
<td className="py-3 pr-4">
|
|
629
|
-
<div className="font-medium">{page.title}</div>
|
|
630
|
-
<div className="text-xs text-muted-foreground">{page.urlPath}</div>
|
|
631
|
-
</td>
|
|
632
|
-
<td className="py-3 pr-4">{page.managed ? "Yes" : "No"}</td>
|
|
633
|
-
<td className="py-3 pr-4">{formatPercent(page.gscCtr28d)}</td>
|
|
634
|
-
<td className="py-3 pr-4">{formatInteger(page.gaViews28d)}</td>
|
|
635
|
-
<td className="py-3 pr-4">{page.opportunityScore}</td>
|
|
636
|
-
</tr>
|
|
637
|
-
))}
|
|
638
|
-
</tbody>
|
|
639
|
-
</table>
|
|
640
|
-
</div>
|
|
641
|
-
</Section>
|
|
642
|
-
<Section title="Selected Page" subtitle="Managed pages also show query data and opportunity evidence.">
|
|
643
|
-
{!selected ? (
|
|
644
|
-
<div className="text-sm text-muted-foreground">Select a page from the table.</div>
|
|
645
|
-
) : (
|
|
646
|
-
<div className="space-y-4">
|
|
647
|
-
<div>
|
|
648
|
-
<div className="text-lg font-semibold">{selected.title}</div>
|
|
649
|
-
<div className="text-xs text-muted-foreground">{selected.urlPath}</div>
|
|
650
|
-
</div>
|
|
651
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
652
|
-
<StatCard label="GSC Impressions" value={formatInteger(selected.gscImpressions28d)} />
|
|
653
|
-
<StatCard label="GSC CTR" value={formatPercent(selected.gscCtr28d)} />
|
|
654
|
-
<StatCard label="GA Views" value={formatInteger(selected.gaViews28d)} />
|
|
655
|
-
<StatCard label="GA Engagement" value={formatPercent(selected.gaEngagementRate28d)} />
|
|
656
|
-
</div>
|
|
657
|
-
<div className="space-y-2">
|
|
658
|
-
<div className="text-sm font-medium">Opportunity Tags</div>
|
|
659
|
-
<div className="flex flex-wrap gap-2">
|
|
660
|
-
{selected.opportunityTags.length > 0 ? (
|
|
661
|
-
selected.opportunityTags.map((tag) => (
|
|
662
|
-
<span key={tag} className="rounded-full bg-accent px-2 py-1 text-xs font-medium">
|
|
663
|
-
{tag}
|
|
664
|
-
</span>
|
|
665
|
-
))
|
|
666
|
-
) : (
|
|
667
|
-
<span className="text-sm text-muted-foreground">No tags.</span>
|
|
668
|
-
)}
|
|
669
|
-
</div>
|
|
670
|
-
</div>
|
|
671
|
-
{detail ? <DetailBlock detail={detail} /> : null}
|
|
672
|
-
</div>
|
|
673
|
-
)}
|
|
674
|
-
</Section>
|
|
675
|
-
</div>
|
|
676
|
-
</Shell>
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function SettingsPage() {
|
|
681
|
-
const [draft, setDraft] = React.useState<ConfigDraft>(EMPTY_CONFIG);
|
|
682
|
-
const [storedConfig, setStoredConfig] = React.useState<StoredConfigFields>({
|
|
683
|
-
siteOrigin: "",
|
|
684
|
-
ga4PropertyId: "",
|
|
685
|
-
gscSiteUrl: ""
|
|
686
|
-
});
|
|
687
|
-
const [hasStoredServiceAccount, setHasStoredServiceAccount] = React.useState(false);
|
|
688
|
-
const [storedServiceAccountEmail, setStoredServiceAccountEmail] = React.useState<string | null>(null);
|
|
689
|
-
const [keys, setKeys] = React.useState<AgentKeyListItem[]>([]);
|
|
690
|
-
const [newKeyLabel, setNewKeyLabel] = React.useState("");
|
|
691
|
-
const [generatedKey, setGeneratedKey] = React.useState<string | null>(null);
|
|
692
|
-
const [loading, setLoading] = React.useState(true);
|
|
693
|
-
const [busy, setBusy] = React.useState<string | null>(null);
|
|
694
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
695
|
-
const [success, setSuccess] = React.useState<string | null>(null);
|
|
696
|
-
|
|
697
|
-
const load = React.useCallback(async () => {
|
|
698
|
-
setLoading(true);
|
|
699
|
-
setError(null);
|
|
700
|
-
try {
|
|
701
|
-
const [configRes, keysRes] = await Promise.all([
|
|
702
|
-
apiGet(ADMIN_ROUTES.CONFIG_GET),
|
|
703
|
-
apiGet(ADMIN_ROUTES.AGENT_KEYS_LIST)
|
|
704
|
-
]);
|
|
705
|
-
const [config, agentKeys] = await Promise.all([
|
|
706
|
-
parseApiResponse<PluginConfigSummary>(configRes, "Failed to load settings"),
|
|
707
|
-
parseApiResponse<AgentKeyListItem[]>(keysRes, "Failed to load API keys")
|
|
708
|
-
]);
|
|
709
|
-
setDraft({
|
|
710
|
-
siteOrigin: config.siteOrigin || "",
|
|
711
|
-
ga4PropertyId: config.ga4PropertyId || "",
|
|
712
|
-
gscSiteUrl: config.gscSiteUrl || "",
|
|
713
|
-
serviceAccountJson: ""
|
|
714
|
-
});
|
|
715
|
-
setStoredConfig({
|
|
716
|
-
siteOrigin: config.siteOrigin || "",
|
|
717
|
-
ga4PropertyId: config.ga4PropertyId || "",
|
|
718
|
-
gscSiteUrl: config.gscSiteUrl || ""
|
|
719
|
-
});
|
|
720
|
-
setHasStoredServiceAccount(!!config.hasServiceAccount);
|
|
721
|
-
setStoredServiceAccountEmail(config.serviceAccountEmail || null);
|
|
722
|
-
setKeys(agentKeys);
|
|
723
|
-
} catch (err) {
|
|
724
|
-
setError(err instanceof Error ? err.message : "Failed to load settings");
|
|
725
|
-
} finally {
|
|
726
|
-
setLoading(false);
|
|
727
|
-
}
|
|
728
|
-
}, []);
|
|
729
|
-
|
|
730
|
-
React.useEffect(() => {
|
|
731
|
-
void load();
|
|
732
|
-
}, [load]);
|
|
733
|
-
|
|
734
|
-
const save = async () => {
|
|
735
|
-
const validationMessage = validateSettingsDraft(draft, storedConfig, hasStoredServiceAccount);
|
|
736
|
-
if (validationMessage) {
|
|
737
|
-
setError(validationMessage);
|
|
738
|
-
setSuccess(null);
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
setBusy("save");
|
|
742
|
-
setError(null);
|
|
743
|
-
setSuccess(null);
|
|
744
|
-
try {
|
|
745
|
-
const payload = buildConfigPayload(draft);
|
|
746
|
-
const config = await parseApiResponse<PluginConfigSummary>(
|
|
747
|
-
await apiPost<PluginConfigSummary>(ADMIN_ROUTES.CONFIG_SAVE, payload),
|
|
748
|
-
"Failed to save settings"
|
|
749
|
-
);
|
|
750
|
-
setDraft((current) => ({
|
|
751
|
-
...current,
|
|
752
|
-
serviceAccountJson: ""
|
|
753
|
-
}));
|
|
754
|
-
setHasStoredServiceAccount(true);
|
|
755
|
-
setStoredServiceAccountEmail(config.serviceAccountEmail || storedServiceAccountEmail);
|
|
756
|
-
setStoredConfig({
|
|
757
|
-
siteOrigin: config.siteOrigin || storedConfig.siteOrigin,
|
|
758
|
-
ga4PropertyId: config.ga4PropertyId || storedConfig.ga4PropertyId,
|
|
759
|
-
gscSiteUrl: config.gscSiteUrl || storedConfig.gscSiteUrl
|
|
760
|
-
});
|
|
761
|
-
setSuccess("Settings saved.");
|
|
762
|
-
} catch (err) {
|
|
763
|
-
setError(err instanceof Error ? err.message : "Failed to save settings");
|
|
764
|
-
} finally {
|
|
765
|
-
setBusy(null);
|
|
766
|
-
}
|
|
767
|
-
};
|
|
768
|
-
|
|
769
|
-
const testConnection = async () => {
|
|
770
|
-
const validationMessage = validateSettingsDraft(draft, storedConfig, hasStoredServiceAccount);
|
|
771
|
-
if (validationMessage) {
|
|
772
|
-
setError(validationMessage);
|
|
773
|
-
setSuccess(null);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
setBusy("test");
|
|
777
|
-
setError(null);
|
|
778
|
-
setSuccess(null);
|
|
779
|
-
try {
|
|
780
|
-
const response = await apiPost<Record<string, unknown>>(ADMIN_ROUTES.CONNECTION_TEST, buildConfigPayload(draft));
|
|
781
|
-
await parseApiResponse<Record<string, unknown>>(response, "Connection test failed");
|
|
782
|
-
setSuccess("Connection test succeeded.");
|
|
783
|
-
} catch (err) {
|
|
784
|
-
setError(err instanceof Error ? err.message : "Connection test failed");
|
|
785
|
-
} finally {
|
|
786
|
-
setBusy(null);
|
|
787
|
-
}
|
|
788
|
-
};
|
|
789
|
-
|
|
790
|
-
const syncNow = async () => {
|
|
791
|
-
setBusy("sync");
|
|
792
|
-
setError(null);
|
|
793
|
-
setSuccess(null);
|
|
794
|
-
try {
|
|
795
|
-
const response = await apiPost<Record<string, unknown>>(ADMIN_ROUTES.SYNC_NOW);
|
|
796
|
-
await parseApiResponse<Record<string, unknown>>(response, "Manual sync failed");
|
|
797
|
-
setSuccess("Manual sync started. Reload Overview after it completes.");
|
|
798
|
-
} catch (err) {
|
|
799
|
-
setError(err instanceof Error ? err.message : "Manual sync failed");
|
|
800
|
-
} finally {
|
|
801
|
-
setBusy(null);
|
|
802
|
-
}
|
|
803
|
-
};
|
|
804
|
-
|
|
805
|
-
const createKey = async () => {
|
|
806
|
-
if (!newKeyLabel.trim()) return;
|
|
807
|
-
setBusy("create-key");
|
|
808
|
-
setError(null);
|
|
809
|
-
setSuccess(null);
|
|
810
|
-
try {
|
|
811
|
-
const response = await apiPost<AgentKeyCreateResponse>(ADMIN_ROUTES.AGENT_KEYS_CREATE, {
|
|
812
|
-
label: newKeyLabel.trim()
|
|
813
|
-
});
|
|
814
|
-
const data = await parseApiResponse<AgentKeyCreateResponse>(response, "Failed to create API key");
|
|
815
|
-
setGeneratedKey(data.key);
|
|
816
|
-
setNewKeyLabel("");
|
|
817
|
-
setSuccess("Created a new agent API key. This is the only time the raw key will be shown.");
|
|
818
|
-
await load();
|
|
819
|
-
} catch (err) {
|
|
820
|
-
setError(err instanceof Error ? err.message : "Failed to create API key");
|
|
821
|
-
} finally {
|
|
822
|
-
setBusy(null);
|
|
823
|
-
}
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
const revokeKey = async (prefix: string) => {
|
|
827
|
-
setBusy(prefix);
|
|
828
|
-
setError(null);
|
|
829
|
-
setSuccess(null);
|
|
830
|
-
try {
|
|
831
|
-
const response = await apiPost<{ success: true }>(ADMIN_ROUTES.AGENT_KEYS_REVOKE, { prefix });
|
|
832
|
-
await parseApiResponse<{ success: true }>(response, "Failed to revoke API key");
|
|
833
|
-
setSuccess(`Revoked ${prefix}.`);
|
|
834
|
-
await load();
|
|
835
|
-
} catch (err) {
|
|
836
|
-
setError(err instanceof Error ? err.message : "Failed to revoke API key");
|
|
837
|
-
} finally {
|
|
838
|
-
setBusy(null);
|
|
839
|
-
}
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
return (
|
|
843
|
-
<Shell
|
|
844
|
-
title="Analytics"
|
|
845
|
-
description="Manage Google connection settings, manual sync, and agent API keys."
|
|
846
|
-
>
|
|
847
|
-
<ErrorBanner message={error} />
|
|
848
|
-
<SuccessBanner message={success} />
|
|
849
|
-
<Section title="Google Connection">
|
|
850
|
-
<div className="grid gap-4 md:grid-cols-2">
|
|
851
|
-
<Field label="Canonical Site Origin" hint="Example: https://www.yourbright.co.jp">
|
|
852
|
-
<Input value={draft.siteOrigin} onChange={(value) => setDraft((current) => ({ ...current, siteOrigin: value }))} />
|
|
853
|
-
</Field>
|
|
854
|
-
<Field label="GA4 Property ID" hint="Enter the numeric property ID.">
|
|
855
|
-
<Input value={draft.ga4PropertyId} onChange={(value) => setDraft((current) => ({ ...current, ga4PropertyId: value }))} />
|
|
856
|
-
</Field>
|
|
857
|
-
<div className="md:col-span-2">
|
|
858
|
-
<Field label="Search Console Property" hint="Example: https://www.yourbright.co.jp/ or sc-domain:yourbright.co.jp">
|
|
859
|
-
<Input value={draft.gscSiteUrl} onChange={(value) => setDraft((current) => ({ ...current, gscSiteUrl: value }))} />
|
|
860
|
-
</Field>
|
|
861
|
-
</div>
|
|
862
|
-
<div className="md:col-span-2">
|
|
863
|
-
<Field
|
|
864
|
-
label="Service Account JSON"
|
|
865
|
-
hint={
|
|
866
|
-
hasStoredServiceAccount
|
|
867
|
-
? `Current: ${storedServiceAccountEmail || "configured"}. Leave blank to keep the current secret.`
|
|
868
|
-
: "Required on the first save."
|
|
869
|
-
}
|
|
870
|
-
>
|
|
871
|
-
<TextArea
|
|
872
|
-
value={draft.serviceAccountJson}
|
|
873
|
-
onChange={(value) => setDraft((current) => ({ ...current, serviceAccountJson: value }))}
|
|
874
|
-
placeholder='{"client_email":"...","private_key":"..."}'
|
|
875
|
-
rows={12}
|
|
876
|
-
/>
|
|
877
|
-
</Field>
|
|
878
|
-
</div>
|
|
879
|
-
</div>
|
|
880
|
-
<div className="mt-6 flex flex-wrap gap-3 border-t border-border pt-4">
|
|
881
|
-
<Button onClick={() => void save()} disabled={!!busy}>
|
|
882
|
-
{busy === "save" ? "Saving..." : "Save Settings"}
|
|
883
|
-
</Button>
|
|
884
|
-
<Button variant="secondary" onClick={() => void testConnection()} disabled={!!busy}>
|
|
885
|
-
{busy === "test" ? "Testing..." : "Test Connection"}
|
|
886
|
-
</Button>
|
|
887
|
-
<Button variant="secondary" onClick={() => void syncNow()} disabled={!!busy}>
|
|
888
|
-
{busy === "sync" ? "Syncing..." : "Run Manual Sync"}
|
|
889
|
-
</Button>
|
|
890
|
-
</div>
|
|
891
|
-
</Section>
|
|
892
|
-
<Section title="Agent API Keys" subtitle="Use these with Authorization: AgentKey yb_ins_... or X-Emdash-Agent-Key. Raw keys are shown only once.">
|
|
893
|
-
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
|
894
|
-
<Field label="New key label">
|
|
895
|
-
<Input value={newKeyLabel} onChange={setNewKeyLabel} placeholder="content-feedback-agent" />
|
|
896
|
-
</Field>
|
|
897
|
-
<div className="flex items-end">
|
|
898
|
-
<Button onClick={() => void createKey()} disabled={!!busy || !newKeyLabel.trim()}>
|
|
899
|
-
{busy === "create-key" ? "Creating..." : "Create Key"}
|
|
900
|
-
</Button>
|
|
901
|
-
</div>
|
|
902
|
-
</div>
|
|
903
|
-
{generatedKey ? (
|
|
904
|
-
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
|
905
|
-
<div className="text-sm font-medium text-amber-900">Generated Key</div>
|
|
906
|
-
<div className="mt-2 break-all font-mono text-sm text-amber-900">{generatedKey}</div>
|
|
907
|
-
</div>
|
|
908
|
-
) : null}
|
|
909
|
-
<div className="mt-4 overflow-x-auto">
|
|
910
|
-
<table className="min-w-full text-sm">
|
|
911
|
-
<thead className="text-left text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
|
912
|
-
<tr>
|
|
913
|
-
<th className="pb-3 pr-4">Prefix</th>
|
|
914
|
-
<th className="pb-3 pr-4">Label</th>
|
|
915
|
-
<th className="pb-3 pr-4">Created</th>
|
|
916
|
-
<th className="pb-3 pr-4">Last Used</th>
|
|
917
|
-
<th className="pb-3 pr-4">Status</th>
|
|
918
|
-
<th className="pb-3 pr-4"></th>
|
|
919
|
-
</tr>
|
|
920
|
-
</thead>
|
|
921
|
-
<tbody>
|
|
922
|
-
{keys.map((key) => (
|
|
923
|
-
<tr key={key.prefix} className="border-t border-border/80">
|
|
924
|
-
<td className="py-3 pr-4 font-mono text-xs">{key.prefix}</td>
|
|
925
|
-
<td className="py-3 pr-4">{key.label}</td>
|
|
926
|
-
<td className="py-3 pr-4">{formatDateTime(key.createdAt)}</td>
|
|
927
|
-
<td className="py-3 pr-4">{formatDateTime(key.lastUsedAt)}</td>
|
|
928
|
-
<td className="py-3 pr-4">{key.revokedAt ? "Revoked" : "Active"}</td>
|
|
929
|
-
<td className="py-3 pr-4">
|
|
930
|
-
{key.revokedAt ? null : (
|
|
931
|
-
<Button variant="danger" onClick={() => void revokeKey(key.prefix)} disabled={busy === key.prefix}>
|
|
932
|
-
Revoke
|
|
933
|
-
</Button>
|
|
934
|
-
)}
|
|
935
|
-
</td>
|
|
936
|
-
</tr>
|
|
937
|
-
))}
|
|
938
|
-
</tbody>
|
|
939
|
-
</table>
|
|
940
|
-
</div>
|
|
941
|
-
</Section>
|
|
942
|
-
</Shell>
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function ContentOpportunitiesWidget() {
|
|
947
|
-
const [overview, setOverview] = React.useState<OverviewResponse | null>(null);
|
|
948
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
949
|
-
|
|
950
|
-
React.useEffect(() => {
|
|
951
|
-
let cancelled = false;
|
|
952
|
-
(async () => {
|
|
953
|
-
try {
|
|
954
|
-
const response = await apiGet(ADMIN_ROUTES.OVERVIEW);
|
|
955
|
-
const data = await parseApiResponse<OverviewResponse>(response, "Failed to load widget data");
|
|
956
|
-
if (!cancelled) setOverview(data);
|
|
957
|
-
} catch (err) {
|
|
958
|
-
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load widget data");
|
|
959
|
-
}
|
|
960
|
-
})();
|
|
961
|
-
return () => {
|
|
962
|
-
cancelled = true;
|
|
963
|
-
};
|
|
964
|
-
}, []);
|
|
965
|
-
|
|
966
|
-
if (error) {
|
|
967
|
-
return <ErrorBanner message={error} />;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
const summary = overview?.summary;
|
|
971
|
-
return (
|
|
972
|
-
<div className="space-y-4">
|
|
973
|
-
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
974
|
-
<StatCard label="Tracked Pages" value={formatInteger(summary?.totals.trackedPages ?? 0)} />
|
|
975
|
-
<StatCard label="Managed Opportunities" value={formatInteger(summary?.totals.managedOpportunities ?? 0)} />
|
|
976
|
-
<StatCard label="GSC Clicks" value={formatInteger(summary?.totals.gscClicks28d ?? 0)} />
|
|
977
|
-
<StatCard label="GA Views" value={formatInteger(summary?.totals.gaViews28d ?? 0)} />
|
|
978
|
-
</div>
|
|
979
|
-
<MetricTable items={overview?.topOpportunities ?? []} emptyMessage="No opportunities yet." />
|
|
980
|
-
</div>
|
|
981
|
-
);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function DetailBlock({ detail }: { detail: Record<string, unknown> }) {
|
|
985
|
-
const analytics = isRecord(detail.analytics) ? detail.analytics : null;
|
|
986
|
-
const searchQueries = Array.isArray(analytics?.searchQueries) ? analytics.searchQueries : [];
|
|
987
|
-
const opportunities = Array.isArray(analytics?.opportunities) ? analytics.opportunities : [];
|
|
988
|
-
|
|
989
|
-
return (
|
|
990
|
-
<div className="space-y-4 border-t border-border pt-4">
|
|
991
|
-
<div className="space-y-2">
|
|
992
|
-
<div className="text-sm font-medium">Opportunity Evidence</div>
|
|
993
|
-
{opportunities.length > 0 ? (
|
|
994
|
-
<ul className="space-y-2 text-sm">
|
|
995
|
-
{opportunities.map((entry, index) => {
|
|
996
|
-
const row = isRecord(entry) ? entry : {};
|
|
997
|
-
return (
|
|
998
|
-
<li key={`${String(row.tag || index)}`} className="rounded-lg border border-border bg-background px-3 py-2">
|
|
999
|
-
<div className="font-medium">{String(row.tag || "-")}</div>
|
|
1000
|
-
<div className="text-muted-foreground">{String(row.reason || "-")}</div>
|
|
1001
|
-
</li>
|
|
1002
|
-
);
|
|
1003
|
-
})}
|
|
1004
|
-
</ul>
|
|
1005
|
-
) : (
|
|
1006
|
-
<div className="text-sm text-muted-foreground">No opportunity evidence yet.</div>
|
|
1007
|
-
)}
|
|
1008
|
-
</div>
|
|
1009
|
-
<div className="space-y-2">
|
|
1010
|
-
<div className="text-sm font-medium">Top Search Queries</div>
|
|
1011
|
-
{searchQueries.length > 0 ? (
|
|
1012
|
-
<div className="overflow-x-auto">
|
|
1013
|
-
<table className="min-w-full text-sm">
|
|
1014
|
-
<thead className="text-left text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
|
1015
|
-
<tr>
|
|
1016
|
-
<th className="pb-3 pr-4">Query</th>
|
|
1017
|
-
<th className="pb-3 pr-4">Clicks</th>
|
|
1018
|
-
<th className="pb-3 pr-4">Impressions</th>
|
|
1019
|
-
<th className="pb-3 pr-4">CTR</th>
|
|
1020
|
-
</tr>
|
|
1021
|
-
</thead>
|
|
1022
|
-
<tbody>
|
|
1023
|
-
{searchQueries.map((entry, index) => {
|
|
1024
|
-
const row = isRecord(entry) ? entry : {};
|
|
1025
|
-
return (
|
|
1026
|
-
<tr key={`${String(row.query || index)}`} className="border-t border-border/80">
|
|
1027
|
-
<td className="py-2 pr-4">{String(row.query || "-")}</td>
|
|
1028
|
-
<td className="py-2 pr-4">{formatInteger(numberValue(row.clicks28d))}</td>
|
|
1029
|
-
<td className="py-2 pr-4">{formatInteger(numberValue(row.impressions28d))}</td>
|
|
1030
|
-
<td className="py-2 pr-4">{formatPercent(numberValue(row.ctr28d))}</td>
|
|
1031
|
-
</tr>
|
|
1032
|
-
);
|
|
1033
|
-
})}
|
|
1034
|
-
</tbody>
|
|
1035
|
-
</table>
|
|
1036
|
-
</div>
|
|
1037
|
-
) : (
|
|
1038
|
-
<div className="text-sm text-muted-foreground">No query data yet.</div>
|
|
1039
|
-
)}
|
|
1040
|
-
</div>
|
|
1041
|
-
</div>
|
|
1042
|
-
);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
function WindowCard({
|
|
1046
|
-
label,
|
|
1047
|
-
value
|
|
1048
|
-
}: {
|
|
1049
|
-
label: string;
|
|
1050
|
-
value?: { startDate: string; endDate: string };
|
|
1051
|
-
}) {
|
|
1052
|
-
return (
|
|
1053
|
-
<div className="rounded-xl border border-border bg-background p-4">
|
|
1054
|
-
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
|
1055
|
-
<div className="mt-2 text-sm font-medium">
|
|
1056
|
-
{value ? `${value.startDate} - ${value.endDate}` : "-"}
|
|
1057
|
-
</div>
|
|
1058
|
-
</div>
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function formatInteger(value: number | null | undefined): string {
|
|
1063
|
-
return new Intl.NumberFormat("ja-JP").format(value ?? 0);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
function formatPercent(value: number | null | undefined): string {
|
|
1067
|
-
return `${((value ?? 0) * 100).toFixed(1)}%`;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
function formatDateTime(value: string | null | undefined): string {
|
|
1071
|
-
if (!value) return "-";
|
|
1072
|
-
const date = new Date(value);
|
|
1073
|
-
if (Number.isNaN(date.getTime())) return value;
|
|
1074
|
-
return date.toLocaleString("ja-JP", {
|
|
1075
|
-
year: "numeric",
|
|
1076
|
-
month: "2-digit",
|
|
1077
|
-
day: "2-digit",
|
|
1078
|
-
hour: "2-digit",
|
|
1079
|
-
minute: "2-digit"
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function pageKindLabel(pageKind: PageKind): string {
|
|
1084
|
-
switch (pageKind) {
|
|
1085
|
-
case "blog_post":
|
|
1086
|
-
return "Blog Post";
|
|
1087
|
-
case "blog_archive":
|
|
1088
|
-
return "Blog Archive";
|
|
1089
|
-
case "tag":
|
|
1090
|
-
return "Tag";
|
|
1091
|
-
case "author":
|
|
1092
|
-
return "Author";
|
|
1093
|
-
case "landing":
|
|
1094
|
-
return "Landing";
|
|
1095
|
-
default:
|
|
1096
|
-
return "Other";
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function statusLabel(status: FreshnessState["lastStatus"]): string {
|
|
1101
|
-
switch (status) {
|
|
1102
|
-
case "success":
|
|
1103
|
-
return "Healthy";
|
|
1104
|
-
case "degraded":
|
|
1105
|
-
return "Degraded";
|
|
1106
|
-
case "error":
|
|
1107
|
-
return "Failed";
|
|
1108
|
-
default:
|
|
1109
|
-
return "Idle";
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
function idleFreshness(): FreshnessState {
|
|
1114
|
-
return {
|
|
1115
|
-
lastSyncedAt: null,
|
|
1116
|
-
lastGscDate: null,
|
|
1117
|
-
lastGaDate: null,
|
|
1118
|
-
lastStatus: "idle"
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1123
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
function numberValue(value: unknown): number {
|
|
1127
|
-
return typeof value === "number" ? value : 0;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
export const pages: PluginAdminExports["pages"] = {
|
|
1131
|
-
"/": OverviewPage,
|
|
1132
|
-
"/pages": PagesPage,
|
|
1133
|
-
"/settings": SettingsPage
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
export const widgets: PluginAdminExports["widgets"] = {
|
|
1137
|
-
"content-opportunities": ContentOpportunitiesWidget
|
|
1138
|
-
};
|