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