@broberg/app-shell 0.5.0 → 0.7.1

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/preact.cjs CHANGED
@@ -226,6 +226,80 @@ function createFetchAccountAdapter(opts = {}) {
226
226
  removeAvatar: () => call("/profile/avatar/remove", { method: "POST" })
227
227
  };
228
228
  }
229
+ var AnalyticsError = class extends Error {
230
+ constructor(status, code) {
231
+ super(code ? `HTTP ${status} ${code}` : `HTTP ${status}`);
232
+ this.status = status;
233
+ this.code = code;
234
+ this.name = "AnalyticsError";
235
+ }
236
+ status;
237
+ code;
238
+ };
239
+ function analyticsErrorKind(err) {
240
+ return err instanceof AnalyticsError && err.status === 503 && err.code === "analytics_unconfigured" ? "unconfigured" : "unavailable";
241
+ }
242
+ function createFetchAnalyticsAdapter(opts = {}) {
243
+ const url = opts.url ?? "/api/analytics";
244
+ return {
245
+ async load(days) {
246
+ let res;
247
+ try {
248
+ res = await (opts.fetch ?? fetch)(`${url}${url.includes("?") ? "&" : "?"}days=${encodeURIComponent(String(days))}`, {
249
+ credentials: opts.credentials ?? "same-origin",
250
+ headers: { accept: "application/json" }
251
+ });
252
+ } catch {
253
+ throw new AnalyticsError(0);
254
+ }
255
+ const body = await res.json().catch(() => null);
256
+ if (!res.ok) throw new AnalyticsError(res.status, typeof body?.error === "string" ? body.error : void 0);
257
+ const ok = body !== null && typeof body.totals?.visitors === "number" && Array.isArray(body.series) && Array.isArray(body.topPages);
258
+ if (!ok) throw new AnalyticsError(res.status, "bad_response");
259
+ return body;
260
+ }
261
+ };
262
+ }
263
+ function copenhagenDay(at = /* @__PURE__ */ new Date()) {
264
+ return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Copenhagen", year: "numeric", month: "2-digit", day: "2-digit" }).format(at);
265
+ }
266
+ function fillDays(series, days, now = /* @__PURE__ */ new Date()) {
267
+ const byDay = new Map(series.map((d2) => [d2.day, d2]));
268
+ const [y, m, d] = copenhagenDay(now).split("-").map(Number);
269
+ return Array.from({ length: days }, (_, i) => {
270
+ const day = new Date(Date.UTC(y, m - 1, d - (days - 1 - i))).toISOString().slice(0, 10);
271
+ return byDay.get(day) ?? { day, visitors: 0, pageviews: 0 };
272
+ });
273
+ }
274
+ function changePct(now, before) {
275
+ return before === 0 ? null : Math.round((now - before) / before * 100);
276
+ }
277
+ var TenantLoadError = class extends Error {
278
+ constructor(status) {
279
+ super(`HTTP ${status}`);
280
+ this.status = status;
281
+ this.name = "TenantLoadError";
282
+ }
283
+ status;
284
+ };
285
+ function createFetchTenantAdapter(opts = {}) {
286
+ return {
287
+ async load() {
288
+ let res;
289
+ try {
290
+ res = await (opts.fetch ?? fetch)(opts.url ?? "/api/me/memberships", { credentials: opts.credentials ?? "same-origin", headers: { accept: "application/json" } });
291
+ } catch {
292
+ throw new TenantLoadError(0);
293
+ }
294
+ const body = await res.json().catch(() => null);
295
+ if (!res.ok) throw new TenantLoadError(res.status);
296
+ if (!Array.isArray(body) || !body.every((r) => r && typeof r.role === "string" && r.tenant && typeof r.tenant.slug === "string")) {
297
+ throw new TenantLoadError(res.status);
298
+ }
299
+ return body;
300
+ }
301
+ };
302
+ }
229
303
  var TEXT = {
230
304
  da: {
231
305
  openMenu: "\xC5bn menu",
@@ -273,7 +347,27 @@ var TEXT = {
273
347
  pictureRemoved: "Billedet er fjernet",
274
348
  pictureTooLarge: "Billedet er for stort \u2014 h\xF8jst 2 MB.",
275
349
  pictureWrongType: "V\xE6lg et PNG-, JPEG- eller WebP-billede.",
276
- bidSecurity: "Sikkerhed i Broberg ID"
350
+ bidSecurity: "Sikkerhed i Broberg ID",
351
+ analyticsPeriod: "Periode",
352
+ analyticsDays: "{n} dage",
353
+ analyticsVisitors: "Bes\xF8gende",
354
+ analyticsPageviews: "Sidevisninger",
355
+ analyticsVsPrev: "{pct} % mod forrige periode",
356
+ analyticsNoPrev: "Ingen sammenligning",
357
+ analyticsOverTime: "Sidevisninger pr. dag",
358
+ analyticsTopPages: "Mest bes\xF8gte sider",
359
+ analyticsDay: "Dag",
360
+ analyticsPage: "Side",
361
+ analyticsEmpty: "Ingen bes\xF8g i perioden.",
362
+ analyticsLoading: "Henter tal\u2026",
363
+ analyticsUnconfigured: "Analytics er ikke sat op for denne app.",
364
+ analyticsUnavailable: "Upmetrics svarer ikke lige nu. Pr\xF8v igen om lidt.",
365
+ tenantSwitch: "Skift organisation",
366
+ tenantLoadFailed: "Kunne ikke hente dine organisationer.",
367
+ tenantSwitchFailed: "Kunne ikke skifte organisation. Du er stadig i {name}.",
368
+ tenantRetry: "Pr\xF8v igen",
369
+ tenantSuspended: "lukket",
370
+ tenantActive: "aktiv"
277
371
  },
278
372
  en: {
279
373
  openMenu: "Open menu",
@@ -321,7 +415,27 @@ var TEXT = {
321
415
  pictureRemoved: "Picture removed",
322
416
  pictureTooLarge: "The picture is too large \u2014 2 MB at most.",
323
417
  pictureWrongType: "Choose a PNG, JPEG or WebP image.",
324
- bidSecurity: "Security in Broberg ID"
418
+ bidSecurity: "Security in Broberg ID",
419
+ analyticsPeriod: "Period",
420
+ analyticsDays: "{n} days",
421
+ analyticsVisitors: "Visitors",
422
+ analyticsPageviews: "Page views",
423
+ analyticsVsPrev: "{pct}% vs previous period",
424
+ analyticsNoPrev: "No comparison",
425
+ analyticsOverTime: "Page views per day",
426
+ analyticsTopPages: "Most visited pages",
427
+ analyticsDay: "Day",
428
+ analyticsPage: "Page",
429
+ analyticsEmpty: "No visits in this period.",
430
+ analyticsLoading: "Loading figures\u2026",
431
+ analyticsUnconfigured: "Analytics is not set up for this app.",
432
+ analyticsUnavailable: "Upmetrics is not answering right now. Try again shortly.",
433
+ tenantSwitch: "Switch organisation",
434
+ tenantLoadFailed: "Could not load your organisations.",
435
+ tenantSwitchFailed: "Could not switch organisation. You are still in {name}.",
436
+ tenantRetry: "Try again",
437
+ tenantSuspended: "suspended",
438
+ tenantActive: "active"
325
439
  }
326
440
  };
327
441
  var MOBILE_QUERY = "(max-width: 767px)";
@@ -1130,7 +1244,7 @@ function AccountPage({ lang, adapter, reauthHref, onProfileChange }) {
1130
1244
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", class: "bas-account__btn is-quiet", onClick: () => setConfirming(false), "data-testid": "account-avatar-remove-cancel", children: t.cancel })
1131
1245
  ] }) : null
1132
1246
  ] }),
1133
- /* @__PURE__ */ jsxRuntime.jsxs("form", { class: "bas-account__form", onSubmit: onSaveName, children: [
1247
+ /* @__PURE__ */ jsxRuntime.jsxs("form", { class: "bas-account__form", onSubmit: onSaveName, "data-testid": "account-name-form", children: [
1134
1248
  /* @__PURE__ */ jsxRuntime.jsx("label", { class: "bas-account__label", for: "bas-account-name", children: t.name }),
1135
1249
  /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-account__row", children: [
1136
1250
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -1157,11 +1271,183 @@ function AccountPage({ lang, adapter, reauthHref, onProfileChange }) {
1157
1271
  ] }) : null
1158
1272
  ] });
1159
1273
  }
1274
+ function AnalyticsPage({ lang, adapter, periods = [7, 30, 90], initialDays }) {
1275
+ const t = TEXT[lang];
1276
+ const api = hooks.useMemo(() => adapter ?? createFetchAnalyticsAdapter(), [adapter]);
1277
+ const [days, setDays] = hooks.useState(initialDays ?? (periods.includes(30) ? 30 : periods[0]));
1278
+ const [load, setLoad] = hooks.useState({ state: "loading" });
1279
+ const locale = lang === "da" ? "da-DK" : "en-GB";
1280
+ const num = (n) => n.toLocaleString(locale);
1281
+ hooks.useEffect(() => {
1282
+ let live = true;
1283
+ setLoad({ state: "loading" });
1284
+ api.load(days).then(
1285
+ (data) => live && setLoad({ state: "ok", days, data }),
1286
+ (e) => live && setLoad({ state: "error", reason: analyticsErrorKind(e) })
1287
+ );
1288
+ return () => {
1289
+ live = false;
1290
+ };
1291
+ }, [api, days]);
1292
+ return /* @__PURE__ */ jsxRuntime.jsxs("section", { class: "bas-analytics", "data-testid": "analytics-page", "aria-busy": load.state === "loading", children: [
1293
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__periods", role: "group", "aria-label": t.analyticsPeriod, children: periods.map((d) => /* @__PURE__ */ jsxRuntime.jsx(
1294
+ "button",
1295
+ {
1296
+ type: "button",
1297
+ class: "bas-analytics__period" + (d === days ? " is-active" : ""),
1298
+ "aria-pressed": d === days,
1299
+ onClick: () => setDays(d),
1300
+ "data-testid": `analytics-period-${d}`,
1301
+ children: t.analyticsDays.replace("{n}", String(d))
1302
+ },
1303
+ d
1304
+ )) }),
1305
+ load.state === "loading" ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-analytics__muted", "data-testid": "analytics-loading", children: t.analyticsLoading }) : null,
1306
+ load.state === "error" ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-analytics__note", role: "alert", "data-testid": "analytics-error", "data-reason": load.reason, children: load.reason === "unconfigured" ? t.analyticsUnconfigured : t.analyticsUnavailable }) : null,
1307
+ load.state === "ok" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1308
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__tiles", children: ["visitors", "pageviews"].map((k) => {
1309
+ const c = changePct(load.data.totals[k], load.data.prev[k]);
1310
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-analytics__tile", "data-testid": `analytics-${k}`, children: [
1311
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__label", children: k === "visitors" ? t.analyticsVisitors : t.analyticsPageviews }),
1312
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__value", "data-testid": `analytics-${k}-value`, children: num(load.data.totals[k]) }),
1313
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__muted", "data-testid": `analytics-${k}-change`, children: c === null ? t.analyticsNoPrev : t.analyticsVsPrev.replace("{pct}", `${c > 0 ? "+" : ""}${c}`) })
1314
+ ] }, k);
1315
+ }) }),
1316
+ /* @__PURE__ */ jsxRuntime.jsx(AnalyticsSeries, { data: fillDays(load.data.series, load.days), t, locale, num }),
1317
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-analytics__card", "data-testid": "analytics-top-pages", children: [
1318
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { class: "bas-analytics__h", children: t.analyticsTopPages }),
1319
+ load.data.topPages.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-analytics__muted", "data-testid": "analytics-top-pages-empty", children: t.analyticsEmpty }) : /* @__PURE__ */ jsxRuntime.jsx("ol", { class: "bas-analytics__pages", children: load.data.topPages.map((p) => /* @__PURE__ */ jsxRuntime.jsxs("li", { class: "bas-analytics__page", children: [
1320
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-analytics__path", title: p.value, children: p.value }),
1321
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-analytics__count", children: num(p.count) })
1322
+ ] }, p.value)) })
1323
+ ] })
1324
+ ] }) : null
1325
+ ] });
1326
+ }
1327
+ function dayLabel(day, locale) {
1328
+ return (/* @__PURE__ */ new Date(`${day}T12:00:00Z`)).toLocaleDateString(locale, { day: "numeric", month: "short", timeZone: "UTC" });
1329
+ }
1330
+ function AnalyticsSeries({ data, t, locale, num }) {
1331
+ const max = Math.max(1, ...data.map((d) => d.pageviews));
1332
+ const empty = data.every((d) => d.pageviews === 0);
1333
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-analytics__card", "data-testid": "analytics-series", children: [
1334
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { class: "bas-analytics__h", children: t.analyticsOverTime }),
1335
+ empty ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-analytics__muted", "data-testid": "analytics-series-empty", children: t.analyticsEmpty }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1336
+ /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__bars", "aria-hidden": "true", children: data.map((d) => /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__col", title: `${dayLabel(d.day, locale)}: ${num(d.pageviews)}`, "data-day": d.day, children: /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-analytics__bar", style: { height: d.pageviews === 0 ? "0" : `max(2px, ${d.pageviews / max * 100}%)` } }) }, d.day)) }),
1337
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-analytics__axis", "aria-hidden": "true", children: [
1338
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: dayLabel(data[0].day, locale) }),
1339
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: dayLabel(data[data.length - 1].day, locale) })
1340
+ ] }),
1341
+ /* @__PURE__ */ jsxRuntime.jsxs("table", { class: "bas-analytics__sr", "data-testid": "analytics-series-table", children: [
1342
+ /* @__PURE__ */ jsxRuntime.jsx("caption", { children: t.analyticsOverTime }),
1343
+ /* @__PURE__ */ jsxRuntime.jsx("thead", { children: /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
1344
+ /* @__PURE__ */ jsxRuntime.jsx("th", { scope: "col", children: t.analyticsDay }),
1345
+ /* @__PURE__ */ jsxRuntime.jsx("th", { scope: "col", children: t.analyticsPageviews })
1346
+ ] }) }),
1347
+ /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: data.map((d) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
1348
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: dayLabel(d.day, locale) }),
1349
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: num(d.pageviews) })
1350
+ ] }, d.day)) })
1351
+ ] })
1352
+ ] })
1353
+ ] });
1354
+ }
1355
+ function TenantSwitcher({ lang, activeSlug, adapter, onSwitch }) {
1356
+ const t = TEXT[lang];
1357
+ const api = hooks.useMemo(() => adapter ?? createFetchTenantAdapter(), [adapter]);
1358
+ const [rows, setRows] = hooks.useState(null);
1359
+ const [loadFailed, setLoadFailed] = hooks.useState(false);
1360
+ const [open, setOpen] = hooks.useState(false);
1361
+ const [busy, setBusy] = hooks.useState(null);
1362
+ const [switchFailed, setSwitchFailed] = hooks.useState(false);
1363
+ const root = hooks.useRef(null);
1364
+ const load = () => {
1365
+ setLoadFailed(false);
1366
+ api.load().then(setRows, () => setLoadFailed(true));
1367
+ };
1368
+ hooks.useEffect(load, [api]);
1369
+ hooks.useEffect(() => {
1370
+ if (!open) return;
1371
+ const onDown = (e) => {
1372
+ if (root.current && !root.current.contains(e.target)) setOpen(false);
1373
+ };
1374
+ const onKey = (e) => e.key === "Escape" && setOpen(false);
1375
+ document.addEventListener("mousedown", onDown);
1376
+ document.addEventListener("keydown", onKey);
1377
+ return () => {
1378
+ document.removeEventListener("mousedown", onDown);
1379
+ document.removeEventListener("keydown", onKey);
1380
+ };
1381
+ }, [open]);
1382
+ const active = rows?.find((r) => r.tenant.slug === activeSlug);
1383
+ const label = active?.tenant.name ?? activeSlug;
1384
+ const pick = async (slug) => {
1385
+ if (slug === activeSlug || busy) return;
1386
+ setBusy(slug);
1387
+ setSwitchFailed(false);
1388
+ try {
1389
+ await onSwitch(slug);
1390
+ setOpen(false);
1391
+ } catch {
1392
+ setSwitchFailed(true);
1393
+ } finally {
1394
+ setBusy(null);
1395
+ }
1396
+ };
1397
+ if (rows && rows.length <= 1 && !loadFailed) {
1398
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { class: "bas-tenant", "data-testid": "tenant-switcher", children: /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__label", "data-testid": "tenant-switcher-label", children: label }) });
1399
+ }
1400
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-tenant", ref: root, "data-testid": "tenant-switcher", children: [
1401
+ /* @__PURE__ */ jsxRuntime.jsx(
1402
+ "button",
1403
+ {
1404
+ type: "button",
1405
+ class: "bas-tenant__btn" + (open ? " is-open" : ""),
1406
+ "aria-haspopup": "menu",
1407
+ "aria-expanded": open,
1408
+ "aria-label": t.tenantSwitch,
1409
+ onClick: () => setOpen((v) => !v),
1410
+ "data-testid": "tenant-switcher-button",
1411
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__label", "data-testid": "tenant-switcher-label", children: label })
1412
+ }
1413
+ ),
1414
+ open ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-menu bas-tenant__menu", role: "menu", "data-testid": "tenant-switcher-menu", children: [
1415
+ loadFailed ? /* @__PURE__ */ jsxRuntime.jsxs("div", { class: "bas-tenant__note", role: "alert", "data-testid": "tenant-switcher-load-error", children: [
1416
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: t.tenantLoadFailed }),
1417
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", class: "bas-mi", onClick: load, "data-testid": "tenant-switcher-retry", children: t.tenantRetry })
1418
+ ] }) : null,
1419
+ switchFailed ? /* @__PURE__ */ jsxRuntime.jsx("p", { class: "bas-tenant__note is-error", role: "alert", "data-testid": "tenant-switcher-switch-error", children: t.tenantSwitchFailed.replace("{name}", label) }) : null,
1420
+ (rows ?? []).map((r) => {
1421
+ const isActive = r.tenant.slug === activeSlug;
1422
+ const suspended = r.tenant.status === "suspended";
1423
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1424
+ "button",
1425
+ {
1426
+ type: "button",
1427
+ role: "menuitemradio",
1428
+ "aria-checked": isActive,
1429
+ class: "bas-mi bas-tenant__item" + (isActive ? " is-active" : ""),
1430
+ disabled: suspended || busy !== null,
1431
+ onClick: () => void pick(r.tenant.slug),
1432
+ "data-testid": `tenant-switcher-item-${r.tenant.slug}`,
1433
+ children: [
1434
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__name", children: r.tenant.name }),
1435
+ /* @__PURE__ */ jsxRuntime.jsx("span", { class: "bas-tenant__role", children: suspended ? t.tenantSuspended : isActive ? t.tenantActive : r.role })
1436
+ ]
1437
+ },
1438
+ r.tenant.id
1439
+ );
1440
+ })
1441
+ ] }) : null
1442
+ ] });
1443
+ }
1160
1444
 
1161
1445
  exports.AVATAR_MAX_BYTES = AVATAR_MAX_BYTES;
1162
1446
  exports.AVATAR_TYPES = AVATAR_TYPES;
1163
1447
  exports.AccountError = AccountError;
1164
1448
  exports.AccountPage = AccountPage;
1449
+ exports.AnalyticsError = AnalyticsError;
1450
+ exports.AnalyticsPage = AnalyticsPage;
1165
1451
  exports.AppShell = AppShell;
1166
1452
  exports.Avatar = Avatar;
1167
1453
  exports.NotificationBell = NotificationBell;
@@ -1169,16 +1455,24 @@ exports.PageHeader = PageHeader;
1169
1455
  exports.PageTabs = PageTabs;
1170
1456
  exports.Sidebar = Sidebar;
1171
1457
  exports.TEXT = TEXT;
1458
+ exports.TenantLoadError = TenantLoadError;
1459
+ exports.TenantSwitcher = TenantSwitcher;
1172
1460
  exports.TopBar = TopBar;
1173
1461
  exports.UserMenu = UserMenu;
1174
1462
  exports.accountErrorKind = accountErrorKind;
1175
1463
  exports.activeNavLabel = activeNavLabel;
1464
+ exports.analyticsErrorKind = analyticsErrorKind;
1176
1465
  exports.badgeLabel = badgeLabel;
1466
+ exports.changePct = changePct;
1177
1467
  exports.checkAvatarFile = checkAvatarFile;
1468
+ exports.copenhagenDay = copenhagenDay;
1178
1469
  exports.createFetchAccountAdapter = createFetchAccountAdapter;
1470
+ exports.createFetchAnalyticsAdapter = createFetchAnalyticsAdapter;
1179
1471
  exports.createFetchNotificationSource = createFetchNotificationSource;
1472
+ exports.createFetchTenantAdapter = createFetchTenantAdapter;
1180
1473
  exports.createMemoryNotificationSource = createMemoryNotificationSource;
1181
1474
  exports.defaultReauthHref = defaultReauthHref;
1475
+ exports.fillDays = fillDays;
1182
1476
  exports.initials = initials;
1183
1477
  exports.isActivePath = isActivePath;
1184
1478
  exports.isExternal = isExternal;