@planningcenter/topbar 12.3.0 → 12.4.0-rc.0

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.
@@ -6,7 +6,7 @@ import * as general from '@planningcenter/icons/paths/general';
6
6
  import Jolt from '@planningcenter/jolt-client';
7
7
  import { useSyncExternalStore } from 'use-sync-external-store/shim';
8
8
  import 'react/jsx-runtime';
9
- import { ThemeProvider, Divider, Button as Button$1, StackView as StackView$2, Text, Icon, designSystem, Box, Heading, GridView, Avatar as Avatar$1, Link as Link$1, HeadingUppercase, List, Combobox, Logo, Dropdown, Spinner, Badge } from '@planningcenter/tapestry-react';
9
+ import { ThemeProvider, Divider, Button as Button$1, StackView as StackView$2, Text, Icon as Icon$1, designSystem, Box, Heading, GridView, Avatar as Avatar$1, Link as Link$1, HeadingUppercase, List, Combobox, Logo, Dropdown, Spinner, Badge } from '@planningcenter/tapestry-react';
10
10
  import { components, UiExtensionFirstPartyComponent, UiExtensionDataContext } from '@planningcenter/add-ons';
11
11
  import * as churchCenter from '@planningcenter/icons/paths/church-center';
12
12
  import * as people from '@planningcenter/icons/paths/people';
@@ -156,12 +156,14 @@ function inferRailsCsrfToken() {
156
156
  }
157
157
  function apiRequest(fetch, url, {
158
158
  method = "GET",
159
- data = null
159
+ data = null,
160
+ headers = {}
160
161
  } = {}) {
161
162
  const options = {
162
163
  headers: {
163
164
  Accept: "application/json",
164
- "Content-Type": "application/json"
165
+ "Content-Type": "application/json",
166
+ ...headers
165
167
  },
166
168
  method,
167
169
  redirect: "follow"
@@ -781,7 +783,7 @@ function useMediaQuery(query) {
781
783
  return matches;
782
784
  }
783
785
 
784
- const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
786
+ const capitalize$1 = s => s.charAt(0).toUpperCase() + s.slice(1);
785
787
  const RESOLVED_COLOR_MODE_ATTRIBUTE = "data-resolved-color-mode";
786
788
  function resolveColorMode(colorMode) {
787
789
  if (colorMode === "system") {
@@ -3305,19 +3307,513 @@ function ProductTabs({
3305
3307
  }, children));
3306
3308
  }
3307
3309
 
3310
+ function parseValue(element) {
3311
+ const datatype = element.getAttribute("datatype");
3312
+ switch (datatype) {
3313
+ case "boolean":
3314
+ return element.content === "true" || element.content === "1";
3315
+ default:
3316
+ return element.content;
3317
+ }
3318
+ }
3319
+ function getAllPcoMeta() {
3320
+ const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3321
+ const meta = {};
3322
+ elements.forEach(e => {
3323
+ const property = e.getAttribute("property");
3324
+ meta[property.substring("pco:".length)] = parseValue(e);
3325
+ });
3326
+ return meta;
3327
+ }
3328
+ // usePcoMeta only reads each tag's content. Recently Visited also needs the
3329
+ // pco:recently_visitable tag's `metadata` attribute, which can change while
3330
+ // the content stays the same, so it is tracked in its own store.
3331
+ function getRecentlyVisitableMetadata() {
3332
+ var _a, _b;
3333
+ return (_b = (_a = document.head.querySelector(`meta[property="pco:recently_visitable"]`)) === null || _a === void 0 ? void 0 : _a.getAttribute("metadata")) !== null && _b !== void 0 ? _b : null;
3334
+ }
3335
+ const store = createStore(getAllPcoMeta());
3336
+ const recentlyVisitableMetadataStore = createStore({
3337
+ metadata: getRecentlyVisitableMetadata()
3338
+ });
3339
+ function sync() {
3340
+ const nextState = getAllPcoMeta();
3341
+ if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3342
+ const metadata = getRecentlyVisitableMetadata();
3343
+ if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3344
+ recentlyVisitableMetadataStore.setState({
3345
+ metadata
3346
+ });
3347
+ }
3348
+ }
3349
+ const observer = new MutationObserver(sync);
3350
+ let hasTriggeredObserve = false;
3351
+ function observe() {
3352
+ if (hasTriggeredObserve) return;
3353
+ hasTriggeredObserve = true;
3354
+ // The module-load snapshot above can predate meta tags that appear later
3355
+ // in <head> (e.g. pco:resource, yielded right before </head>) if this
3356
+ // module's own script tag isn't deferred and runs mid-parse. Those tags
3357
+ // never mutate afterward, so the observer alone would never catch them.
3358
+ // Re-read once now, since observe() is only called once a consumer is
3359
+ // actually mounting, which is always after the document has fully parsed.
3360
+ sync();
3361
+ observer.observe(document.head, {
3362
+ attributes: true,
3363
+ childList: true,
3364
+ subtree: true
3365
+ });
3366
+ }
3367
+ function usePcoMeta() {
3368
+ observe();
3369
+ return useSyncExternalStore(store.subscribe, () => store.getState());
3370
+ }
3371
+ function useRecentlyVisitableMetadata() {
3372
+ observe();
3373
+ return useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3374
+ }
3375
+
3376
+ const MAX_ENTRIES$1 = 20;
3377
+ const FORMAT_VERSION = 1;
3378
+ const MAX_TEXT_LENGTH = 200;
3379
+ const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3380
+ function isId(value) {
3381
+ return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3382
+ }
3383
+ // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3384
+ function isPcrl(value) {
3385
+ const segments = value.split("/");
3386
+ return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3387
+ }
3388
+ function isNonBlankString(value) {
3389
+ return typeof value === "string" && value.trim() !== "";
3390
+ }
3391
+ function isRecord(value) {
3392
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3393
+ }
3394
+ function entryKey(entry) {
3395
+ if (entry.type === "resource") {
3396
+ return `resource:${entry.pcrl}`;
3397
+ } else {
3398
+ return `page:${entry.product}:${entry.path}`;
3399
+ }
3400
+ }
3401
+ function isEntry(value) {
3402
+ if (!isRecord(value)) {
3403
+ return false;
3404
+ } else if (value.type === "resource") {
3405
+ return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3406
+ } else if (value.type === "page") {
3407
+ return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3408
+ } else {
3409
+ return false;
3410
+ }
3411
+ }
3412
+ // Cuts on code points so a surrogate pair is never split.
3413
+ function clampText(value) {
3414
+ return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3415
+ }
3416
+ function clampEntry(entry) {
3417
+ if (entry.type === "resource") {
3418
+ return entry;
3419
+ } else {
3420
+ return {
3421
+ ...entry,
3422
+ details: clampText(entry.details),
3423
+ title: clampText(entry.title)
3424
+ };
3425
+ }
3426
+ }
3427
+ // Recents are disposable: anything unexpected reads as an empty list (or, for
3428
+ // a single bad entry, a list without it) rather than throwing.
3429
+ function parseRecentlyVisited(value, personId) {
3430
+ let parsed = null;
3431
+ try {
3432
+ parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
3433
+ } catch {
3434
+ parsed = null;
3435
+ }
3436
+ if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
3437
+ return parsed.entries.filter(isEntry);
3438
+ } else {
3439
+ return [];
3440
+ }
3441
+ }
3442
+ function addVisit(entries, visit) {
3443
+ const key = entryKey(visit);
3444
+ return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES$1);
3445
+ }
3446
+ function serializeRecentlyVisited(entries, personId) {
3447
+ return JSON.stringify({
3448
+ entries,
3449
+ person: personId,
3450
+ v: FORMAT_VERSION
3451
+ });
3452
+ }
3453
+
3454
+ // The product whose URL, in this environment, has the current page's host.
3455
+ // Reversing pcoUrl handles every environment @planningcenter/url knows about,
3456
+ // and guarantees the stored product rebuilds a working link.
3457
+ function currentProduct() {
3458
+ var _a;
3459
+ return (_a = PRODUCTS.find(product => new URL(pcoUrl$2(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
3460
+ }
3461
+ function detailsFromMetadata(metadata) {
3462
+ var _a;
3463
+ try {
3464
+ const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
3465
+ return typeof details === "string" ? details : "";
3466
+ } catch {
3467
+ return "";
3468
+ }
3469
+ }
3470
+ // pco:resource is the more specific signal, so it wins when both tags are
3471
+ // present. A resource that isn't a PCRL is ignored rather than recorded.
3472
+ function visitFromPage({
3473
+ metadata,
3474
+ resource,
3475
+ title
3476
+ }) {
3477
+ const product = currentProduct();
3478
+ if (resource && isPcrl(resource)) {
3479
+ return {
3480
+ pcrl: resource,
3481
+ type: "resource"
3482
+ };
3483
+ } else if (title && title.trim() !== "" && product) {
3484
+ return {
3485
+ details: detailsFromMetadata(metadata),
3486
+ path: window.location.pathname,
3487
+ product,
3488
+ title,
3489
+ type: "page"
3490
+ };
3491
+ } else {
3492
+ return null;
3493
+ }
3494
+ }
3495
+ // The entry Recently Visited would record for the current page, or null when
3496
+ // the page isn't eligible. Shared by the recorder and the debug panel so the
3497
+ // panel always shows what actually gets stored.
3498
+ function useCurrentVisit() {
3499
+ const {
3500
+ recently_visitable: title,
3501
+ resource
3502
+ } = usePcoMeta();
3503
+ const metadata = useRecentlyVisitableMetadata();
3504
+ return useMemo(() => visitFromPage({
3505
+ metadata,
3506
+ resource,
3507
+ title
3508
+ }), [metadata, resource, title]);
3509
+ }
3510
+ // Products hand the person's ID to AppsProvider, not to the topbar itself,
3511
+ // and several pass it as a number.
3512
+ function useCurrentPersonId() {
3513
+ const {
3514
+ userId
3515
+ } = useStore();
3516
+ return userId == null ? undefined : String(userId);
3517
+ }
3518
+
3519
+ // Per-origin localStorage is a stopgap: each product keeps its own list until
3520
+ // PCA-409 moves this to storage shared across product subdomains. Only this
3521
+ // file should need to change when it does.
3522
+ function storageKey({
3523
+ env,
3524
+ personId
3525
+ }) {
3526
+ return `topbar.recently_visited.${env}.${personId}`;
3527
+ }
3528
+ // localStorage doesn't notify the tab that wrote it, so readers in this tab
3529
+ // learn about new visits from this counter instead.
3530
+ const changes = createStore({
3531
+ version: 0
3532
+ });
3533
+ function readRecentlyVisited(scope) {
3534
+ try {
3535
+ return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
3536
+ } catch {
3537
+ return [];
3538
+ }
3539
+ }
3540
+ function recordVisit(visit, scope) {
3541
+ if (isEntry(visit)) {
3542
+ try {
3543
+ localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
3544
+ changes.setState({
3545
+ version: changes.getState().version + 1
3546
+ });
3547
+ } catch {
3548
+ // Storage can be full or blocked; recents are disposable.
3549
+ }
3550
+ }
3551
+ }
3552
+ function useRecentlyVisited({
3553
+ env,
3554
+ personId
3555
+ }) {
3556
+ const version = useSyncExternalStore(changes.subscribe, () => changes.getState().version);
3557
+ // `version` isn't read, but listing it re-reads storage after each visit.
3558
+ return useMemo(() => personId ? readRecentlyVisited({
3559
+ env,
3560
+ personId
3561
+ }) : [], [env, personId, version]);
3562
+ }
3563
+
3564
+ // The versions home-mobile pins, so web and mobile read the same response
3565
+ // shapes. Some endpoints only exist from a version on: Registrations' me,
3566
+ // product_search_results, and pcrl_lookups need 2025-05-01.
3567
+ const API_VERSIONS = {
3568
+ calendar: "2022-07-07",
3569
+ "check-ins": "2025-05-28",
3570
+ giving: "2019-10-18",
3571
+ groups: "2023-07-10",
3572
+ home: "2018-08-01",
3573
+ people: "2023-03-21",
3574
+ publishing: "2024-03-25",
3575
+ registrations: "2025-05-01",
3576
+ services: "2018-11-01"
3577
+ };
3578
+ // For a `/~api/{product}/v2/...` URL, the header that pins its product's
3579
+ // version.
3580
+ function apiVersionHeaders(url) {
3581
+ const [,, product] = new URL(url).pathname.split("/");
3582
+ const version = API_VERSIONS[product];
3583
+ if (version) {
3584
+ return {
3585
+ "X-PCO-API-Version": version
3586
+ };
3587
+ } else {
3588
+ return {};
3589
+ }
3590
+ }
3591
+
3592
+ const CACHE_TTL_MS = 60000;
3593
+ const MAX_ENTRIES = 50;
3594
+ const cache = new Map();
3595
+ function cachedRequest(url) {
3596
+ const cached = cache.get(url);
3597
+ if (cached && cached.expiresAt > Date.now()) {
3598
+ // Re-inserting keeps the Map in recently-used order for eviction.
3599
+ cache.delete(url);
3600
+ cache.set(url, cached);
3601
+ return cached.promise;
3602
+ } else {
3603
+ return fetchIntoCache(url);
3604
+ }
3605
+ }
3606
+ function fetchIntoCache(url) {
3607
+ const promise = apiRequest(defaultFetch, url, {
3608
+ headers: apiVersionHeaders(url)
3609
+ }).then(({
3610
+ json,
3611
+ ok
3612
+ }) => {
3613
+ if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3614
+ return json;
3615
+ });
3616
+ // In-flight entries never expire, so every caller shares the one fetch.
3617
+ const entry = {
3618
+ expiresAt: Infinity,
3619
+ promise
3620
+ };
3621
+ cache.set(url, entry);
3622
+ promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3623
+ if (cache.size > MAX_ENTRIES) cache.delete(cache.keys().next().value);
3624
+ return promise;
3625
+ }
3626
+ function useCachedRequest(url) {
3627
+ const [settled, setSettled] = React__default.useState();
3628
+ React__default.useEffect(() => {
3629
+ if (url) {
3630
+ let current = true;
3631
+ cachedRequest(url).then(data => current && setSettled({
3632
+ data,
3633
+ url
3634
+ }), () => current && setSettled({
3635
+ failed: true,
3636
+ url
3637
+ }));
3638
+ return () => {
3639
+ current = false;
3640
+ };
3641
+ } else {
3642
+ return undefined;
3643
+ }
3644
+ }, [url]);
3645
+ if (!url) {
3646
+ return {
3647
+ status: "idle"
3648
+ };
3649
+ } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3650
+ return {
3651
+ data: settled && "data" in settled ? settled.data : undefined,
3652
+ status: "loading"
3653
+ };
3654
+ } else if ("data" in settled) {
3655
+ return {
3656
+ data: settled.data,
3657
+ status: "done"
3658
+ };
3659
+ } else {
3660
+ return {
3661
+ status: "error"
3662
+ };
3663
+ }
3664
+ }
3665
+ // One request per URL, for callers that fan out to a varying number of
3666
+ // endpoints. Unlike `useCachedRequest`, a URL that hasn't settled yet has no
3667
+ // earlier data to fall back on.
3668
+ function useCachedRequests(urls) {
3669
+ const [settled, setSettled] = React__default.useState({});
3670
+ // Keyed by value because each settle re-renders the caller, and a caller
3671
+ // that builds a fresh `urls` array on render would otherwise loop forever.
3672
+ const key = urls.join("\n");
3673
+ React__default.useEffect(() => {
3674
+ let current = true;
3675
+ const settle = result => current && setSettled(prev => ({
3676
+ ...prev,
3677
+ [result.url]: result
3678
+ }));
3679
+ for (const url of key.split("\n").filter(Boolean)) {
3680
+ cachedRequest(url).then(data => settle({
3681
+ data,
3682
+ url
3683
+ }), () => settle({
3684
+ failed: true,
3685
+ url
3686
+ }));
3687
+ }
3688
+ return () => {
3689
+ current = false;
3690
+ };
3691
+ }, [key]);
3692
+ return urls.map(url => {
3693
+ const result = settled[url];
3694
+ if (!result) {
3695
+ return {
3696
+ status: "loading"
3697
+ };
3698
+ } else if ("data" in result) {
3699
+ return {
3700
+ data: result.data,
3701
+ status: "done"
3702
+ };
3703
+ } else {
3704
+ return {
3705
+ status: "error"
3706
+ };
3707
+ }
3708
+ });
3709
+ }
3710
+
3711
+ const pcoUrl = pcoUrl$1;
3712
+
3713
+ // Resolves resource entries with one pcrl_lookups request per product.
3714
+ // pcrl_lookups reports forbidden and deleted resources the same way, so both
3715
+ // become `unresolved`, as does every entry in a batch whose request failed.
3716
+ function useHydratedRecentlyVisited(entries, env) {
3717
+ const batches = React__default.useMemo(() => batchByProduct(entries), [entries]);
3718
+ const urls = React__default.useMemo(() => batches.map(([product, pcrls]) => pcrlLookupsUrl(product, pcrls)), [batches]);
3719
+ const requests = useCachedRequests(urls);
3720
+ const lookups = new Map();
3721
+ batches.forEach(([, pcrls], index) => {
3722
+ const request = requests[index];
3723
+ for (const pcrl of pcrls) lookups.set(pcrl, lookupFor(pcrl, request));
3724
+ });
3725
+ return entries.map(entry => {
3726
+ var _a;
3727
+ if (entry.type === "page") {
3728
+ return {
3729
+ entry,
3730
+ status: "page",
3731
+ url: pageUrl(entry, env)
3732
+ };
3733
+ } else {
3734
+ const lookup = (_a = lookups.get(entry.pcrl)) !== null && _a !== void 0 ? _a : {
3735
+ status: "unresolved"
3736
+ };
3737
+ if (lookup.status === "resolved") {
3738
+ return {
3739
+ ...lookup,
3740
+ entry,
3741
+ url: lookup.result.attributes.url
3742
+ };
3743
+ } else {
3744
+ return {
3745
+ entry,
3746
+ status: lookup.status
3747
+ };
3748
+ }
3749
+ }
3750
+ });
3751
+ }
3752
+ function lookupFor(pcrl, request) {
3753
+ if (request.status === "done") {
3754
+ const result = request.data.data.find(({
3755
+ attributes
3756
+ }) => attributes.pcrl === pcrl);
3757
+ return result ? {
3758
+ result,
3759
+ status: "resolved"
3760
+ } : {
3761
+ status: "unresolved"
3762
+ };
3763
+ } else if (request.status === "error") {
3764
+ return {
3765
+ status: "unresolved"
3766
+ };
3767
+ } else {
3768
+ return {
3769
+ status: "loading"
3770
+ };
3771
+ }
3772
+ }
3773
+ const RECENTLY_VISITED_PAGE_SIZE = 10;
3774
+ // A PCRL can only be looked up through its own product's API, which may not be
3775
+ // the product it was visited in. Each page of entries is batched on its own,
3776
+ // so loading the next page leaves earlier pages' URLs, and so their cache
3777
+ // keys, alone. Sorting keeps them stable as a page reorders.
3778
+ function batchByProduct(entries) {
3779
+ var _a;
3780
+ const batches = [];
3781
+ for (let start = 0; start < entries.length; start += RECENTLY_VISITED_PAGE_SIZE) {
3782
+ const page = new Map();
3783
+ for (const entry of entries.slice(start, start + RECENTLY_VISITED_PAGE_SIZE)) {
3784
+ if (entry.type === "resource") {
3785
+ const [product] = entry.pcrl.split("/");
3786
+ page.set(product, [...((_a = page.get(product)) !== null && _a !== void 0 ? _a : []), entry.pcrl]);
3787
+ }
3788
+ }
3789
+ for (const [product, pcrls] of page) {
3790
+ batches.push([product, [...pcrls].sort()]);
3791
+ }
3792
+ }
3793
+ return batches;
3794
+ }
3795
+ function pcrlLookupsUrl(product, pcrls) {
3796
+ const params = new URLSearchParams({
3797
+ "where[pcrl]": pcrls.join(",")
3798
+ });
3799
+ return `${window.location.origin}/~api/${product}/v2/pcrl_lookups?${params}`;
3800
+ }
3801
+ // Built from the product rather than the current origin, so it stays right
3802
+ // once PCA-409 shares one list across product subdomains.
3803
+ function pageUrl(entry, env) {
3804
+ return `${pcoUrl(env)(entry.product)}${entry.path}`;
3805
+ }
3806
+
3308
3807
  var styles$a = {"result":"result-WCnxr"};
3309
3808
 
3310
3809
  function SearchResult({
3311
3810
  highlighted,
3312
3811
  id,
3313
3812
  onHighlight,
3314
- result
3813
+ subtitle,
3814
+ title,
3815
+ url
3315
3816
  }) {
3316
- const {
3317
- metadata,
3318
- title,
3319
- url
3320
- } = result.attributes;
3321
3817
  const ref = React__default.useRef(null);
3322
3818
  React__default.useEffect(() => {
3323
3819
  var _a;
@@ -3338,10 +3834,7 @@ function SearchResult({
3338
3834
  ref: ref,
3339
3835
  role: "option",
3340
3836
  tabIndex: -1
3341
- }, /*#__PURE__*/React__default.createElement("div", null, title), /*#__PURE__*/React__default.createElement("div", null, summarize(metadata)));
3342
- }
3343
- function summarize(metadata) {
3344
- return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3837
+ }, /*#__PURE__*/React__default.createElement("div", null, title), /*#__PURE__*/React__default.createElement("div", null, subtitle));
3345
3838
  }
3346
3839
 
3347
3840
  const resultId = (listboxId, index) => `${listboxId}-result-${index}`;
@@ -3413,83 +3906,127 @@ function ResultGroups({
3413
3906
  id: resultId(id, resultIndex),
3414
3907
  key: result.id,
3415
3908
  onHighlight: () => onHighlight(resultIndex),
3416
- result: result
3909
+ subtitle: summarize(result.attributes.metadata),
3910
+ title: result.attributes.title,
3911
+ url: result.attributes.url
3417
3912
  });
3418
3913
  }));
3419
3914
  }));
3420
3915
  }
3916
+ function summarize(metadata) {
3917
+ return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3918
+ }
3421
3919
 
3422
- const CACHE_TTL_MS = 60000;
3423
- const MAX_ENTRIES$1 = 50;
3424
- const cache = new Map();
3425
- function cachedRequest(url) {
3426
- const cached = cache.get(url);
3427
- if (cached && cached.expiresAt > Date.now()) {
3428
- // Re-inserting keeps the Map in recently-used order for eviction.
3429
- cache.delete(url);
3430
- cache.set(url, cached);
3431
- return cached.promise;
3920
+ function useRecentVisits(env) {
3921
+ const personId = useCurrentPersonId();
3922
+ const entries = useRecentlyVisited({
3923
+ env,
3924
+ personId
3925
+ });
3926
+ const [count, setCount] = React__default.useState(RECENTLY_VISITED_PAGE_SIZE);
3927
+ const shown = React__default.useMemo(() => entries.slice(0, count), [entries, count]);
3928
+ const hydrated = useHydratedRecentlyVisited(shown, env);
3929
+ // Resources show up once they're found. Ones that can't be found anymore,
3930
+ // or that the person can no longer see, are left out.
3931
+ const visits = hydrated.filter(isShown);
3932
+ const loading = hydrated.some(({
3933
+ status
3934
+ }) => status === "loading");
3935
+ const showMore = React__default.useCallback(() => setCount(current => current + RECENTLY_VISITED_PAGE_SIZE), []);
3936
+ return {
3937
+ count,
3938
+ hasMore: entries.length > count,
3939
+ loading,
3940
+ showMore,
3941
+ visits
3942
+ };
3943
+ }
3944
+ function isShown(visit) {
3945
+ return visit.status === "page" || visit.status === "resolved";
3946
+ }
3947
+ function RecentlyVisited({
3948
+ count,
3949
+ hasMore,
3950
+ highlightedIndex,
3951
+ id,
3952
+ loading,
3953
+ onHighlight,
3954
+ showMore,
3955
+ visits
3956
+ }) {
3957
+ const headingId = `${id}-heading`;
3958
+ if (visits.length === 0 && !hasMore && !loading) {
3959
+ return /*#__PURE__*/React__default.createElement("p", null, "Pages you visit will show up here.");
3432
3960
  } else {
3433
- return fetchIntoCache(url);
3961
+ return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("div", {
3962
+ id: headingId
3963
+ }, "Recently visited"), /*#__PURE__*/React__default.createElement("div", {
3964
+ "aria-labelledby": headingId,
3965
+ id: id,
3966
+ role: "listbox"
3967
+ }, visits.map((visit, index) => (/*#__PURE__*/React__default.createElement(SearchResult, {
3968
+ highlighted: index === highlightedIndex,
3969
+ id: resultId(id, index),
3970
+ key: entryKey(visit.entry),
3971
+ onHighlight: () => onHighlight(index),
3972
+ ...describeVisit(visit)
3973
+ })))), hasMore && /*#__PURE__*/React__default.createElement(ShowMoreWhenVisible, {
3974
+ key: count,
3975
+ onVisible: showMore
3976
+ }));
3434
3977
  }
3435
3978
  }
3436
- function fetchIntoCache(url) {
3437
- const promise = apiRequest(defaultFetch, url).then(({
3438
- json,
3439
- ok
3440
- }) => {
3441
- if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3442
- return json;
3979
+ function ShowMoreWhenVisible({
3980
+ onVisible
3981
+ }) {
3982
+ const ref = React__default.useRef(null);
3983
+ React__default.useEffect(() => {
3984
+ const observer = new IntersectionObserver(([entry]) => {
3985
+ if (entry.isIntersecting) onVisible();
3986
+ });
3987
+ if (ref.current) observer.observe(ref.current);
3988
+ return () => observer.disconnect();
3989
+ }, [onVisible]);
3990
+ return /*#__PURE__*/React__default.createElement("div", {
3991
+ ref: ref
3443
3992
  });
3444
- // In-flight entries never expire, so every caller shares the one fetch.
3445
- const entry = {
3446
- expiresAt: Infinity,
3447
- promise
3448
- };
3449
- cache.set(url, entry);
3450
- promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3451
- if (cache.size > MAX_ENTRIES$1) cache.delete(cache.keys().next().value);
3452
- return promise;
3453
3993
  }
3454
- function useCachedRequest(url) {
3455
- const [settled, setSettled] = React__default.useState();
3456
- React__default.useEffect(() => {
3457
- if (url) {
3458
- let current = true;
3459
- cachedRequest(url).then(data => current && setSettled({
3460
- data,
3461
- url
3462
- }), () => current && setSettled({
3463
- failed: true,
3464
- url
3465
- }));
3466
- return () => {
3467
- current = false;
3468
- };
3469
- } else {
3470
- return undefined;
3471
- }
3472
- }, [url]);
3473
- if (!url) {
3474
- return {
3475
- status: "idle"
3476
- };
3477
- } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3478
- return {
3479
- data: settled && "data" in settled ? settled.data : undefined,
3480
- status: "loading"
3481
- };
3482
- } else if ("data" in settled) {
3994
+ // Unlike search results, recents come from every product, so each one names
3995
+ // its product and type.
3996
+ function describeVisit(visit) {
3997
+ const subtitle = (...parts) => parts.filter(Boolean).join(" · ");
3998
+ if (visit.status === "page") {
3999
+ const {
4000
+ details,
4001
+ product,
4002
+ title
4003
+ } = visit.entry;
3483
4004
  return {
3484
- data: settled.data,
3485
- status: "done"
4005
+ subtitle: subtitle(productName(product), details),
4006
+ title,
4007
+ url: visit.url
3486
4008
  };
3487
4009
  } else {
4010
+ const {
4011
+ metadata,
4012
+ product,
4013
+ title,
4014
+ type
4015
+ } = visit.result.attributes;
3488
4016
  return {
3489
- status: "error"
4017
+ subtitle: subtitle(productName(product), type, summarize(metadata)),
4018
+ title,
4019
+ url: visit.url
3490
4020
  };
3491
4021
  }
3492
4022
  }
4023
+ // check-ins → Check-Ins
4024
+ function productName(slug) {
4025
+ return slug.split("-").map(capitalize).join("-");
4026
+ }
4027
+ function capitalize(word) {
4028
+ return word.charAt(0).toUpperCase() + word.slice(1);
4029
+ }
3493
4030
 
3494
4031
  const SEARCH_DEBOUNCE_MS = 300;
3495
4032
  function useProductSearch(product, term) {
@@ -3580,15 +4117,16 @@ const verticalStep = key => ({
3580
4117
  ArrowDown: 1,
3581
4118
  ArrowUp: -1
3582
4119
  })[key];
3583
- function useResultNavigation(results) {
3584
- // Remembering which results the index belongs to puts the highlight back on
3585
- // the first result whenever new ones arrive, without an effect to reset it.
4120
+ // Remembering which list the index belongs to puts the highlight back on the
4121
+ // first option whenever a new list replaces it, without an effect to reset it.
4122
+ // A list that grows or fills in under the same `list` keeps its highlight.
4123
+ function useResultNavigation(options, list) {
3586
4124
  const [highlight, setHighlight] = React__default.useState({
3587
4125
  index: 0,
3588
- results
4126
+ list
3589
4127
  });
3590
- const highlightedIndex = highlight.results === results ? highlight.index : 0;
3591
- const highlighted = results[highlightedIndex];
4128
+ const highlightedIndex = highlight.list === list ? highlight.index : 0;
4129
+ const highlighted = options[highlightedIndex];
3592
4130
  const [copiedUrl, setCopiedUrl] = React__default.useState(null);
3593
4131
  React__default.useEffect(() => {
3594
4132
  if (copiedUrl) {
@@ -3598,30 +4136,30 @@ function useResultNavigation(results) {
3598
4136
  return undefined;
3599
4137
  }
3600
4138
  }, [copiedUrl]);
3601
- const highlightIndex = React__default.useCallback(index => setHighlight(current => current.results === results && current.index === index ? current : {
4139
+ const highlightIndex = React__default.useCallback(index => setHighlight(current => current.list === list && current.index === index ? current : {
3602
4140
  index,
3603
- results
3604
- }), [results]);
4141
+ list
4142
+ }), [list]);
3605
4143
  const handleKeyDown = event => {
3606
4144
  const step = verticalStep(event.key);
3607
- if (step && results.length > 0) {
4145
+ if (step && options.length > 0) {
3608
4146
  event.preventDefault();
3609
- highlightIndex((highlightedIndex + step + results.length) % results.length);
4147
+ highlightIndex((highlightedIndex + step + options.length) % options.length);
3610
4148
  } else if (event.key === "Enter" && highlighted && !event.nativeEvent.isComposing) {
3611
4149
  event.preventDefault();
3612
- open(highlighted.attributes.url, {
4150
+ open(highlighted.url, {
3613
4151
  newTab: hasCommandKey(event)
3614
4152
  });
3615
4153
  } else if (isCopyShortcut(event) && highlighted && !hasSelectedText(event.currentTarget)) {
3616
4154
  event.preventDefault();
3617
4155
  const {
3618
4156
  url
3619
- } = highlighted.attributes;
4157
+ } = highlighted;
3620
4158
  navigator.clipboard.writeText(url).then(() => setCopiedUrl(url));
3621
4159
  }
3622
4160
  };
3623
4161
  return {
3624
- copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.attributes.url),
4162
+ copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.url),
3625
4163
  handleKeyDown,
3626
4164
  highlightIndex,
3627
4165
  highlighted,
@@ -3646,29 +4184,66 @@ function hasSelectedText(element) {
3646
4184
  }
3647
4185
 
3648
4186
  const SEARCHABLE_PRODUCTS = [{
4187
+ appName: "Calendar",
4188
+ slug: "calendar"
4189
+ }, {
4190
+ appName: "Check-Ins",
4191
+ slug: "check-ins"
4192
+ }, {
4193
+ appName: "Giving",
4194
+ slug: "giving"
4195
+ }, {
3649
4196
  appName: "Groups",
3650
4197
  slug: "groups"
3651
4198
  }, {
3652
4199
  appName: "People",
3653
4200
  slug: "people"
4201
+ }, {
4202
+ appName: "Publishing",
4203
+ slug: "publishing"
4204
+ }, {
4205
+ appName: "Registrations",
4206
+ slug: "registrations"
3654
4207
  }, {
3655
4208
  appName: "Services",
3656
4209
  slug: "services"
3657
4210
  }];
3658
4211
  const PERMISSION_CHECKS = {
4212
+ "check-ins": () => fetchMe("check-ins", "permission").then(({
4213
+ permission
4214
+ }) => permission !== null),
4215
+ giving: personId => {
4216
+ if (personId === undefined) {
4217
+ return Promise.reject(new Error("No person to check Giving access for"));
4218
+ } else {
4219
+ return fetchAttributes(`giving/v2/person_permissions/${personId}`, {
4220
+ "fields[PersonPermissions]": "can_search"
4221
+ }).then(({
4222
+ can_search
4223
+ }) => can_search);
4224
+ }
4225
+ },
3659
4226
  people: () => fetchMe("people", "people_permissions,site_administrator").then(({
3660
4227
  people_permissions,
3661
4228
  site_administrator
3662
4229
  }) => site_administrator || people_permissions !== null),
4230
+ registrations: () => fetchMe("registrations", "can_search").then(({
4231
+ can_search
4232
+ }) => can_search),
3663
4233
  services: () => fetchMe("services", "can_search").then(({
3664
4234
  can_search
3665
4235
  }) => can_search)
3666
4236
  };
3667
4237
  function fetchMe(product, fields) {
3668
- const params = new URLSearchParams({
4238
+ return fetchAttributes(`${product}/v2/me`, {
3669
4239
  "fields[Person]": fields
3670
4240
  });
3671
- return apiRequest(defaultFetch, `${window.location.origin}/~api/${product}/v2/me?${params}`).then(({
4241
+ }
4242
+ function fetchAttributes(path, params) {
4243
+ const url = `${window.location.origin}/~api/${path}?${new URLSearchParams(params)}`;
4244
+ return apiRequest(defaultFetch, url, {
4245
+ headers: apiVersionHeaders(url)
4246
+ }).then(({
3672
4247
  json,
3673
4248
  ok
3674
4249
  }) => {
@@ -3679,7 +4254,7 @@ function fetchMe(product, fields) {
3679
4254
  // Permissions rarely change mid-session, so a successful check is kept until
3680
4255
  // the page reloads. A failed one is dropped so a remount tries again.
3681
4256
  const permissionCache = new Map();
3682
- function canSearch(slug) {
4257
+ function canSearch(slug, personId) {
3683
4258
  const check = PERMISSION_CHECKS[slug];
3684
4259
  const cached = permissionCache.get(slug);
3685
4260
  if (!check) {
@@ -3687,7 +4262,7 @@ function canSearch(slug) {
3687
4262
  } else if (cached) {
3688
4263
  return cached;
3689
4264
  } else {
3690
- const promise = check().catch(() => {
4265
+ const promise = check(personId).catch(() => {
3691
4266
  permissionCache.delete(slug);
3692
4267
  return false;
3693
4268
  });
@@ -3701,16 +4276,18 @@ function useSearchableProducts(apps) {
3701
4276
  const accessibleKey = inSwitcherOrder(apps).map(({
3702
4277
  slug
3703
4278
  }) => slug).join(",");
4279
+ const personId = useCurrentPersonId();
3704
4280
  const [settled, setSettled] = React__default.useState();
3705
4281
  React__default.useEffect(() => {
3706
4282
  let current = true;
3707
4283
  const accessible = accessibleKey.split(",").flatMap(slug => SEARCHABLE_PRODUCTS.filter(product => product.slug === slug));
3708
4284
  Promise.all(accessible.map(({
3709
4285
  slug
3710
- }) => canSearch(slug))).then(allowed => {
4286
+ }) => canSearch(slug, personId))).then(allowed => {
3711
4287
  if (current) {
3712
4288
  setSettled({
3713
4289
  accessibleKey,
4290
+ personId,
3714
4291
  products: accessible.filter((_, index) => allowed[index])
3715
4292
  });
3716
4293
  }
@@ -3718,8 +4295,8 @@ function useSearchableProducts(apps) {
3718
4295
  return () => {
3719
4296
  current = false;
3720
4297
  };
3721
- }, [accessibleKey]);
3722
- if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey) {
4298
+ }, [accessibleKey, personId]);
4299
+ if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey && settled.personId === personId) {
3723
4300
  return {
3724
4301
  products: settled.products,
3725
4302
  status: "done"
@@ -3740,11 +4317,13 @@ const isLegacySearchShortcut = event => event.key === "/" && !event.altKey && !e
3740
4317
  const isTypingKey = event => event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey;
3741
4318
  const isTypingIn = target => target instanceof Element && target.closest('input, select, textarea, [contenteditable]:not([contenteditable="false"])') !== null;
3742
4319
  function ProductSearch({
3743
- apps
4320
+ apps,
4321
+ env
3744
4322
  }) {
3745
4323
  const searchable = useSearchableProducts(apps);
3746
4324
  if (searchable.status === "done" && searchable.products.length > 0) {
3747
4325
  return /*#__PURE__*/React__default.createElement(SearchButtonAndDialog, {
4326
+ env: env,
3748
4327
  products: searchable.products
3749
4328
  });
3750
4329
  } else {
@@ -3752,6 +4331,7 @@ function ProductSearch({
3752
4331
  }
3753
4332
  }
3754
4333
  function SearchButtonAndDialog({
4334
+ env,
3755
4335
  products
3756
4336
  }) {
3757
4337
  const [open, setOpen] = React__default.useState(false);
@@ -3789,10 +4369,15 @@ function SearchButtonAndDialog({
3789
4369
  onClose: () => setOpen(false),
3790
4370
  open: open
3791
4371
  }, /*#__PURE__*/React__default.createElement(SearchPanel, {
4372
+ env: env,
3792
4373
  products: products
3793
4374
  })));
3794
4375
  }
4376
+ // Keys the recents list for navigation, so the highlight stays put as more
4377
+ // recents load and as their lookups answer.
4378
+ const RECENTS = "recents";
3795
4379
  function SearchPanel({
4380
+ env,
3796
4381
  products
3797
4382
  }) {
3798
4383
  var _a, _b;
@@ -3809,7 +4394,23 @@ function SearchPanel({
3809
4394
  var _a;
3810
4395
  return (_a = groups === null || groups === void 0 ? void 0 : groups.flatMap(group => group.results)) !== null && _a !== void 0 ? _a : [];
3811
4396
  }, [groups]);
3812
- const navigation = useResultNavigation(results);
4397
+ const recents = useRecentVisits(env);
4398
+ const options = hasTerm ? results.map(({
4399
+ attributes
4400
+ }) => ({
4401
+ label: attributes.title,
4402
+ url: attributes.url
4403
+ })) : recents.visits.map(visit => {
4404
+ const {
4405
+ title,
4406
+ url
4407
+ } = describeVisit(visit);
4408
+ return {
4409
+ label: title,
4410
+ url
4411
+ };
4412
+ });
4413
+ const navigation = useResultNavigation(options, hasTerm ? results : RECENTS);
3813
4414
  const {
3814
4415
  highlightedIndex
3815
4416
  } = navigation;
@@ -3835,8 +4436,8 @@ function SearchPanel({
3835
4436
  return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("input", {
3836
4437
  "aria-activedescendant": highlightedIndex === null ? undefined : resultId(listboxId, highlightedIndex),
3837
4438
  "aria-autocomplete": "list",
3838
- "aria-controls": results.length > 0 ? listboxId : undefined,
3839
- "aria-expanded": results.length > 0,
4439
+ "aria-controls": options.length > 0 ? listboxId : undefined,
4440
+ "aria-expanded": options.length > 0,
3840
4441
  "aria-label": "Search",
3841
4442
  autoFocus: true,
3842
4443
  onChange: event => setTerm(event.target.value),
@@ -3852,17 +4453,22 @@ function SearchPanel({
3852
4453
  onSelect: setSelectedSlug,
3853
4454
  products: products,
3854
4455
  selected: product
3855
- }, /*#__PURE__*/React__default.createElement(SearchResults, {
4456
+ }, hasTerm ? (/*#__PURE__*/React__default.createElement(SearchResults, {
3856
4457
  highlightedIndex: highlightedIndex,
3857
4458
  id: listboxId,
3858
4459
  label: `${product.appName} results`,
3859
4460
  onHighlight: navigation.highlightIndex,
3860
4461
  search: search,
3861
4462
  term: term
3862
- })), /*#__PURE__*/React__default.createElement("div", {
4463
+ })) : (/*#__PURE__*/React__default.createElement(RecentlyVisited, {
4464
+ highlightedIndex: highlightedIndex,
4465
+ id: listboxId,
4466
+ onHighlight: navigation.highlightIndex,
4467
+ ...recents
4468
+ }))), /*#__PURE__*/React__default.createElement("div", {
3863
4469
  "aria-live": "polite",
3864
4470
  className: styles$c.visuallyHidden
3865
- }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.attributes.title : null), /*#__PURE__*/React__default.createElement(KeyboardHints, {
4471
+ }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.label : null), /*#__PURE__*/React__default.createElement(KeyboardHints, {
3866
4472
  copied: navigation.copied
3867
4473
  }));
3868
4474
  }
@@ -3881,249 +4487,6 @@ function initialProduct(products) {
3881
4487
  return (_c = (_b = (_a = bySlug(lastProduct)) !== null && _a !== void 0 ? _a : bySlug("people")) !== null && _b !== void 0 ? _b : bySlug("services")) !== null && _c !== void 0 ? _c : products[0];
3882
4488
  }
3883
4489
 
3884
- function parseValue(element) {
3885
- const datatype = element.getAttribute("datatype");
3886
- switch (datatype) {
3887
- case "boolean":
3888
- return element.content === "true" || element.content === "1";
3889
- default:
3890
- return element.content;
3891
- }
3892
- }
3893
- function getAllPcoMeta() {
3894
- const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3895
- const meta = {};
3896
- elements.forEach(e => {
3897
- const property = e.getAttribute("property");
3898
- meta[property.substring("pco:".length)] = parseValue(e);
3899
- });
3900
- return meta;
3901
- }
3902
- // usePcoMeta only reads each tag's content. Recently Visited also needs the
3903
- // pco:recently_visitable tag's `metadata` attribute, which can change while
3904
- // the content stays the same, so it is tracked in its own store.
3905
- function getRecentlyVisitableMetadata() {
3906
- var _a, _b;
3907
- return (_b = (_a = document.head.querySelector(`meta[property="pco:recently_visitable"]`)) === null || _a === void 0 ? void 0 : _a.getAttribute("metadata")) !== null && _b !== void 0 ? _b : null;
3908
- }
3909
- const store = createStore(getAllPcoMeta());
3910
- const recentlyVisitableMetadataStore = createStore({
3911
- metadata: getRecentlyVisitableMetadata()
3912
- });
3913
- function sync() {
3914
- const nextState = getAllPcoMeta();
3915
- if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3916
- const metadata = getRecentlyVisitableMetadata();
3917
- if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3918
- recentlyVisitableMetadataStore.setState({
3919
- metadata
3920
- });
3921
- }
3922
- }
3923
- const observer = new MutationObserver(sync);
3924
- let hasTriggeredObserve = false;
3925
- function observe() {
3926
- if (hasTriggeredObserve) return;
3927
- hasTriggeredObserve = true;
3928
- // The module-load snapshot above can predate meta tags that appear later
3929
- // in <head> (e.g. pco:resource, yielded right before </head>) if this
3930
- // module's own script tag isn't deferred and runs mid-parse. Those tags
3931
- // never mutate afterward, so the observer alone would never catch them.
3932
- // Re-read once now, since observe() is only called once a consumer is
3933
- // actually mounting, which is always after the document has fully parsed.
3934
- sync();
3935
- observer.observe(document.head, {
3936
- attributes: true,
3937
- childList: true,
3938
- subtree: true
3939
- });
3940
- }
3941
- function usePcoMeta() {
3942
- observe();
3943
- return useSyncExternalStore(store.subscribe, () => store.getState());
3944
- }
3945
- function useRecentlyVisitableMetadata() {
3946
- observe();
3947
- return useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3948
- }
3949
-
3950
- const MAX_ENTRIES = 20;
3951
- const FORMAT_VERSION = 1;
3952
- const MAX_TEXT_LENGTH = 200;
3953
- const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3954
- function isId(value) {
3955
- return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3956
- }
3957
- // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3958
- function isPcrl(value) {
3959
- const segments = value.split("/");
3960
- return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3961
- }
3962
- function isNonBlankString(value) {
3963
- return typeof value === "string" && value.trim() !== "";
3964
- }
3965
- function isRecord(value) {
3966
- return typeof value === "object" && value !== null && !Array.isArray(value);
3967
- }
3968
- function entryKey(entry) {
3969
- if (entry.type === "resource") {
3970
- return `resource:${entry.pcrl}`;
3971
- } else {
3972
- return `page:${entry.product}:${entry.path}`;
3973
- }
3974
- }
3975
- function isEntry(value) {
3976
- if (!isRecord(value)) {
3977
- return false;
3978
- } else if (value.type === "resource") {
3979
- return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3980
- } else if (value.type === "page") {
3981
- return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3982
- } else {
3983
- return false;
3984
- }
3985
- }
3986
- // Cuts on code points so a surrogate pair is never split.
3987
- function clampText(value) {
3988
- return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3989
- }
3990
- function clampEntry(entry) {
3991
- if (entry.type === "resource") {
3992
- return entry;
3993
- } else {
3994
- return {
3995
- ...entry,
3996
- details: clampText(entry.details),
3997
- title: clampText(entry.title)
3998
- };
3999
- }
4000
- }
4001
- // Recents are disposable: anything unexpected reads as an empty list (or, for
4002
- // a single bad entry, a list without it) rather than throwing.
4003
- function parseRecentlyVisited(value, personId) {
4004
- let parsed = null;
4005
- try {
4006
- parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
4007
- } catch {
4008
- parsed = null;
4009
- }
4010
- if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
4011
- return parsed.entries.filter(isEntry);
4012
- } else {
4013
- return [];
4014
- }
4015
- }
4016
- function addVisit(entries, visit) {
4017
- const key = entryKey(visit);
4018
- return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES);
4019
- }
4020
- function serializeRecentlyVisited(entries, personId) {
4021
- return JSON.stringify({
4022
- entries,
4023
- person: personId,
4024
- v: FORMAT_VERSION
4025
- });
4026
- }
4027
-
4028
- // The product whose URL, in this environment, has the current page's host.
4029
- // Reversing pcoUrl handles every environment @planningcenter/url knows about,
4030
- // and guarantees the stored product rebuilds a working link.
4031
- function currentProduct() {
4032
- var _a;
4033
- return (_a = PRODUCTS.find(product => new URL(pcoUrl$2(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
4034
- }
4035
- function detailsFromMetadata(metadata) {
4036
- var _a;
4037
- try {
4038
- const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
4039
- return typeof details === "string" ? details : "";
4040
- } catch {
4041
- return "";
4042
- }
4043
- }
4044
- // pco:resource is the more specific signal, so it wins when both tags are
4045
- // present. A resource that isn't a PCRL is ignored rather than recorded.
4046
- function visitFromPage({
4047
- metadata,
4048
- resource,
4049
- title
4050
- }) {
4051
- const product = currentProduct();
4052
- if (resource && isPcrl(resource)) {
4053
- return {
4054
- pcrl: resource,
4055
- type: "resource"
4056
- };
4057
- } else if (title && title.trim() !== "" && product) {
4058
- return {
4059
- details: detailsFromMetadata(metadata),
4060
- path: window.location.pathname,
4061
- product,
4062
- title,
4063
- type: "page"
4064
- };
4065
- } else {
4066
- return null;
4067
- }
4068
- }
4069
- // The entry Recently Visited would record for the current page, or null when
4070
- // the page isn't eligible. Shared by the recorder and the debug panel so the
4071
- // panel always shows what actually gets stored.
4072
- function useCurrentVisit() {
4073
- const {
4074
- recently_visitable: title,
4075
- resource
4076
- } = usePcoMeta();
4077
- const metadata = useRecentlyVisitableMetadata();
4078
- return useMemo(() => visitFromPage({
4079
- metadata,
4080
- resource,
4081
- title
4082
- }), [metadata, resource, title]);
4083
- }
4084
- // Products hand the person's ID to AppsProvider, not to the topbar itself,
4085
- // and several pass it as a number.
4086
- function useCurrentPersonId() {
4087
- const {
4088
- userId
4089
- } = useStore();
4090
- return userId == null ? undefined : String(userId);
4091
- }
4092
-
4093
- // Per-origin localStorage is a stopgap: each product keeps its own list until
4094
- // PCA-409 moves this to storage shared across product subdomains. Only this
4095
- // file should need to change when it does.
4096
- function storageKey({
4097
- env,
4098
- personId
4099
- }) {
4100
- return `topbar.recently_visited.${env}.${personId}`;
4101
- }
4102
- // localStorage doesn't notify the tab that wrote it, so readers in this tab
4103
- // learn about new visits from this counter instead.
4104
- const changes = createStore({
4105
- version: 0
4106
- });
4107
- function readRecentlyVisited(scope) {
4108
- try {
4109
- return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
4110
- } catch {
4111
- return [];
4112
- }
4113
- }
4114
- function recordVisit(visit, scope) {
4115
- if (isEntry(visit)) {
4116
- try {
4117
- localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
4118
- changes.setState({
4119
- version: changes.getState().version + 1
4120
- });
4121
- } catch {
4122
- // Storage can be full or blocked; recents are disposable.
4123
- }
4124
- }
4125
- }
4126
-
4127
4490
  function useRecordRecentlyVisited({
4128
4491
  enabled,
4129
4492
  env
@@ -4295,7 +4658,7 @@ const UserMenu = ({
4295
4658
  }, /*#__PURE__*/React__default.createElement(Text, {
4296
4659
  color: "var(--t-text-color-secondary)",
4297
4660
  lineHeight: "24px"
4298
- }, "Log out"), /*#__PURE__*/React__default.createElement(Icon, {
4661
+ }, "Log out"), /*#__PURE__*/React__default.createElement(Icon$1, {
4299
4662
  name: "general.intoBox",
4300
4663
  fill: "var(--t-icon-color-secondary)"
4301
4664
  })))))));
@@ -4413,7 +4776,8 @@ const NotSmallTopbar = ({
4413
4776
  margin: "auto"
4414
4777
  }
4415
4778
  }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React__default.createElement(ProductSearch, {
4416
- apps: props.apps
4779
+ apps: props.apps,
4780
+ env: props.env
4417
4781
  })) : search({
4418
4782
  hideRoutes: () => setRoutesVisible(false),
4419
4783
  showRoutes: () => setRoutesVisible(true)
@@ -32546,7 +32910,7 @@ const SmallTopbar = ({
32546
32910
  setProductSwitcherVisible(false);
32547
32911
  setRoutesMenuVisible(visible => !visible);
32548
32912
  } : null
32549
- }, /*#__PURE__*/React__default.createElement(CurrentRouteComponent, null, capitalize(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React__default.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React__default.createElement("button", {
32913
+ }, /*#__PURE__*/React__default.createElement(CurrentRouteComponent, null, capitalize$1(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React__default.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React__default.createElement("button", {
32550
32914
  type: "button",
32551
32915
  className: styles$5["profile-button"],
32552
32916
  onClick: () => {
@@ -32739,8 +33103,24 @@ function Field({
32739
33103
  className: styles$4["debug-pco-resource-field-value"]
32740
33104
  }, value));
32741
33105
  }
33106
+ // Only staff ghosting or impersonating a person get pco:staff-access-level,
33107
+ // which keeps the panel away from regular users on staging.
32742
33108
  function DebugPcoResource({
32743
33109
  env
33110
+ }) {
33111
+ const {
33112
+ "staff-access-level": staffAccessLevel
33113
+ } = usePcoMeta();
33114
+ if (isPresent(staffAccessLevel)) {
33115
+ return /*#__PURE__*/React__default.createElement(DebugPanel, {
33116
+ env: env
33117
+ });
33118
+ } else {
33119
+ return null;
33120
+ }
33121
+ }
33122
+ function DebugPanel({
33123
+ env
32744
33124
  }) {
32745
33125
  const {
32746
33126
  recently_visitable: recentlyVisitable,
@@ -32749,12 +33129,17 @@ function DebugPcoResource({
32749
33129
  const [lookup, setLookup] = useState({
32750
33130
  status: "idle"
32751
33131
  });
32752
- const hasResource = isPresent(resource);
33132
+ const visit = useCurrentVisit();
33133
+ const personId = useCurrentPersonId();
33134
+ const recentlyVisited = useRecentlyVisited({
33135
+ env,
33136
+ personId
33137
+ });
33138
+ const pcrl = (visit === null || visit === void 0 ? void 0 : visit.type) === "resource" ? visit.pcrl : null;
32753
33139
  const hasRecentlyVisitable = isPresent(recentlyVisitable);
33140
+ const hasInvalidResource = isPresent(resource) && !isPcrl(resource);
32754
33141
  useEffect(() => {
32755
- // pco:resource is the more specific signal, so it always wins when both
32756
- // tags are present.
32757
- const url = hasResource ? pcrlLookupUrl(resource, env) : null;
33142
+ const url = pcrl ? pcrlLookupUrl(pcrl, env) : null;
32758
33143
  if (!url) {
32759
33144
  setLookup({
32760
33145
  status: "idle"
@@ -32786,7 +33171,7 @@ function DebugPcoResource({
32786
33171
  return () => {
32787
33172
  cancelled = true;
32788
33173
  };
32789
- }, [resource, env, hasResource]);
33174
+ }, [pcrl, env]);
32790
33175
  return /*#__PURE__*/React__default.createElement("div", {
32791
33176
  className: classnames("staff-only", styles$4["debug-pco-resource"])
32792
33177
  }, /*#__PURE__*/React__default.createElement("div", {
@@ -32801,19 +33186,27 @@ function DebugPcoResource({
32801
33186
  }), /*#__PURE__*/React__default.createElement(Field, {
32802
33187
  label: "pco:recently_visitable",
32803
33188
  value: describeTag("recently_visitable", recentlyVisitable)
32804
- }), !hasResource && !hasRecentlyVisitable && (/*#__PURE__*/React__default.createElement("p", {
33189
+ }), hasInvalidResource && (/*#__PURE__*/React__default.createElement("p", {
33190
+ className: styles$4["debug-pco-resource-note"]
33191
+ }, "pco:resource isn't a PCRL (product/v2/type/id), so Recently Visited ignores it.")), visit === null && (/*#__PURE__*/React__default.createElement("p", {
33192
+ className: styles$4["debug-pco-resource-note"]
33193
+ }, "This page is not eligible for Recently Visited")), pcrl && hasRecentlyVisitable && (/*#__PURE__*/React__default.createElement("p", {
32805
33194
  className: styles$4["debug-pco-resource-note"]
32806
- }, "This page is not eligible for Recently Visited")), hasResource && hasRecentlyVisitable && (/*#__PURE__*/React__default.createElement("p", {
33195
+ }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), pcrl && lookup.status === "loading" && (/*#__PURE__*/React__default.createElement("p", {
32807
33196
  className: styles$4["debug-pco-resource-note"]
32808
- }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), hasResource && lookup.status === "loading" && (/*#__PURE__*/React__default.createElement("p", {
33197
+ }, "Looking up resource\u2026")), pcrl && lookup.status === "error" && (/*#__PURE__*/React__default.createElement("p", {
32809
33198
  className: styles$4["debug-pco-resource-note"]
32810
- }, "Looking up resource\u2026")), hasResource && lookup.status === "error" && (/*#__PURE__*/React__default.createElement("p", {
33199
+ }, lookup.message)), pcrl && lookup.status === "loaded" && (/*#__PURE__*/React__default.createElement("pre", {
33200
+ className: styles$4["debug-pco-resource-json"]
33201
+ }, JSON.stringify(lookup.json, null, 2))), (visit === null || visit === void 0 ? void 0 : visit.type) === "page" && (/*#__PURE__*/React__default.createElement("p", {
32811
33202
  className: styles$4["debug-pco-resource-note"]
32812
- }, lookup.message)), hasResource && lookup.status === "loaded" && (/*#__PURE__*/React__default.createElement("pre", {
33203
+ }, "Recently Visited title: ", visit.title)), /*#__PURE__*/React__default.createElement("span", {
33204
+ className: styles$4["debug-pco-resource-field-label"]
33205
+ }, "Recently Visited"), personId ? (/*#__PURE__*/React__default.createElement("pre", {
32813
33206
  className: styles$4["debug-pco-resource-json"]
32814
- }, JSON.stringify(lookup.json, null, 2))), !hasResource && hasRecentlyVisitable && (/*#__PURE__*/React__default.createElement("p", {
33207
+ }, JSON.stringify(recentlyVisited, null, 2))) : (/*#__PURE__*/React__default.createElement("p", {
32815
33208
  className: styles$4["debug-pco-resource-note"]
32816
- }, "Recently Visited title: ", recentlyVisitable)));
33209
+ }, "No current person, so nothing is recorded.")));
32817
33210
  }
32818
33211
 
32819
33212
  var styles$3 = {"drawer-wrap":"drawer-wrap-J0rSL","is-visible":"is-visible-KTME8","drawer":"drawer-j7f4l"};
@@ -33018,15 +33411,50 @@ const SwitchView = ({
33018
33411
  }));
33019
33412
  };
33020
33413
 
33414
+ const collections = {
33415
+ general,
33416
+ toolbar
33417
+ };
33418
+ const paths = collections;
33419
+ function Icon({
33420
+ name,
33421
+ size = 16,
33422
+ style,
33423
+ theme,
33424
+ title,
33425
+ ...props
33426
+ }) {
33427
+ const [collection, symbol] = name.split(".");
33428
+ return /*#__PURE__*/React__default.createElement("svg", {
33429
+ "aria-hidden": title ? undefined : true,
33430
+ fill: "currentColor",
33431
+ height: size,
33432
+ role: title ? "img" : undefined,
33433
+ style: {
33434
+ color: theme && `var(${theme})`,
33435
+ flexShrink: 0,
33436
+ ...style
33437
+ },
33438
+ viewBox: "0 0 16 16",
33439
+ width: size,
33440
+ ...props
33441
+ }, title && /*#__PURE__*/React__default.createElement("title", null, title), /*#__PURE__*/React__default.createElement("path", {
33442
+ d: paths[collection][symbol]
33443
+ }));
33444
+ }
33445
+
33021
33446
  var styles$2 = {"color-mode-switcher":"color-mode-switcher-JpczO","color-mode-switcher-label":"color-mode-switcher-label-3jPM2","color-mode-switcher-modes":"color-mode-switcher-modes-u-bic"};
33022
33447
 
33023
33448
  const COLOR_MODES = [{
33449
+ icon: "toolbar.sun",
33024
33450
  label: "Light",
33025
33451
  value: "light"
33026
33452
  }, {
33453
+ icon: "toolbar.moon",
33027
33454
  label: "Dark",
33028
33455
  value: "dark"
33029
33456
  }, {
33457
+ icon: "toolbar.display",
33030
33458
  label: "System",
33031
33459
  value: "system"
33032
33460
  }];
@@ -33044,8 +33472,9 @@ function ColorModeSwitcher() {
33044
33472
  }, "Appearance", " "), /*#__PURE__*/React__default.createElement("div", {
33045
33473
  className: styles$2["color-mode-switcher-modes"]
33046
33474
  }, COLOR_MODES.map(({
33047
- value,
33048
- label
33475
+ icon,
33476
+ label,
33477
+ value
33049
33478
  }) => (/*#__PURE__*/React__default.createElement("label", {
33050
33479
  key: value
33051
33480
  }, /*#__PURE__*/React__default.createElement("input", {
@@ -33062,8 +33491,9 @@ function ColorModeSwitcher() {
33062
33491
  document.documentElement.setAttribute(RESOLVED_COLOR_MODE_ATTRIBUTE, resolveColorMode(colorMode));
33063
33492
  updateColorMode(colorMode);
33064
33493
  }
33065
- }), /*#__PURE__*/React__default.createElement(ColorModeIcon, {
33066
- mode: value
33494
+ }), /*#__PURE__*/React__default.createElement(Icon, {
33495
+ name: icon,
33496
+ size: 15
33067
33497
  }), label)))));
33068
33498
  }
33069
33499
  function updateColorMode(mode) {
@@ -33078,28 +33508,6 @@ function updateColorMode(mode) {
33078
33508
  method: "POST"
33079
33509
  });
33080
33510
  }
33081
- const COLOR_MODE_ICON_PATHS = {
33082
- dark: "M12.66667,9.07579 C11.90047,9.42229 11.04997,9.61514 10.15457,9.61514 C6.78392,9.61514 4.05151,6.88275 4.05151,3.51215 C4.05151,2.61663 4.24438,1.76615 4.59086,1 C2.47336,1.95762 1,4.08858 1,6.56366 C1,9.93423 3.73241,12.66663 7.103,12.66663 C9.57807,12.66663 11.70907,11.19333 12.66667,9.07579 Z",
33083
- light: "M6.83333,1 L6.83333,2.16667 M6.83333,11.50003 L6.83333,12.66663 M2.16667,6.83334 L1,6.83334 M3.51657,3.51657 L2.69161,2.69161 M10.1501,3.51657 L10.9751,2.69161 M3.51657,10.15253 L2.69161,10.97743 M10.1501,10.15253 L10.9751,10.97743 M12.6667,6.83334 L11.5,6.83334 M9.75,6.83334 C9.75,8.44415 8.44415,9.75 6.83333,9.75 C5.2225,9.75 3.91667,8.44415 3.91667,6.83334 C3.91667,5.22251 5.2225,3.91667 6.83333,3.91667 C8.44415,3.91667 9.75,5.22251 9.75,6.83334 Z",
33084
- system: "M4.75,12.50003 L9.41667,12.50003 M7.08334,10.16667 L7.08334,12.50003 M4.05,10.16667 L10.11665,10.16667 C11.09675,10.16667 11.58675,10.16667 11.96115,9.97592 C12.29045,9.80815 12.55815,9.54046 12.72595,9.21117 C12.91665,8.83678 12.91665,8.34678 12.91665,7.36667 L12.91665,4.8 C12.91665,3.81991 12.91665,3.32986 12.72595,2.95552 C12.55815,2.62623 12.29045,2.35852 11.96115,2.19074 C11.58675,2 11.09675,2 10.11665,2 L4.05,2 C3.06991,2 2.57986,2 2.20552,2.19074 C1.87623,2.35852 1.60852,2.62623 1.44074,2.95552 C1.25,3.32986 1.25,3.81991 1.25,4.8 L1.25,7.36667 C1.25,8.34678 1.25,8.83678 1.44074,9.21117 C1.60852,9.54046 1.87623,9.80815 2.20552,9.97592 C2.57986,10.16667 3.06991,10.16667 4.05,10.16667 Z"
33085
- };
33086
- function ColorModeIcon({
33087
- mode
33088
- }) {
33089
- return /*#__PURE__*/React__default.createElement("svg", {
33090
- width: "16",
33091
- height: "16",
33092
- viewBox: "0 0 14 14",
33093
- xmlns: "http://www.w3.org/2000/svg",
33094
- "aria-hidden": "true"
33095
- }, /*#__PURE__*/React__default.createElement("path", {
33096
- d: COLOR_MODE_ICON_PATHS[mode],
33097
- strokeWidth: "1.5",
33098
- fill: "none",
33099
- strokeLinecap: "round",
33100
- strokeLinejoin: "round"
33101
- }));
33102
- }
33103
33511
 
33104
33512
  const requestClearConnectedPeopleCache = () => remove();
33105
33513
  function UserProfile({
@@ -33164,8 +33572,8 @@ function UserProfile({
33164
33572
  width: "100%"
33165
33573
  }, /*#__PURE__*/React__default.createElement(Icon, {
33166
33574
  name: mfaConfigured ? "general.lock" : "general.unlock",
33167
- size: "sm",
33168
- color: mfaConfigured ? "var(--t-icon-color-status-success)" : "var(--t-icon-color-status-error)"
33575
+ size: 12,
33576
+ theme: mfaConfigured ? "--t-icon-color-status-success" : "--t-icon-color-status-error"
33169
33577
  }), /*#__PURE__*/React__default.createElement(Text, {
33170
33578
  size: 4,
33171
33579
  weight: 400,
@@ -33215,7 +33623,8 @@ function UserProfile({
33215
33623
  size: 3
33216
33624
  }, "Log out"), /*#__PURE__*/React__default.createElement(Icon, {
33217
33625
  name: "general.intoBox",
33218
- fill: "var(--t-icon-color-secondary)"
33626
+ size: 14,
33627
+ theme: "--t-icon-color-secondary"
33219
33628
  })))), env !== "production" && /*#__PURE__*/React__default.createElement(DebugPcoResource, {
33220
33629
  env: env
33221
33630
  }), hasConnectedPeople && (/*#__PURE__*/React__default.createElement(StackView$2, {
@@ -33277,26 +33686,42 @@ function UserProfile({
33277
33686
 
33278
33687
  var ActionType;
33279
33688
  (function (ActionType) {
33689
+ ActionType["clearPending"] = "clearPending";
33280
33690
  ActionType["close"] = "close";
33281
33691
  ActionType["mount"] = "mount";
33282
33692
  ActionType["open"] = "open";
33693
+ ActionType["setPending"] = "setPending";
33283
33694
  ActionType["unmount"] = "unmount";
33284
33695
  })(ActionType || (ActionType = {}));
33285
33696
  const initialState = {
33286
33697
  currentContent: null,
33287
- open: false
33698
+ open: false,
33699
+ pending: null
33288
33700
  };
33289
33701
  const reducer = (state, action) => {
33702
+ var _a, _b;
33290
33703
  switch (action.type) {
33291
33704
  case ActionType.mount:
33292
33705
  return {
33293
33706
  ...state,
33294
- currentContent: action.payload.content
33707
+ currentContent: action.payload.content,
33708
+ pending: ((_a = state.pending) === null || _a === void 0 ? void 0 : _a.tool) === ((_b = action.payload.content) === null || _b === void 0 ? void 0 : _b.name) ? state.pending : null
33295
33709
  };
33296
33710
  case ActionType.unmount:
33297
33711
  return {
33298
33712
  ...state,
33299
- currentContent: null
33713
+ currentContent: null,
33714
+ pending: null
33715
+ };
33716
+ case ActionType.setPending:
33717
+ return {
33718
+ ...state,
33719
+ pending: action.payload
33720
+ };
33721
+ case ActionType.clearPending:
33722
+ return {
33723
+ ...state,
33724
+ pending: null
33300
33725
  };
33301
33726
  case ActionType.open:
33302
33727
  return {
@@ -33317,11 +33742,179 @@ const useToolbarReducer = overrides => React.useReducer(reducer, {
33317
33742
 
33318
33743
  var styles$1 = {"toolbar":"toolbar-KHJR5","toolbar-button":"toolbar-button-Ye4h5","is-current":"is-current-DZK47","toolbar-button--avatar":"toolbar-button--avatar-5FUik","toolbar-button--home":"toolbar-button--home-Pthd0"};
33319
33744
 
33320
- const pcoUrl = pcoUrl$1;
33745
+ // PCO Box serves TLS by default, and a bare http:// frame inside an https
33746
+ // product page is blocked as mixed content, so follow the page's scheme.
33747
+ const iframeSrc = () => `${window.location.protocol}//pco.test/iframe`;
33748
+ const STAFF_PINK = "var(--t-fill-color-product-staff)";
33749
+ // The pco.test domain is only ever served by a local PCO Box, so the tool
33750
+ // stays hidden in staging and production — and on Topbar's own dev server,
33751
+ // which runs on localhost and has no pco.test to frame.
33752
+ function isPcoTestHost() {
33753
+ const {
33754
+ hostname
33755
+ } = window.location;
33756
+ return hostname === "pco.test" || hostname.endsWith(".pco.test");
33757
+ }
33758
+ // Flattened from the source artwork's two outline paths — its gradients,
33759
+ // filters and clip paths carry nothing at icon size. Those paths are authored
33760
+ // on a 128px canvas, hence the transform: it fits the pair into the same ~24px
33761
+ // optical box the sibling toolbar icons occupy. Filled explicitly rather than
33762
+ // with `currentColor` so the mark stays staff pink instead of inheriting the
33763
+ // toolbar's text color and its hover/active states.
33764
+ const WRENCH = "m22.42 17.2 12.96 12.95-2.5 8.78-8.78 2.5-12.96-12.96-5.86 5.86 14.89 21.46 17.56-3.34 61.63 65.62c9.44 9.51 22.17-4.75 13.34-12.87L46.85 42.92l4.01-17.76-22.14-14.27-6.3 6.31zm81.06 91.97c1.68-1.68 4.4-1.68 6.08 0 1.68 1.68 1.68 4.4 0 6.08-1.68 1.67-4.4 1.67-6.08 0-1.68-1.68-1.68-4.4 0-6.08z";
33765
+ const SCREWDRIVER = "m55.8 12 .76.16c8.84-2.94 28.68 8.64 24.26 18.96-1.12 1.62-.93 4.5.42 6.31L56.43 62.25c-1.39-1.39-4.48-1.36-5.9 0L9.5 101.63c-3.74 3.59-.8 6.64.78 8.25l7.36 7.36c1.61 1.6 5.86 3.76 8.88.56l38.72-40.81c1.35-1.42 1.36-4.47 0-5.87 0 0 .01-.02 0-.02l24.91-24.9c2.63 2.37 5.11 2.57 7.47 1.93 4.55-1.25 7.09 3.21 9.81 6.46l-1.23 1.21c-1.82 1.84-1.65 4.94.39 6.98s5.15 2.21 6.97.39l8.14-8.13c1.82-1.84 1.65-4.94-.4-6.97-1.84-1.85-4.51-2.09-6.37-.77-5.58-6.26-2.65-7.2-10.01-15.35L91.13 17.86C81.2 8.12 68.3 8.13 55.8 12z";
33766
+ function ToolsIcon() {
33767
+ return /*#__PURE__*/React__default.createElement("svg", {
33768
+ "aria-hidden": "true",
33769
+ width: "32",
33770
+ height: "32",
33771
+ viewBox: "0 0 32 32",
33772
+ xmlns: "http://www.w3.org/2000/svg"
33773
+ }, /*#__PURE__*/React__default.createElement("g", {
33774
+ fill: STAFF_PINK,
33775
+ transform: "translate(2.92 2.66) scale(.204)"
33776
+ }, /*#__PURE__*/React__default.createElement("path", {
33777
+ d: WRENCH
33778
+ }), /*#__PURE__*/React__default.createElement("path", {
33779
+ d: SCREWDRIVER
33780
+ })));
33781
+ }
33782
+ // A cross-origin frame's own history object is unreachable (touching it throws
33783
+ // a SecurityError), but the frame's navigations do land in the top-level joint
33784
+ // session history, so the *parent's* back/forward traverse them while leaving
33785
+ // the host page in place. Counting frame loads tracks how deep we are, which is
33786
+ // what keeps a click on Back from stepping past the frame's first page and
33787
+ // navigating the host app instead. Loads we caused are attributed to the
33788
+ // traversal that requested them rather than counted as new navigations.
33789
+ function useFrameHistory() {
33790
+ const [steps, setSteps] = useState({
33791
+ back: 0,
33792
+ forward: 0
33793
+ });
33794
+ // Traversal has to read the current counts outside of render, so the ref is
33795
+ // the source of truth and state exists to paint the buttons.
33796
+ const stepsRef = useRef(steps);
33797
+ const pendingTraversal = useRef(null);
33798
+ const frameIsLoaded = useRef(false);
33799
+ const moveTo = useCallback(next => {
33800
+ stepsRef.current = next;
33801
+ setSteps(next);
33802
+ }, []);
33803
+ const onFrameLoad = useCallback(() => {
33804
+ if (!frameIsLoaded.current) {
33805
+ // The frame's first page is the floor, not a step.
33806
+ frameIsLoaded.current = true;
33807
+ } else {
33808
+ const traversal = pendingTraversal.current;
33809
+ pendingTraversal.current = null;
33810
+ const {
33811
+ back,
33812
+ forward
33813
+ } = stepsRef.current;
33814
+ if (traversal === "back") {
33815
+ moveTo({
33816
+ back: back - 1,
33817
+ forward: forward + 1
33818
+ });
33819
+ } else if (traversal === "forward") {
33820
+ moveTo({
33821
+ back: back + 1,
33822
+ forward: forward - 1
33823
+ });
33824
+ } else {
33825
+ moveTo({
33826
+ back: back + 1,
33827
+ forward: 0
33828
+ });
33829
+ }
33830
+ }
33831
+ }, [moveTo]);
33832
+ // Guarded here rather than trusting the button's disabled state: traversing
33833
+ // with no frame step left would navigate the host app out from under us.
33834
+ const traverse = useCallback(direction => {
33835
+ if (stepsRef.current[direction] > 0) {
33836
+ pendingTraversal.current = direction;
33837
+ window.history[direction]();
33838
+ }
33839
+ }, []);
33840
+ return {
33841
+ onFrameLoad,
33842
+ steps,
33843
+ traverse
33844
+ };
33845
+ }
33846
+ function NavButton({
33847
+ direction,
33848
+ disabled,
33849
+ onClick
33850
+ }) {
33851
+ return /*#__PURE__*/React__default.createElement(Button$1
33852
+ // Tapestry's `disabled` suppresses the click but emits no ARIA state.
33853
+ , {
33854
+ "aria-disabled": disabled,
33855
+ disabled: disabled,
33856
+ hover: {
33857
+ backgroundColor: "var(--t-fill-color-neutral-070)"
33858
+ },
33859
+ icon: {
33860
+ fill: "var(--t-icon-color-dim)",
33861
+ name: direction === "back" ? "general.leftChevron" : "general.rightChevron",
33862
+ size: "md"
33863
+ },
33864
+ onClick: onClick,
33865
+ opacity: disabled ? 0.35 : 1,
33866
+ theme: false,
33867
+ title: direction === "back" ? "Back" : "Forward"
33868
+ });
33869
+ }
33870
+ function DeveloperToolsComponent({
33871
+ closeDrawer
33872
+ }) {
33873
+ const {
33874
+ onFrameLoad,
33875
+ steps,
33876
+ traverse
33877
+ } = useFrameHistory();
33878
+ return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement(DrawerHeader, {
33879
+ closeDrawer: closeDrawer
33880
+ }, /*#__PURE__*/React__default.createElement(StackView$2, {
33881
+ alignment: "center",
33882
+ axis: "horizontal",
33883
+ spacing: 0.25
33884
+ }, /*#__PURE__*/React__default.createElement(NavButton, {
33885
+ direction: "back",
33886
+ disabled: steps.back === 0,
33887
+ onClick: () => traverse("back")
33888
+ }), /*#__PURE__*/React__default.createElement(NavButton, {
33889
+ direction: "forward",
33890
+ disabled: steps.forward === 0,
33891
+ onClick: () => traverse("forward")
33892
+ }), /*#__PURE__*/React__default.createElement(DrawerHeading, {
33893
+ paddingLeft: 0.5
33894
+ }, "Developer Tools"))), /*#__PURE__*/React__default.createElement(Box, {
33895
+ as: "iframe",
33896
+ border: "none",
33897
+ flex: "1",
33898
+ minHeight: 0,
33899
+ onLoad: onFrameLoad,
33900
+ src: iframeSrc(),
33901
+ title: "Developer Tools",
33902
+ width: "100%"
33903
+ }));
33904
+ }
33905
+ const DeveloperTools = {
33906
+ component: DeveloperToolsComponent,
33907
+ icons: {
33908
+ component: ToolsIcon
33909
+ },
33910
+ label: "Developer Tools",
33911
+ // No space: `name` becomes an element id and a pendo attribute.
33912
+ name: "DeveloperTools"
33913
+ };
33321
33914
 
33322
33915
  var styles = {"banner":"banner-YfQ37","stacked":"stacked-EwLRh","content":"content-lNjWf","icon":"icon-abz7q","iconShape":"iconShape-6uyot","iconSparkle":"iconSparkle-t0OzK","text":"text--Vlhl","title":"title-zOEOR","subtitle":"subtitle-ufLnk","chevron":"chevron-oOUA-","inlineContent":"inlineContent-h5sVl","sparkle":"sparkle-uuMBp","inlineTitle":"inlineTitle-ru2Rk","action":"action-fSY3n","actionLabel":"actionLabel-MRBjb","actionChevron":"actionChevron-DghlN"};
33323
33916
 
33324
- const SPARKLE_PATH = "M3.809 10.1332C3.87511 9.95596 4.12579 9.95588 4.19181 10.1332L4.63712 11.3363C4.64169 11.3487 4.65116 11.359 4.66349 11.3637L5.86661 11.809C6.04452 11.8749 6.04455 12.126 5.86661 12.1918L4.66349 12.6371C4.65134 12.6417 4.64173 12.6513 4.63712 12.6635L4.19181 13.8666C4.12597 14.0445 3.87486 14.0445 3.809 13.8666L3.36368 12.6635C3.35905 12.6512 3.3487 12.6417 3.33634 12.6371L2.13321 12.1918C1.95588 12.1258 1.95596 11.8751 2.13321 11.809L3.33634 11.3637C3.34877 11.3591 3.35908 11.3488 3.36368 11.3363L3.809 10.1332ZM8.52091 3.33341C8.68571 2.88899 9.31518 2.88891 9.47989 3.33341L10.5922 6.34122C10.6037 6.37226 10.6285 6.39712 10.6596 6.4086L13.6664 7.52091C14.1113 7.68553 14.1113 8.31528 13.6664 8.47989L10.6596 9.5922C10.6285 9.6037 10.6037 9.6285 10.5922 9.65958L9.47989 12.6664C9.31528 13.1113 8.68553 13.1113 8.52091 12.6664L7.4086 9.65958C7.39712 9.62853 7.37226 9.60371 7.34122 9.5922L4.33341 8.47989C3.88891 8.31518 3.88899 7.68571 4.33341 7.52091L7.34122 6.4086C7.3723 6.3971 7.3971 6.3723 7.4086 6.34122L8.52091 3.33341ZM3.809 2.13321C3.87511 1.95596 4.12579 1.95588 4.19181 2.13321L4.63712 3.33634C4.64169 3.3487 4.65116 3.35905 4.66349 3.36368L5.86661 3.809C6.04452 3.87486 6.04455 4.12597 5.86661 4.19181L4.66349 4.63712C4.65134 4.64173 4.64173 4.65134 4.63712 4.66349L4.19181 5.86661C4.12597 6.04455 3.87486 6.04452 3.809 5.86661L3.36368 4.66349C3.35905 4.65116 3.3487 4.64169 3.33634 4.63712L2.13321 4.19181C1.95588 4.12579 1.95596 3.87511 2.13321 3.809L3.33634 3.36368C3.34877 3.35908 3.35908 3.34877 3.36368 3.33634L3.809 2.13321Z";
33917
+ const SPARKLE_PATH$1 = "M3.809 10.1332C3.87511 9.95596 4.12579 9.95588 4.19181 10.1332L4.63712 11.3363C4.64169 11.3487 4.65116 11.359 4.66349 11.3637L5.86661 11.809C6.04452 11.8749 6.04455 12.126 5.86661 12.1918L4.66349 12.6371C4.65134 12.6417 4.64173 12.6513 4.63712 12.6635L4.19181 13.8666C4.12597 14.0445 3.87486 14.0445 3.809 13.8666L3.36368 12.6635C3.35905 12.6512 3.3487 12.6417 3.33634 12.6371L2.13321 12.1918C1.95588 12.1258 1.95596 11.8751 2.13321 11.809L3.33634 11.3637C3.34877 11.3591 3.35908 11.3488 3.36368 11.3363L3.809 10.1332ZM8.52091 3.33341C8.68571 2.88899 9.31518 2.88891 9.47989 3.33341L10.5922 6.34122C10.6037 6.37226 10.6285 6.39712 10.6596 6.4086L13.6664 7.52091C14.1113 7.68553 14.1113 8.31528 13.6664 8.47989L10.6596 9.5922C10.6285 9.6037 10.6037 9.6285 10.5922 9.65958L9.47989 12.6664C9.31528 13.1113 8.68553 13.1113 8.52091 12.6664L7.4086 9.65958C7.39712 9.62853 7.37226 9.60371 7.34122 9.5922L4.33341 8.47989C3.88891 8.31518 3.88899 7.68571 4.33341 7.52091L7.34122 6.4086C7.3723 6.3971 7.3971 6.3723 7.4086 6.34122L8.52091 3.33341ZM3.809 2.13321C3.87511 1.95596 4.12579 1.95588 4.19181 2.13321L4.63712 3.33634C4.64169 3.3487 4.65116 3.35905 4.66349 3.36368L5.86661 3.809C6.04452 3.87486 6.04455 4.12597 5.86661 4.19181L4.66349 4.63712C4.65134 4.64173 4.64173 4.65134 4.63712 4.66349L4.19181 5.86661C4.12597 6.04455 3.87486 6.04452 3.809 5.86661L3.36368 4.66349C3.35905 4.65116 3.3487 4.64169 3.33634 4.63712L2.13321 4.19181C1.95588 4.12579 1.95596 3.87511 2.13321 3.809L3.33634 3.36368C3.34877 3.35908 3.35908 3.34877 3.36368 3.33634L3.809 2.13321Z";
33325
33918
  // Same stops as the AskAIBanner border gradient, so the two stay in sync.
33326
33919
  const GRADIENT_STOPS = [{
33327
33920
  color: "var(--t-fill-color-tag-teal-040)",
@@ -33370,7 +33963,7 @@ function SparkleIcon({
33370
33963
  stopColor: color
33371
33964
  }
33372
33965
  })))))), /*#__PURE__*/React__default.createElement("path", {
33373
- d: SPARKLE_PATH,
33966
+ d: SPARKLE_PATH$1,
33374
33967
  fill: isGradient ? `url(#${gradientId})` : "currentColor"
33375
33968
  }));
33376
33969
  }
@@ -33402,7 +33995,7 @@ function StackedContent({
33402
33995
  className: styles.title
33403
33996
  }, title), /*#__PURE__*/React__default.createElement("span", {
33404
33997
  className: styles.subtitle
33405
- }, "Support answers tailored to you"))), /*#__PURE__*/React__default.createElement(Icon, {
33998
+ }, "Support answers tailored to you"))), /*#__PURE__*/React__default.createElement(Icon$1, {
33406
33999
  className: styles.chevron,
33407
34000
  name: "general.rightChevron",
33408
34001
  size: "16px"
@@ -33423,7 +34016,7 @@ function InlineContent({
33423
34016
  className: styles.action
33424
34017
  }, /*#__PURE__*/React__default.createElement("span", {
33425
34018
  className: styles.actionLabel
33426
- }, action), /*#__PURE__*/React__default.createElement(Icon, {
34019
+ }, action), /*#__PURE__*/React__default.createElement(Icon$1, {
33427
34020
  className: styles.actionChevron,
33428
34021
  name: "general.rightChevron",
33429
34022
  size: "14px"
@@ -33646,7 +34239,7 @@ const HeadingIcon = ({
33646
34239
  const logoName = appName.toLowerCase().replace(/\s+/g, "-");
33647
34240
  // Accounts has no tapestry Logo, so use the cog icon
33648
34241
  if (logoName === "accounts") {
33649
- return /*#__PURE__*/React__default.createElement(Icon, {
34242
+ return /*#__PURE__*/React__default.createElement(Icon$1, {
33650
34243
  name: "general.cog",
33651
34244
  color: "foregroundSecondary"
33652
34245
  });
@@ -33710,7 +34303,7 @@ const CustomSearchBox = ({
33710
34303
  },
33711
34304
  placeholder: "Search our documentation",
33712
34305
  value: query,
33713
- renderRight: /*#__PURE__*/React__default.createElement(Icon, {
34306
+ renderRight: /*#__PURE__*/React__default.createElement(Icon$1, {
33714
34307
  name: "general.search"
33715
34308
  }),
33716
34309
  onChange: e => setQuery(e.target.value),
@@ -33778,16 +34371,24 @@ const DocumentationSearch = ({
33778
34371
  // not the path, so updating it is a same-document navigation that doesn't
33779
34372
  // reload the iframe, but also lets the route survive a forced reload (e.g.
33780
34373
  // Turbo re-parenting the topbar).
33781
- function buildPicobotSrc(origin, path) {
34374
+ //
34375
+ // `panel` loads picobot as its own toolbar panel rather than inside Helpdesk.
34376
+ function buildPicobotSrc(origin, path, {
34377
+ panel = false
34378
+ } = {}) {
33782
34379
  const [rawPath, queryPart] = path.replace(/^\/+/, "").split("?", 2);
33783
34380
  const safePath = rawPath.split("/").filter(segment => segment && segment !== "." && segment !== "..").join("/");
33784
34381
  const fragment = queryPart !== undefined ? `${safePath}?${queryPart}` : safePath;
33785
- const base = new URL("topbar", origin).toString();
34382
+ const url = new URL("topbar", origin);
34383
+ if (panel) url.searchParams.set("panel", "1");
34384
+ const base = url.toString();
33786
34385
  return fragment ? `${base}#${fragment}` : base;
33787
34386
  }
33788
34387
 
33789
34388
  const LOAD_TIMEOUT_MS = 3000;
33790
- function createPicobotIframe(picobotOrigin) {
34389
+ function createPicobotIframe(picobotOrigin, {
34390
+ panel = false
34391
+ } = {}) {
33791
34392
  return /*#__PURE__*/React__default.forwardRef(function PicobotIframe({
33792
34393
  currentPath: iframePath,
33793
34394
  onChangePath: onIframeChangePath,
@@ -33843,7 +34444,9 @@ function createPicobotIframe(picobotOrigin) {
33843
34444
  (_b = (_a = iframeRef.current) === null || _a === void 0 ? void 0 : _a.contentWindow) === null || _b === void 0 ? void 0 : _b.postMessage(message, picobotOrigin);
33844
34445
  }
33845
34446
  }), []);
33846
- const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath);
34447
+ const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath, {
34448
+ panel
34449
+ });
33847
34450
  return /*#__PURE__*/React__default.createElement("iframe", {
33848
34451
  ref: iframeRef,
33849
34452
  src: iframeSrc,
@@ -33882,22 +34485,28 @@ function HelpdeskComponent({
33882
34485
  currentPath,
33883
34486
  env,
33884
34487
  helpdeskConfig,
33885
- onChangePath
34488
+ isPicobotLoading,
34489
+ onChangePath,
34490
+ openPicobot
33886
34491
  }) {
33887
34492
  const PicobotIframe = React__default.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot")), [env]);
34493
+ const loadingFallback = /*#__PURE__*/React__default.createElement(StackView$1, {
34494
+ flex: 1,
34495
+ axis: "vertical"
34496
+ }, /*#__PURE__*/React__default.createElement(DrawerHeader, {
34497
+ closeDrawer: closeDrawer
34498
+ }, /*#__PURE__*/React__default.createElement(StackView$1, {
34499
+ axis: "horizontal",
34500
+ spacing: 1
34501
+ }, /*#__PURE__*/React__default.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React__default.createElement(LoadingIndicator$1, null))));
34502
+ // The add-on reads its args once, so mounting before the gate answers
34503
+ // would leave out openPicobot for good.
34504
+ if (isPicobotLoading) return loadingFallback;
33888
34505
  return /*#__PURE__*/React__default.createElement(UiExtensionFirstPartyComponent, {
33889
34506
  grow: 1,
33890
34507
  display: "flex",
33891
34508
  extensionId: env === "staging" ? "42" : "11",
33892
- loadingFallback: /*#__PURE__*/React__default.createElement(StackView$1, {
33893
- flex: 1,
33894
- axis: "vertical"
33895
- }, /*#__PURE__*/React__default.createElement(DrawerHeader, {
33896
- closeDrawer: closeDrawer
33897
- }, /*#__PURE__*/React__default.createElement(StackView$1, {
33898
- axis: "horizontal",
33899
- spacing: 1
33900
- }, /*#__PURE__*/React__default.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React__default.createElement(LoadingIndicator$1, null)))),
34509
+ loadingFallback: loadingFallback,
33901
34510
  additionalComponents: {
33902
34511
  AskAIBanner: {
33903
34512
  component: AskAIBanner,
@@ -33995,7 +34604,11 @@ function HelpdeskComponent({
33995
34604
  currentPageParams: location.search,
33996
34605
  currentPath,
33997
34606
  helpdeskConfig,
33998
- onChangePath
34607
+ onChangePath,
34608
+ // Its presence tells helpdesk that Picobot has its own panel.
34609
+ ...(openPicobot && {
34610
+ openPicobot
34611
+ })
33999
34612
  }
34000
34613
  });
34001
34614
  }
@@ -34542,6 +35155,142 @@ const Notifications = {
34542
35155
  name: "Notifications"
34543
35156
  };
34544
35157
 
35158
+ const SPARKLE_PATH = "M8.7824 18.8791C8.88371 18.5973 9.26996 18.5973 9.37127 18.8791L9.94256 20.465C10.0222 20.6863 10.1914 20.8616 10.4064 20.9435L11.6779 21.4269L11.9484 21.5304L11.9963 21.5539C12.2205 21.6839 12.2047 22.039 11.9484 22.1369L11.6779 22.2394L10.4064 22.7248C10.1915 22.8068 10.0222 22.9812 9.94256 23.2023L9.37127 24.7893L9.34881 24.8381C9.23098 25.054 8.92273 25.0539 8.80486 24.8381L8.7824 24.7893L8.2111 23.2023C8.14141 23.0088 8.0029 22.8513 7.82536 22.76L7.74723 22.7248L6.47573 22.2394L6.20522 22.1369C5.93172 22.0325 5.93164 21.6347 6.20522 21.5304L6.47476 21.4269L7.74723 20.9435C7.96226 20.8616 8.13147 20.6863 8.2111 20.465L8.7824 18.8791ZM17.5706 8.11138C17.8241 7.40747 18.7928 7.40734 19.0462 8.11138L20.472 12.0782C20.6711 12.6318 21.0952 13.0687 21.6331 13.2735L25.4867 14.7413C26.1711 15.002 26.1711 15.9983 25.4867 16.2589L21.6331 17.7267C21.0952 17.9316 20.6711 18.3684 20.472 18.922L19.2649 22.2785L19.0462 22.8889L18.9905 23.0119C18.6956 23.5517 17.9212 23.5515 17.6262 23.0119L17.5706 22.8889L17.3508 22.2795L16.1448 18.922C15.9457 18.3684 15.5206 17.9316 14.9827 17.7267L11.1291 16.2589C10.4449 15.9982 10.4449 15.0021 11.1291 14.7413L14.9827 13.2735C15.4533 13.0943 15.8379 12.7376 16.0598 12.2804L16.1448 12.0782L17.5706 8.11138ZM18.0266 12.755C17.6351 13.8438 16.7916 14.7249 15.6946 15.1427L14.7551 15.5001L15.6946 15.8575C16.7916 16.2753 17.6351 17.1564 18.0266 18.2453L18.3079 19.0275L18.5891 18.2453C18.9806 17.1564 19.8242 16.2753 20.9212 15.8575L21.8597 15.5001L20.9212 15.1427C19.8242 14.7249 18.9806 13.8439 18.5891 12.755L18.3079 11.9718L18.0266 12.755ZM8.7824 6.21097C8.88388 5.9297 9.26982 5.92966 9.37127 6.21097L9.94256 7.7979C10.0222 8.01911 10.1915 8.19346 10.4064 8.27544L11.6779 8.75982L11.8322 9.16607L11.9484 8.86334C12.2218 8.96779 12.2218 9.36542 11.9484 9.46979L11.6779 9.57233L10.4064 10.0567C10.1914 10.1387 10.0222 10.3139 9.94256 10.5352L9.37127 12.1212C9.26996 12.4029 8.88371 12.4029 8.7824 12.1212L8.2111 10.5352C8.14139 10.3415 8.00304 10.1832 7.82536 10.0919L7.74723 10.0567L6.47573 9.57233L6.20522 9.46979C5.9486 9.37195 5.93265 9.01568 6.15737 8.8858L6.20522 8.86334L7.74723 8.27544C7.96214 8.19346 8.13149 8.0191 8.2111 7.7979L8.69451 6.45413L8.7824 6.21097ZM11.9484 8.86334L11.8322 9.16607L11.6779 8.75982L11.9484 8.86334Z";
35159
+ // Helpdesk reads this context off the email path to open its email form the
35160
+ // same way a handoff inside its own drawer does.
35161
+ function emailPathWithContext(context = {}) {
35162
+ const params = new URLSearchParams();
35163
+ for (const key of ["conversationId", "source", "turnIndex"]) {
35164
+ const value = context[key];
35165
+ if (value !== null && value !== undefined) params.set(key, String(value));
35166
+ }
35167
+ return `email?${params}`;
35168
+ }
35169
+ function PicobotComponent({
35170
+ clearPending,
35171
+ closeDrawer,
35172
+ currentPath,
35173
+ env,
35174
+ onChangePath,
35175
+ openTool,
35176
+ pending
35177
+ }) {
35178
+ const PicobotIframe = React__default.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot"), {
35179
+ panel: true
35180
+ }), [env]);
35181
+ const picobotRef = React__default.useRef(null);
35182
+ const [isPicobotReady, setIsPicobotReady] = React__default.useState(false);
35183
+ // Picobot has no listener until it sends READY, so openPicobot's draft or
35184
+ // conversation waits for it.
35185
+ React__default.useEffect(function handOverPendingOncePicobotIsReady() {
35186
+ var _a, _b;
35187
+ if (!pending || !isPicobotReady) return;
35188
+ const {
35189
+ conversationId,
35190
+ draft,
35191
+ ticketExternalId
35192
+ } = pending;
35193
+ if (conversationId) {
35194
+ (_a = picobotRef.current) === null || _a === void 0 ? void 0 : _a.postMessage({
35195
+ payload: {
35196
+ conversation_id: conversationId,
35197
+ // Picobot asks whether this resolved the ticket, and closes it.
35198
+ ...(ticketExternalId && {
35199
+ external_id: ticketExternalId,
35200
+ source: "ticket_submitted"
35201
+ })
35202
+ },
35203
+ type: "LOAD_CONVERSATION"
35204
+ });
35205
+ } else if (draft) {
35206
+ // Not both: Picobot cancels a conversation load when a prefill
35207
+ // starts a new conversation.
35208
+ (_b = picobotRef.current) === null || _b === void 0 ? void 0 : _b.postMessage({
35209
+ payload: {
35210
+ source: "email_form",
35211
+ text: draft
35212
+ },
35213
+ type: "PREFILL_COMPOSER"
35214
+ });
35215
+ }
35216
+ clearPending();
35217
+ }, [pending, isPicobotReady, clearPending]);
35218
+ const [iframeErrored, setIframeErrored] = React__default.useState(false);
35219
+ const [mountKey, setMountKey] = React__default.useState(0);
35220
+ const handleRetry = React__default.useCallback(() => {
35221
+ setIframeErrored(false);
35222
+ setIsPicobotReady(false);
35223
+ setMountKey(key => key + 1);
35224
+ }, []);
35225
+ const handleMessage = React__default.useCallback(data => {
35226
+ var _a;
35227
+ const message = data;
35228
+ switch (message === null || message === void 0 ? void 0 : message.type) {
35229
+ case "CLOSE_DRAWER":
35230
+ return closeDrawer();
35231
+ case "READY":
35232
+ return setIsPicobotReady(true);
35233
+ case "PICOBOT_SUPPORT_HANDOFF":
35234
+ return openTool("helpdesk", ((_a = message.payload) === null || _a === void 0 ? void 0 : _a.method) === "zoom" ? "phone" : emailPathWithContext(message.payload));
35235
+ case "IFRAME_LOAD_ERROR":
35236
+ console.warn("topbar: Picobot iframe failed to load");
35237
+ return setIframeErrored(true);
35238
+ }
35239
+ }, [closeDrawer, openTool]);
35240
+ if (iframeErrored) {
35241
+ return /*#__PURE__*/React__default.createElement(LoadError, {
35242
+ onContactSupport: () => openTool("helpdesk", "email"),
35243
+ onRetry: handleRetry
35244
+ });
35245
+ }
35246
+ return /*#__PURE__*/React__default.createElement(PicobotIframe, {
35247
+ key: mountKey,
35248
+ ref: picobotRef,
35249
+ currentPath: currentPath,
35250
+ onChangePath: onChangePath,
35251
+ onMessage: handleMessage
35252
+ });
35253
+ }
35254
+ function LoadError({
35255
+ onContactSupport,
35256
+ onRetry
35257
+ }) {
35258
+ return /*#__PURE__*/React__default.createElement(StackView$2, {
35259
+ axis: "vertical",
35260
+ flex: 1,
35261
+ alignment: "center",
35262
+ distribution: "center",
35263
+ padding: 3,
35264
+ spacing: 1
35265
+ }, /*#__PURE__*/React__default.createElement(Heading, {
35266
+ level: 2
35267
+ }, "Picobot is temporarily unavailable"), /*#__PURE__*/React__default.createElement(Text, {
35268
+ size: 5,
35269
+ color: "var(--t-text-color-secondary)"
35270
+ }, "Try again or contact support."), /*#__PURE__*/React__default.createElement(Box, {
35271
+ paddingTop: 2
35272
+ }, /*#__PURE__*/React__default.createElement(Button$1, {
35273
+ onClick: onRetry,
35274
+ iconLeft: "general.refresh",
35275
+ theme: "primary",
35276
+ variant: "fill",
35277
+ title: "Retry"
35278
+ })), /*#__PURE__*/React__default.createElement(Button$1, {
35279
+ onClick: onContactSupport,
35280
+ variant: "naked",
35281
+ theme: "primary",
35282
+ title: "Contact support"
35283
+ }));
35284
+ }
35285
+ const Picobot = {
35286
+ component: PicobotComponent,
35287
+ icons: {
35288
+ staticDotPath: SPARKLE_PATH,
35289
+ staticPath: SPARKLE_PATH
35290
+ },
35291
+ name: "Picobot"
35292
+ };
35293
+
34545
35294
  const {
34546
35295
  LoadingIndicator,
34547
35296
  StackView
@@ -34937,6 +35686,41 @@ const useChatConfig = ({
34937
35686
  };
34938
35687
  };
34939
35688
 
35689
+ // Picobot's API rejects requests without a version header, and apiRequest
35690
+ // sets its own headers, so the version rides in on the fetch.
35691
+ const picobotFetch = (url, opts) => defaultFetch(url, {
35692
+ ...opts,
35693
+ headers: {
35694
+ ...(opts === null || opts === void 0 ? void 0 : opts.headers),
35695
+ "X-PCO-API-Version": "2026-08-11"
35696
+ }
35697
+ });
35698
+ // Routes wait on the gate, so a slow Picobot can't hold them up for long.
35699
+ const GATE_TIMEOUT_MS = 3000;
35700
+ const usePicobotConfig = ({
35701
+ isEnabled
35702
+ }) => {
35703
+ // Undefined until Picobot answers.
35704
+ const [panelEnabled, setPanelEnabled] = useState();
35705
+ useEffect(() => {
35706
+ if (!isEnabled) return undefined;
35707
+ // Whatever settles first wins, so a late answer can't reopen the gate.
35708
+ const settle = enabled => setPanelEnabled(current => current !== null && current !== void 0 ? current : enabled);
35709
+ const timeout = setTimeout(() => settle(false), GATE_TIMEOUT_MS);
35710
+ apiRequest(picobotFetch, `${window.location.origin}/~api/picobot/v2/me`).then(({
35711
+ json
35712
+ }) => {
35713
+ var _a, _b;
35714
+ return settle(((_b = (_a = json === null || json === void 0 ? void 0 : json.data) === null || _a === void 0 ? void 0 : _a.attributes) === null || _b === void 0 ? void 0 : _b.picobot_panel_enabled) === true);
35715
+ }).catch(() => settle(false)).finally(() => clearTimeout(timeout));
35716
+ return () => clearTimeout(timeout);
35717
+ }, [isEnabled]);
35718
+ return {
35719
+ displayPicobotIcon: panelEnabled === true,
35720
+ isLoading: isEnabled && panelEnabled === undefined
35721
+ };
35722
+ };
35723
+
34940
35724
  const Profile = {
34941
35725
  component: UserProfile,
34942
35726
  icons: {
@@ -34947,10 +35731,16 @@ const Profile = {
34947
35731
  };
34948
35732
 
34949
35733
  const DURATION = 100;
35734
+ // Exempt paths match regardless of query, so `email?conversationId=…` is
35735
+ // exempt along with `email`.
34950
35736
  function isToolPathExemptFromStorage(toolName, path) {
34951
35737
  var _a, _b;
34952
35738
  const tool = toolsRouteMap[toolName];
34953
- return (_b = (_a = tool === null || tool === void 0 ? void 0 : tool.storageExemptPaths) === null || _a === void 0 ? void 0 : _a.includes(path)) !== null && _b !== void 0 ? _b : false;
35739
+ const [pathWithoutQuery] = path.split("?");
35740
+ return (_b = (_a = tool === null || tool === void 0 ? void 0 : tool.storageExemptPaths) === null || _a === void 0 ? void 0 : _a.includes(pathWithoutQuery)) !== null && _b !== void 0 ? _b : false;
35741
+ }
35742
+ function isConversationPath(path) {
35743
+ return Boolean(path === null || path === void 0 ? void 0 : path.startsWith("conversations/"));
34954
35744
  }
34955
35745
  function scrollToolDrawerToTop(toolName) {
34956
35746
  var _a;
@@ -34963,6 +35753,7 @@ const TOOLS_MAP = {
34963
35753
  Chat,
34964
35754
  Helpdesk,
34965
35755
  Notifications,
35756
+ Picobot,
34966
35757
  Profile,
34967
35758
  Tasks
34968
35759
  };
@@ -34990,7 +35781,8 @@ function Toolbar({
34990
35781
  });
34991
35782
  const {
34992
35783
  currentContent,
34993
- open
35784
+ open,
35785
+ pending
34994
35786
  } = state;
34995
35787
  const mounted = !!currentContent;
34996
35788
  const storeData = useStore();
@@ -35003,6 +35795,15 @@ function Toolbar({
35003
35795
  ...(env === "development" ? _storeDataOverride !== null && _storeDataOverride !== void 0 ? _storeDataOverride : {} : {})
35004
35796
  };
35005
35797
  const updateStore = useUpdateStore();
35798
+ const {
35799
+ displayPicobotIcon,
35800
+ isLoading: isPicobotGateLoading
35801
+ } = usePicobotConfig({
35802
+ isEnabled: Boolean(userId)
35803
+ });
35804
+ // Picobot is a kill-switched tool: until its gate says the panel is
35805
+ // enabled, its routes are held, and if it never does, they're ignored.
35806
+ const routableTool = useCallback(name => name === Picobot.name.toLowerCase() && !displayPicobotIcon ? undefined : toolsRouteMap[name], [displayPicobotIcon]);
35006
35807
  React__default.useEffect(() => {
35007
35808
  if (open) {
35008
35809
  document.body.style.overflow = "hidden";
@@ -35090,7 +35891,9 @@ function Toolbar({
35090
35891
  });
35091
35892
  }, [clearRoute]);
35092
35893
  React__default.useEffect(() => {
35093
- if (!toolbarFragmentRoute) return;
35894
+ if (!toolbarFragmentRoute || !routableTool(toolbarFragmentRoute.tool)) {
35895
+ return;
35896
+ }
35094
35897
  const isExempt = isToolPathExemptFromStorage(toolbarFragmentRoute.tool, toolbarFragmentRoute.path);
35095
35898
  if (isExempt) return;
35096
35899
  const nextState = {
@@ -35100,12 +35903,32 @@ function Toolbar({
35100
35903
  if (!shallowCompare(nextState, toolStateCookie)) {
35101
35904
  setToolStateCookie(nextState);
35102
35905
  }
35103
- }, [toolbarFragmentRoute, toolStateCookie]);
35906
+ }, [toolbarFragmentRoute, toolStateCookie, routableTool]);
35104
35907
  React__default.useEffect(() => {
35105
- if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && (currentContent !== toolsRouteMap[toolbarFragmentRoute.tool] || !open)) {
35106
- setToolAndOpenDrawer(toolsRouteMap[toolbarFragmentRoute.tool]);
35908
+ // Helpdesk conversations may belong to Picobot, so they wait for its gate
35909
+ // rather than flashing open in Helpdesk first.
35910
+ const mayRedirectToPicobot = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path) && (isPicobotGateLoading || displayPicobotIcon);
35911
+ const tool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && !mayRedirectToPicobot && routableTool(toolbarFragmentRoute.tool);
35912
+ if (tool && (currentContent !== tool || !open)) {
35913
+ setToolAndOpenDrawer(tool);
35107
35914
  }
35108
- }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, currentContent, open]);
35915
+ }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path, currentContent, open, routableTool, isPicobotGateLoading, displayPicobotIcon]);
35916
+ const openTool = useCallback((name, path, pendingValue) => {
35917
+ const tool = routableTool(name.toLowerCase());
35918
+ if (!tool) return;
35919
+ dispatch({
35920
+ payload: pendingValue === undefined ? null : {
35921
+ tool: tool.name,
35922
+ value: pendingValue
35923
+ },
35924
+ type: ActionType.setPending
35925
+ });
35926
+ onChangeToolbarFragmentRoute(tool.name.toLowerCase(), path);
35927
+ scrollToolDrawerToTop(tool.name);
35928
+ }, [onChangeToolbarFragmentRoute, routableTool]);
35929
+ const clearPending = useCallback(() => dispatch({
35930
+ type: ActionType.clearPending
35931
+ }), []);
35109
35932
  const toggleDrawer = content => {
35110
35933
  if (!open) {
35111
35934
  return openDrawer(content);
@@ -35122,12 +35945,52 @@ function Toolbar({
35122
35945
  joltPromise: storeData === null || storeData === void 0 ? void 0 : storeData.joltPromise,
35123
35946
  userId
35124
35947
  });
35125
- const TOOLS = [Notifications, Tasks, displayChatIcon && Chat].filter(Boolean);
35948
+ // Picobot conversations moved from Helpdesk to the Picobot panel, so
35949
+ // send old links and saved paths there once the panel is enabled.
35950
+ React__default.useEffect(() => {
35951
+ if (!displayPicobotIcon) return;
35952
+ if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path)) {
35953
+ onChangeToolbarFragmentRoute(Picobot.name.toLowerCase(), toolbarFragmentRoute.path);
35954
+ }
35955
+ }, [displayPicobotIcon, toolbarFragmentRoute, onChangeToolbarFragmentRoute]);
35956
+ React__default.useEffect(() => {
35957
+ if (!displayPicobotIcon) return;
35958
+ const {
35959
+ helpdesk,
35960
+ picobot
35961
+ } = toolStateCookie;
35962
+ if (!isConversationPath(helpdesk)) return;
35963
+ setToolStateCookie({
35964
+ ...toolStateCookie,
35965
+ helpdesk: "",
35966
+ picobot: picobot || helpdesk
35967
+ });
35968
+ }, [displayPicobotIcon, toolStateCookie]);
35969
+ const openPicobot = useCallback(options => {
35970
+ var _a;
35971
+ // Moving an open Picobot off its path reloads or navigates the iframe,
35972
+ // which would race the options it's being handed.
35973
+ const path = currentContent === Picobot ? (_a = toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path) !== null && _a !== void 0 ? _a : "" : "";
35974
+ openTool(Picobot.name, path, options);
35975
+ }, [openTool, currentContent, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path]);
35976
+ React__default.useEffect(() => {
35977
+ if (!displayPicobotIcon) return undefined;
35978
+ window.topbar = window.topbar || {};
35979
+ window.topbar.openPicobot = openPicobot;
35980
+ return () => {
35981
+ delete window.topbar.openPicobot;
35982
+ };
35983
+ }, [displayPicobotIcon, openPicobot]);
35984
+ const TOOLS = [Notifications, Tasks, displayChatIcon && Chat, displayPicobotIcon && Picobot, isPcoTestHost() && DeveloperTools].filter(Boolean);
35126
35985
  const handleToolOnClick = tool => {
35127
35986
  toggleDrawer(tool);
35128
35987
  scrollToolDrawerToTop(tool.name);
35129
35988
  };
35130
35989
  const toolSpecificProps = {
35990
+ Helpdesk: {
35991
+ isPicobotLoading: isPicobotGateLoading,
35992
+ openPicobot: displayPicobotIcon ? openPicobot : undefined
35993
+ },
35131
35994
  Profile: {
35132
35995
  appName,
35133
35996
  closeDrawer,
@@ -35150,6 +36013,7 @@ function Toolbar({
35150
36013
  const routeIsForCurrentTool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && toolsRouteMap[toolbarFragmentRoute.tool] === currentContent;
35151
36014
  const currentToolProps = {
35152
36015
  appName,
36016
+ clearPending,
35153
36017
  closeDrawer,
35154
36018
  currentPath: routeIsForCurrentTool ? toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path : null,
35155
36019
  env,
@@ -35158,6 +36022,8 @@ function Toolbar({
35158
36022
  joltPromise,
35159
36023
  onChangePath,
35160
36024
  openDrawer: () => currentContent && openDrawer(currentContent),
36025
+ openTool,
36026
+ pending: (pending === null || pending === void 0 ? void 0 : pending.tool) === (currentContent === null || currentContent === void 0 ? void 0 : currentContent.name) ? pending === null || pending === void 0 ? void 0 : pending.value : undefined,
35161
36027
  userId,
35162
36028
  ...(currentContent ? toolSpecificProps[currentContent.name] : {})
35163
36029
  };
@@ -35181,25 +36047,24 @@ function Toolbar({
35181
36047
  })), /*#__PURE__*/React__default.createElement(ToolbarButton, {
35182
36048
  className: styles$1["toolbar-button--home"],
35183
36049
  onClick: () => closeDrawer()
35184
- }, /*#__PURE__*/React__default.createElement(Icon, {
36050
+ }, /*#__PURE__*/React__default.createElement(Icon$1, {
35185
36051
  name: "toolbar.planningCenter",
35186
36052
  size: "24px"
35187
36053
  })), [HelpDeskTool, ...TOOLS].map(tool => {
36054
+ var _a;
35188
36055
  const current = open && currentContent === tool;
35189
36056
  const indicator = indicatorState[tool.name];
35190
36057
  return /*#__PURE__*/React__default.createElement(ToolbarButton, {
35191
36058
  current: current,
35192
36059
  onClick: () => handleToolOnClick(tool),
35193
36060
  id: getDataPendoAttribute(tool.name),
35194
- "aria-label": `Open ${tool.name} drawer`,
36061
+ "aria-label": `Open ${(_a = tool.label) !== null && _a !== void 0 ? _a : tool.name} drawer`,
35195
36062
  "data-pendo": getDataPendoAttribute(tool.name),
35196
36063
  key: tool.name
35197
- }, !indicator && (/*#__PURE__*/React__default.createElement(ToolbarButtonIcon, {
35198
- path: tool.icons.staticPath
35199
- })), indicator && (/*#__PURE__*/React__default.createElement(ToolbarButtonIcon, {
35200
- path: tool.icons.staticDotPath,
35201
- dot: true
35202
- })));
36064
+ }, /*#__PURE__*/React__default.createElement(ToolbarToolIcon, {
36065
+ icons: tool.icons,
36066
+ dot: !!indicator
36067
+ }));
35203
36068
  })), /*#__PURE__*/React__default.createElement(DrawerWrap, {
35204
36069
  isVisible: open
35205
36070
  }, mounted && (/*#__PURE__*/React__default.createElement(Drawer, {
@@ -35223,6 +36088,19 @@ const ToolbarButton = ({
35223
36088
  ...restProps
35224
36089
  }, children);
35225
36090
  };
36091
+ function ToolbarToolIcon({
36092
+ dot,
36093
+ icons
36094
+ }) {
36095
+ if ("component" in icons) {
36096
+ return /*#__PURE__*/React__default.createElement(icons.component, null);
36097
+ } else {
36098
+ return /*#__PURE__*/React__default.createElement(ToolbarButtonIcon, {
36099
+ path: dot ? icons.staticDotPath : icons.staticPath,
36100
+ dot: dot
36101
+ });
36102
+ }
36103
+ }
35226
36104
  function ToolbarButtonIcon({
35227
36105
  dot,
35228
36106
  path