@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.
@@ -166,12 +166,14 @@
166
166
  }
167
167
  function apiRequest(fetch, url, {
168
168
  method = "GET",
169
- data = null
169
+ data = null,
170
+ headers = {}
170
171
  } = {}) {
171
172
  const options = {
172
173
  headers: {
173
174
  Accept: "application/json",
174
- "Content-Type": "application/json"
175
+ "Content-Type": "application/json",
176
+ ...headers
175
177
  },
176
178
  method,
177
179
  redirect: "follow"
@@ -791,7 +793,7 @@
791
793
  return matches;
792
794
  }
793
795
 
794
- const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
796
+ const capitalize$1 = s => s.charAt(0).toUpperCase() + s.slice(1);
795
797
  const RESOLVED_COLOR_MODE_ATTRIBUTE = "data-resolved-color-mode";
796
798
  function resolveColorMode(colorMode) {
797
799
  if (colorMode === "system") {
@@ -3315,19 +3317,513 @@ true;
3315
3317
  }, children));
3316
3318
  }
3317
3319
 
3320
+ function parseValue(element) {
3321
+ const datatype = element.getAttribute("datatype");
3322
+ switch (datatype) {
3323
+ case "boolean":
3324
+ return element.content === "true" || element.content === "1";
3325
+ default:
3326
+ return element.content;
3327
+ }
3328
+ }
3329
+ function getAllPcoMeta() {
3330
+ const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3331
+ const meta = {};
3332
+ elements.forEach(e => {
3333
+ const property = e.getAttribute("property");
3334
+ meta[property.substring("pco:".length)] = parseValue(e);
3335
+ });
3336
+ return meta;
3337
+ }
3338
+ // usePcoMeta only reads each tag's content. Recently Visited also needs the
3339
+ // pco:recently_visitable tag's `metadata` attribute, which can change while
3340
+ // the content stays the same, so it is tracked in its own store.
3341
+ function getRecentlyVisitableMetadata() {
3342
+ var _a, _b;
3343
+ 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;
3344
+ }
3345
+ const store = createStore(getAllPcoMeta());
3346
+ const recentlyVisitableMetadataStore = createStore({
3347
+ metadata: getRecentlyVisitableMetadata()
3348
+ });
3349
+ function sync() {
3350
+ const nextState = getAllPcoMeta();
3351
+ if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3352
+ const metadata = getRecentlyVisitableMetadata();
3353
+ if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3354
+ recentlyVisitableMetadataStore.setState({
3355
+ metadata
3356
+ });
3357
+ }
3358
+ }
3359
+ const observer = new MutationObserver(sync);
3360
+ let hasTriggeredObserve = false;
3361
+ function observe() {
3362
+ if (hasTriggeredObserve) return;
3363
+ hasTriggeredObserve = true;
3364
+ // The module-load snapshot above can predate meta tags that appear later
3365
+ // in <head> (e.g. pco:resource, yielded right before </head>) if this
3366
+ // module's own script tag isn't deferred and runs mid-parse. Those tags
3367
+ // never mutate afterward, so the observer alone would never catch them.
3368
+ // Re-read once now, since observe() is only called once a consumer is
3369
+ // actually mounting, which is always after the document has fully parsed.
3370
+ sync();
3371
+ observer.observe(document.head, {
3372
+ attributes: true,
3373
+ childList: true,
3374
+ subtree: true
3375
+ });
3376
+ }
3377
+ function usePcoMeta() {
3378
+ observe();
3379
+ return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3380
+ }
3381
+ function useRecentlyVisitableMetadata() {
3382
+ observe();
3383
+ return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3384
+ }
3385
+
3386
+ const MAX_ENTRIES$1 = 20;
3387
+ const FORMAT_VERSION = 1;
3388
+ const MAX_TEXT_LENGTH = 200;
3389
+ const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3390
+ function isId(value) {
3391
+ return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3392
+ }
3393
+ // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3394
+ function isPcrl(value) {
3395
+ const segments = value.split("/");
3396
+ return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3397
+ }
3398
+ function isNonBlankString(value) {
3399
+ return typeof value === "string" && value.trim() !== "";
3400
+ }
3401
+ function isRecord(value) {
3402
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3403
+ }
3404
+ function entryKey(entry) {
3405
+ if (entry.type === "resource") {
3406
+ return `resource:${entry.pcrl}`;
3407
+ } else {
3408
+ return `page:${entry.product}:${entry.path}`;
3409
+ }
3410
+ }
3411
+ function isEntry(value) {
3412
+ if (!isRecord(value)) {
3413
+ return false;
3414
+ } else if (value.type === "resource") {
3415
+ return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3416
+ } else if (value.type === "page") {
3417
+ return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3418
+ } else {
3419
+ return false;
3420
+ }
3421
+ }
3422
+ // Cuts on code points so a surrogate pair is never split.
3423
+ function clampText(value) {
3424
+ return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3425
+ }
3426
+ function clampEntry(entry) {
3427
+ if (entry.type === "resource") {
3428
+ return entry;
3429
+ } else {
3430
+ return {
3431
+ ...entry,
3432
+ details: clampText(entry.details),
3433
+ title: clampText(entry.title)
3434
+ };
3435
+ }
3436
+ }
3437
+ // Recents are disposable: anything unexpected reads as an empty list (or, for
3438
+ // a single bad entry, a list without it) rather than throwing.
3439
+ function parseRecentlyVisited(value, personId) {
3440
+ let parsed = null;
3441
+ try {
3442
+ parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
3443
+ } catch {
3444
+ parsed = null;
3445
+ }
3446
+ if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
3447
+ return parsed.entries.filter(isEntry);
3448
+ } else {
3449
+ return [];
3450
+ }
3451
+ }
3452
+ function addVisit(entries, visit) {
3453
+ const key = entryKey(visit);
3454
+ return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES$1);
3455
+ }
3456
+ function serializeRecentlyVisited(entries, personId) {
3457
+ return JSON.stringify({
3458
+ entries,
3459
+ person: personId,
3460
+ v: FORMAT_VERSION
3461
+ });
3462
+ }
3463
+
3464
+ // The product whose URL, in this environment, has the current page's host.
3465
+ // Reversing pcoUrl handles every environment @planningcenter/url knows about,
3466
+ // and guarantees the stored product rebuilds a working link.
3467
+ function currentProduct() {
3468
+ var _a;
3469
+ return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
3470
+ }
3471
+ function detailsFromMetadata(metadata) {
3472
+ var _a;
3473
+ try {
3474
+ const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
3475
+ return typeof details === "string" ? details : "";
3476
+ } catch {
3477
+ return "";
3478
+ }
3479
+ }
3480
+ // pco:resource is the more specific signal, so it wins when both tags are
3481
+ // present. A resource that isn't a PCRL is ignored rather than recorded.
3482
+ function visitFromPage({
3483
+ metadata,
3484
+ resource,
3485
+ title
3486
+ }) {
3487
+ const product = currentProduct();
3488
+ if (resource && isPcrl(resource)) {
3489
+ return {
3490
+ pcrl: resource,
3491
+ type: "resource"
3492
+ };
3493
+ } else if (title && title.trim() !== "" && product) {
3494
+ return {
3495
+ details: detailsFromMetadata(metadata),
3496
+ path: window.location.pathname,
3497
+ product,
3498
+ title,
3499
+ type: "page"
3500
+ };
3501
+ } else {
3502
+ return null;
3503
+ }
3504
+ }
3505
+ // The entry Recently Visited would record for the current page, or null when
3506
+ // the page isn't eligible. Shared by the recorder and the debug panel so the
3507
+ // panel always shows what actually gets stored.
3508
+ function useCurrentVisit() {
3509
+ const {
3510
+ recently_visitable: title,
3511
+ resource
3512
+ } = usePcoMeta();
3513
+ const metadata = useRecentlyVisitableMetadata();
3514
+ return React.useMemo(() => visitFromPage({
3515
+ metadata,
3516
+ resource,
3517
+ title
3518
+ }), [metadata, resource, title]);
3519
+ }
3520
+ // Products hand the person's ID to AppsProvider, not to the topbar itself,
3521
+ // and several pass it as a number.
3522
+ function useCurrentPersonId() {
3523
+ const {
3524
+ userId
3525
+ } = useStore();
3526
+ return userId == null ? undefined : String(userId);
3527
+ }
3528
+
3529
+ // Per-origin localStorage is a stopgap: each product keeps its own list until
3530
+ // PCA-409 moves this to storage shared across product subdomains. Only this
3531
+ // file should need to change when it does.
3532
+ function storageKey({
3533
+ env,
3534
+ personId
3535
+ }) {
3536
+ return `topbar.recently_visited.${env}.${personId}`;
3537
+ }
3538
+ // localStorage doesn't notify the tab that wrote it, so readers in this tab
3539
+ // learn about new visits from this counter instead.
3540
+ const changes = createStore({
3541
+ version: 0
3542
+ });
3543
+ function readRecentlyVisited(scope) {
3544
+ try {
3545
+ return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
3546
+ } catch {
3547
+ return [];
3548
+ }
3549
+ }
3550
+ function recordVisit(visit, scope) {
3551
+ if (isEntry(visit)) {
3552
+ try {
3553
+ localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
3554
+ changes.setState({
3555
+ version: changes.getState().version + 1
3556
+ });
3557
+ } catch {
3558
+ // Storage can be full or blocked; recents are disposable.
3559
+ }
3560
+ }
3561
+ }
3562
+ function useRecentlyVisited({
3563
+ env,
3564
+ personId
3565
+ }) {
3566
+ const version = shim.useSyncExternalStore(changes.subscribe, () => changes.getState().version);
3567
+ // `version` isn't read, but listing it re-reads storage after each visit.
3568
+ return React.useMemo(() => personId ? readRecentlyVisited({
3569
+ env,
3570
+ personId
3571
+ }) : [], [env, personId, version]);
3572
+ }
3573
+
3574
+ // The versions home-mobile pins, so web and mobile read the same response
3575
+ // shapes. Some endpoints only exist from a version on: Registrations' me,
3576
+ // product_search_results, and pcrl_lookups need 2025-05-01.
3577
+ const API_VERSIONS = {
3578
+ calendar: "2022-07-07",
3579
+ "check-ins": "2025-05-28",
3580
+ giving: "2019-10-18",
3581
+ groups: "2023-07-10",
3582
+ home: "2018-08-01",
3583
+ people: "2023-03-21",
3584
+ publishing: "2024-03-25",
3585
+ registrations: "2025-05-01",
3586
+ services: "2018-11-01"
3587
+ };
3588
+ // For a `/~api/{product}/v2/...` URL, the header that pins its product's
3589
+ // version.
3590
+ function apiVersionHeaders(url) {
3591
+ const [,, product] = new URL(url).pathname.split("/");
3592
+ const version = API_VERSIONS[product];
3593
+ if (version) {
3594
+ return {
3595
+ "X-PCO-API-Version": version
3596
+ };
3597
+ } else {
3598
+ return {};
3599
+ }
3600
+ }
3601
+
3602
+ const CACHE_TTL_MS = 60000;
3603
+ const MAX_ENTRIES = 50;
3604
+ const cache = new Map();
3605
+ function cachedRequest(url) {
3606
+ const cached = cache.get(url);
3607
+ if (cached && cached.expiresAt > Date.now()) {
3608
+ // Re-inserting keeps the Map in recently-used order for eviction.
3609
+ cache.delete(url);
3610
+ cache.set(url, cached);
3611
+ return cached.promise;
3612
+ } else {
3613
+ return fetchIntoCache(url);
3614
+ }
3615
+ }
3616
+ function fetchIntoCache(url) {
3617
+ const promise = apiRequest(defaultFetch, url, {
3618
+ headers: apiVersionHeaders(url)
3619
+ }).then(({
3620
+ json,
3621
+ ok
3622
+ }) => {
3623
+ if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3624
+ return json;
3625
+ });
3626
+ // In-flight entries never expire, so every caller shares the one fetch.
3627
+ const entry = {
3628
+ expiresAt: Infinity,
3629
+ promise
3630
+ };
3631
+ cache.set(url, entry);
3632
+ promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3633
+ if (cache.size > MAX_ENTRIES) cache.delete(cache.keys().next().value);
3634
+ return promise;
3635
+ }
3636
+ function useCachedRequest(url) {
3637
+ const [settled, setSettled] = React.useState();
3638
+ React.useEffect(() => {
3639
+ if (url) {
3640
+ let current = true;
3641
+ cachedRequest(url).then(data => current && setSettled({
3642
+ data,
3643
+ url
3644
+ }), () => current && setSettled({
3645
+ failed: true,
3646
+ url
3647
+ }));
3648
+ return () => {
3649
+ current = false;
3650
+ };
3651
+ } else {
3652
+ return undefined;
3653
+ }
3654
+ }, [url]);
3655
+ if (!url) {
3656
+ return {
3657
+ status: "idle"
3658
+ };
3659
+ } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3660
+ return {
3661
+ data: settled && "data" in settled ? settled.data : undefined,
3662
+ status: "loading"
3663
+ };
3664
+ } else if ("data" in settled) {
3665
+ return {
3666
+ data: settled.data,
3667
+ status: "done"
3668
+ };
3669
+ } else {
3670
+ return {
3671
+ status: "error"
3672
+ };
3673
+ }
3674
+ }
3675
+ // One request per URL, for callers that fan out to a varying number of
3676
+ // endpoints. Unlike `useCachedRequest`, a URL that hasn't settled yet has no
3677
+ // earlier data to fall back on.
3678
+ function useCachedRequests(urls) {
3679
+ const [settled, setSettled] = React.useState({});
3680
+ // Keyed by value because each settle re-renders the caller, and a caller
3681
+ // that builds a fresh `urls` array on render would otherwise loop forever.
3682
+ const key = urls.join("\n");
3683
+ React.useEffect(() => {
3684
+ let current = true;
3685
+ const settle = result => current && setSettled(prev => ({
3686
+ ...prev,
3687
+ [result.url]: result
3688
+ }));
3689
+ for (const url of key.split("\n").filter(Boolean)) {
3690
+ cachedRequest(url).then(data => settle({
3691
+ data,
3692
+ url
3693
+ }), () => settle({
3694
+ failed: true,
3695
+ url
3696
+ }));
3697
+ }
3698
+ return () => {
3699
+ current = false;
3700
+ };
3701
+ }, [key]);
3702
+ return urls.map(url => {
3703
+ const result = settled[url];
3704
+ if (!result) {
3705
+ return {
3706
+ status: "loading"
3707
+ };
3708
+ } else if ("data" in result) {
3709
+ return {
3710
+ data: result.data,
3711
+ status: "done"
3712
+ };
3713
+ } else {
3714
+ return {
3715
+ status: "error"
3716
+ };
3717
+ }
3718
+ });
3719
+ }
3720
+
3721
+ const pcoUrl = pcoUrl$1;
3722
+
3723
+ // Resolves resource entries with one pcrl_lookups request per product.
3724
+ // pcrl_lookups reports forbidden and deleted resources the same way, so both
3725
+ // become `unresolved`, as does every entry in a batch whose request failed.
3726
+ function useHydratedRecentlyVisited(entries, env) {
3727
+ const batches = React.useMemo(() => batchByProduct(entries), [entries]);
3728
+ const urls = React.useMemo(() => batches.map(([product, pcrls]) => pcrlLookupsUrl(product, pcrls)), [batches]);
3729
+ const requests = useCachedRequests(urls);
3730
+ const lookups = new Map();
3731
+ batches.forEach(([, pcrls], index) => {
3732
+ const request = requests[index];
3733
+ for (const pcrl of pcrls) lookups.set(pcrl, lookupFor(pcrl, request));
3734
+ });
3735
+ return entries.map(entry => {
3736
+ var _a;
3737
+ if (entry.type === "page") {
3738
+ return {
3739
+ entry,
3740
+ status: "page",
3741
+ url: pageUrl(entry, env)
3742
+ };
3743
+ } else {
3744
+ const lookup = (_a = lookups.get(entry.pcrl)) !== null && _a !== void 0 ? _a : {
3745
+ status: "unresolved"
3746
+ };
3747
+ if (lookup.status === "resolved") {
3748
+ return {
3749
+ ...lookup,
3750
+ entry,
3751
+ url: lookup.result.attributes.url
3752
+ };
3753
+ } else {
3754
+ return {
3755
+ entry,
3756
+ status: lookup.status
3757
+ };
3758
+ }
3759
+ }
3760
+ });
3761
+ }
3762
+ function lookupFor(pcrl, request) {
3763
+ if (request.status === "done") {
3764
+ const result = request.data.data.find(({
3765
+ attributes
3766
+ }) => attributes.pcrl === pcrl);
3767
+ return result ? {
3768
+ result,
3769
+ status: "resolved"
3770
+ } : {
3771
+ status: "unresolved"
3772
+ };
3773
+ } else if (request.status === "error") {
3774
+ return {
3775
+ status: "unresolved"
3776
+ };
3777
+ } else {
3778
+ return {
3779
+ status: "loading"
3780
+ };
3781
+ }
3782
+ }
3783
+ const RECENTLY_VISITED_PAGE_SIZE = 10;
3784
+ // A PCRL can only be looked up through its own product's API, which may not be
3785
+ // the product it was visited in. Each page of entries is batched on its own,
3786
+ // so loading the next page leaves earlier pages' URLs, and so their cache
3787
+ // keys, alone. Sorting keeps them stable as a page reorders.
3788
+ function batchByProduct(entries) {
3789
+ var _a;
3790
+ const batches = [];
3791
+ for (let start = 0; start < entries.length; start += RECENTLY_VISITED_PAGE_SIZE) {
3792
+ const page = new Map();
3793
+ for (const entry of entries.slice(start, start + RECENTLY_VISITED_PAGE_SIZE)) {
3794
+ if (entry.type === "resource") {
3795
+ const [product] = entry.pcrl.split("/");
3796
+ page.set(product, [...((_a = page.get(product)) !== null && _a !== void 0 ? _a : []), entry.pcrl]);
3797
+ }
3798
+ }
3799
+ for (const [product, pcrls] of page) {
3800
+ batches.push([product, [...pcrls].sort()]);
3801
+ }
3802
+ }
3803
+ return batches;
3804
+ }
3805
+ function pcrlLookupsUrl(product, pcrls) {
3806
+ const params = new URLSearchParams({
3807
+ "where[pcrl]": pcrls.join(",")
3808
+ });
3809
+ return `${window.location.origin}/~api/${product}/v2/pcrl_lookups?${params}`;
3810
+ }
3811
+ // Built from the product rather than the current origin, so it stays right
3812
+ // once PCA-409 shares one list across product subdomains.
3813
+ function pageUrl(entry, env) {
3814
+ return `${pcoUrl(env)(entry.product)}${entry.path}`;
3815
+ }
3816
+
3318
3817
  var styles$a = {"result":"result-WCnxr"};
3319
3818
 
3320
3819
  function SearchResult({
3321
3820
  highlighted,
3322
3821
  id,
3323
3822
  onHighlight,
3324
- result
3823
+ subtitle,
3824
+ title,
3825
+ url
3325
3826
  }) {
3326
- const {
3327
- metadata,
3328
- title,
3329
- url
3330
- } = result.attributes;
3331
3827
  const ref = React.useRef(null);
3332
3828
  React.useEffect(() => {
3333
3829
  var _a;
@@ -3348,10 +3844,7 @@ true;
3348
3844
  ref: ref,
3349
3845
  role: "option",
3350
3846
  tabIndex: -1
3351
- }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, summarize(metadata)));
3352
- }
3353
- function summarize(metadata) {
3354
- return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3847
+ }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, subtitle));
3355
3848
  }
3356
3849
 
3357
3850
  const resultId = (listboxId, index) => `${listboxId}-result-${index}`;
@@ -3423,83 +3916,127 @@ true;
3423
3916
  id: resultId(id, resultIndex),
3424
3917
  key: result.id,
3425
3918
  onHighlight: () => onHighlight(resultIndex),
3426
- result: result
3919
+ subtitle: summarize(result.attributes.metadata),
3920
+ title: result.attributes.title,
3921
+ url: result.attributes.url
3427
3922
  });
3428
3923
  }));
3429
3924
  }));
3430
3925
  }
3926
+ function summarize(metadata) {
3927
+ return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3928
+ }
3431
3929
 
3432
- const CACHE_TTL_MS = 60000;
3433
- const MAX_ENTRIES$1 = 50;
3434
- const cache = new Map();
3435
- function cachedRequest(url) {
3436
- const cached = cache.get(url);
3437
- if (cached && cached.expiresAt > Date.now()) {
3438
- // Re-inserting keeps the Map in recently-used order for eviction.
3439
- cache.delete(url);
3440
- cache.set(url, cached);
3441
- return cached.promise;
3930
+ function useRecentVisits(env) {
3931
+ const personId = useCurrentPersonId();
3932
+ const entries = useRecentlyVisited({
3933
+ env,
3934
+ personId
3935
+ });
3936
+ const [count, setCount] = React.useState(RECENTLY_VISITED_PAGE_SIZE);
3937
+ const shown = React.useMemo(() => entries.slice(0, count), [entries, count]);
3938
+ const hydrated = useHydratedRecentlyVisited(shown, env);
3939
+ // Resources show up once they're found. Ones that can't be found anymore,
3940
+ // or that the person can no longer see, are left out.
3941
+ const visits = hydrated.filter(isShown);
3942
+ const loading = hydrated.some(({
3943
+ status
3944
+ }) => status === "loading");
3945
+ const showMore = React.useCallback(() => setCount(current => current + RECENTLY_VISITED_PAGE_SIZE), []);
3946
+ return {
3947
+ count,
3948
+ hasMore: entries.length > count,
3949
+ loading,
3950
+ showMore,
3951
+ visits
3952
+ };
3953
+ }
3954
+ function isShown(visit) {
3955
+ return visit.status === "page" || visit.status === "resolved";
3956
+ }
3957
+ function RecentlyVisited({
3958
+ count,
3959
+ hasMore,
3960
+ highlightedIndex,
3961
+ id,
3962
+ loading,
3963
+ onHighlight,
3964
+ showMore,
3965
+ visits
3966
+ }) {
3967
+ const headingId = `${id}-heading`;
3968
+ if (visits.length === 0 && !hasMore && !loading) {
3969
+ return /*#__PURE__*/React.createElement("p", null, "Pages you visit will show up here.");
3442
3970
  } else {
3443
- return fetchIntoCache(url);
3971
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
3972
+ id: headingId
3973
+ }, "Recently visited"), /*#__PURE__*/React.createElement("div", {
3974
+ "aria-labelledby": headingId,
3975
+ id: id,
3976
+ role: "listbox"
3977
+ }, visits.map((visit, index) => (/*#__PURE__*/React.createElement(SearchResult, {
3978
+ highlighted: index === highlightedIndex,
3979
+ id: resultId(id, index),
3980
+ key: entryKey(visit.entry),
3981
+ onHighlight: () => onHighlight(index),
3982
+ ...describeVisit(visit)
3983
+ })))), hasMore && /*#__PURE__*/React.createElement(ShowMoreWhenVisible, {
3984
+ key: count,
3985
+ onVisible: showMore
3986
+ }));
3444
3987
  }
3445
3988
  }
3446
- function fetchIntoCache(url) {
3447
- const promise = apiRequest(defaultFetch, url).then(({
3448
- json,
3449
- ok
3450
- }) => {
3451
- if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3452
- return json;
3989
+ function ShowMoreWhenVisible({
3990
+ onVisible
3991
+ }) {
3992
+ const ref = React.useRef(null);
3993
+ React.useEffect(() => {
3994
+ const observer = new IntersectionObserver(([entry]) => {
3995
+ if (entry.isIntersecting) onVisible();
3996
+ });
3997
+ if (ref.current) observer.observe(ref.current);
3998
+ return () => observer.disconnect();
3999
+ }, [onVisible]);
4000
+ return /*#__PURE__*/React.createElement("div", {
4001
+ ref: ref
3453
4002
  });
3454
- // In-flight entries never expire, so every caller shares the one fetch.
3455
- const entry = {
3456
- expiresAt: Infinity,
3457
- promise
3458
- };
3459
- cache.set(url, entry);
3460
- promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3461
- if (cache.size > MAX_ENTRIES$1) cache.delete(cache.keys().next().value);
3462
- return promise;
3463
4003
  }
3464
- function useCachedRequest(url) {
3465
- const [settled, setSettled] = React.useState();
3466
- React.useEffect(() => {
3467
- if (url) {
3468
- let current = true;
3469
- cachedRequest(url).then(data => current && setSettled({
3470
- data,
3471
- url
3472
- }), () => current && setSettled({
3473
- failed: true,
3474
- url
3475
- }));
3476
- return () => {
3477
- current = false;
3478
- };
3479
- } else {
3480
- return undefined;
3481
- }
3482
- }, [url]);
3483
- if (!url) {
3484
- return {
3485
- status: "idle"
3486
- };
3487
- } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3488
- return {
3489
- data: settled && "data" in settled ? settled.data : undefined,
3490
- status: "loading"
3491
- };
3492
- } else if ("data" in settled) {
4004
+ // Unlike search results, recents come from every product, so each one names
4005
+ // its product and type.
4006
+ function describeVisit(visit) {
4007
+ const subtitle = (...parts) => parts.filter(Boolean).join(" · ");
4008
+ if (visit.status === "page") {
4009
+ const {
4010
+ details,
4011
+ product,
4012
+ title
4013
+ } = visit.entry;
3493
4014
  return {
3494
- data: settled.data,
3495
- status: "done"
4015
+ subtitle: subtitle(productName(product), details),
4016
+ title,
4017
+ url: visit.url
3496
4018
  };
3497
4019
  } else {
4020
+ const {
4021
+ metadata,
4022
+ product,
4023
+ title,
4024
+ type
4025
+ } = visit.result.attributes;
3498
4026
  return {
3499
- status: "error"
4027
+ subtitle: subtitle(productName(product), type, summarize(metadata)),
4028
+ title,
4029
+ url: visit.url
3500
4030
  };
3501
4031
  }
3502
4032
  }
4033
+ // check-ins → Check-Ins
4034
+ function productName(slug) {
4035
+ return slug.split("-").map(capitalize).join("-");
4036
+ }
4037
+ function capitalize(word) {
4038
+ return word.charAt(0).toUpperCase() + word.slice(1);
4039
+ }
3503
4040
 
3504
4041
  const SEARCH_DEBOUNCE_MS = 300;
3505
4042
  function useProductSearch(product, term) {
@@ -3590,15 +4127,16 @@ true;
3590
4127
  ArrowDown: 1,
3591
4128
  ArrowUp: -1
3592
4129
  })[key];
3593
- function useResultNavigation(results) {
3594
- // Remembering which results the index belongs to puts the highlight back on
3595
- // the first result whenever new ones arrive, without an effect to reset it.
4130
+ // Remembering which list the index belongs to puts the highlight back on the
4131
+ // first option whenever a new list replaces it, without an effect to reset it.
4132
+ // A list that grows or fills in under the same `list` keeps its highlight.
4133
+ function useResultNavigation(options, list) {
3596
4134
  const [highlight, setHighlight] = React.useState({
3597
4135
  index: 0,
3598
- results
4136
+ list
3599
4137
  });
3600
- const highlightedIndex = highlight.results === results ? highlight.index : 0;
3601
- const highlighted = results[highlightedIndex];
4138
+ const highlightedIndex = highlight.list === list ? highlight.index : 0;
4139
+ const highlighted = options[highlightedIndex];
3602
4140
  const [copiedUrl, setCopiedUrl] = React.useState(null);
3603
4141
  React.useEffect(() => {
3604
4142
  if (copiedUrl) {
@@ -3608,30 +4146,30 @@ true;
3608
4146
  return undefined;
3609
4147
  }
3610
4148
  }, [copiedUrl]);
3611
- const highlightIndex = React.useCallback(index => setHighlight(current => current.results === results && current.index === index ? current : {
4149
+ const highlightIndex = React.useCallback(index => setHighlight(current => current.list === list && current.index === index ? current : {
3612
4150
  index,
3613
- results
3614
- }), [results]);
4151
+ list
4152
+ }), [list]);
3615
4153
  const handleKeyDown = event => {
3616
4154
  const step = verticalStep(event.key);
3617
- if (step && results.length > 0) {
4155
+ if (step && options.length > 0) {
3618
4156
  event.preventDefault();
3619
- highlightIndex((highlightedIndex + step + results.length) % results.length);
4157
+ highlightIndex((highlightedIndex + step + options.length) % options.length);
3620
4158
  } else if (event.key === "Enter" && highlighted && !event.nativeEvent.isComposing) {
3621
4159
  event.preventDefault();
3622
- open(highlighted.attributes.url, {
4160
+ open(highlighted.url, {
3623
4161
  newTab: hasCommandKey(event)
3624
4162
  });
3625
4163
  } else if (isCopyShortcut(event) && highlighted && !hasSelectedText(event.currentTarget)) {
3626
4164
  event.preventDefault();
3627
4165
  const {
3628
4166
  url
3629
- } = highlighted.attributes;
4167
+ } = highlighted;
3630
4168
  navigator.clipboard.writeText(url).then(() => setCopiedUrl(url));
3631
4169
  }
3632
4170
  };
3633
4171
  return {
3634
- copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.attributes.url),
4172
+ copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.url),
3635
4173
  handleKeyDown,
3636
4174
  highlightIndex,
3637
4175
  highlighted,
@@ -3656,29 +4194,66 @@ true;
3656
4194
  }
3657
4195
 
3658
4196
  const SEARCHABLE_PRODUCTS = [{
4197
+ appName: "Calendar",
4198
+ slug: "calendar"
4199
+ }, {
4200
+ appName: "Check-Ins",
4201
+ slug: "check-ins"
4202
+ }, {
4203
+ appName: "Giving",
4204
+ slug: "giving"
4205
+ }, {
3659
4206
  appName: "Groups",
3660
4207
  slug: "groups"
3661
4208
  }, {
3662
4209
  appName: "People",
3663
4210
  slug: "people"
4211
+ }, {
4212
+ appName: "Publishing",
4213
+ slug: "publishing"
4214
+ }, {
4215
+ appName: "Registrations",
4216
+ slug: "registrations"
3664
4217
  }, {
3665
4218
  appName: "Services",
3666
4219
  slug: "services"
3667
4220
  }];
3668
4221
  const PERMISSION_CHECKS = {
4222
+ "check-ins": () => fetchMe("check-ins", "permission").then(({
4223
+ permission
4224
+ }) => permission !== null),
4225
+ giving: personId => {
4226
+ if (personId === undefined) {
4227
+ return Promise.reject(new Error("No person to check Giving access for"));
4228
+ } else {
4229
+ return fetchAttributes(`giving/v2/person_permissions/${personId}`, {
4230
+ "fields[PersonPermissions]": "can_search"
4231
+ }).then(({
4232
+ can_search
4233
+ }) => can_search);
4234
+ }
4235
+ },
3669
4236
  people: () => fetchMe("people", "people_permissions,site_administrator").then(({
3670
4237
  people_permissions,
3671
4238
  site_administrator
3672
4239
  }) => site_administrator || people_permissions !== null),
4240
+ registrations: () => fetchMe("registrations", "can_search").then(({
4241
+ can_search
4242
+ }) => can_search),
3673
4243
  services: () => fetchMe("services", "can_search").then(({
3674
4244
  can_search
3675
4245
  }) => can_search)
3676
4246
  };
3677
4247
  function fetchMe(product, fields) {
3678
- const params = new URLSearchParams({
4248
+ return fetchAttributes(`${product}/v2/me`, {
3679
4249
  "fields[Person]": fields
3680
4250
  });
3681
- return apiRequest(defaultFetch, `${window.location.origin}/~api/${product}/v2/me?${params}`).then(({
4251
+ }
4252
+ function fetchAttributes(path, params) {
4253
+ const url = `${window.location.origin}/~api/${path}?${new URLSearchParams(params)}`;
4254
+ return apiRequest(defaultFetch, url, {
4255
+ headers: apiVersionHeaders(url)
4256
+ }).then(({
3682
4257
  json,
3683
4258
  ok
3684
4259
  }) => {
@@ -3689,7 +4264,7 @@ true;
3689
4264
  // Permissions rarely change mid-session, so a successful check is kept until
3690
4265
  // the page reloads. A failed one is dropped so a remount tries again.
3691
4266
  const permissionCache = new Map();
3692
- function canSearch(slug) {
4267
+ function canSearch(slug, personId) {
3693
4268
  const check = PERMISSION_CHECKS[slug];
3694
4269
  const cached = permissionCache.get(slug);
3695
4270
  if (!check) {
@@ -3697,7 +4272,7 @@ true;
3697
4272
  } else if (cached) {
3698
4273
  return cached;
3699
4274
  } else {
3700
- const promise = check().catch(() => {
4275
+ const promise = check(personId).catch(() => {
3701
4276
  permissionCache.delete(slug);
3702
4277
  return false;
3703
4278
  });
@@ -3711,16 +4286,18 @@ true;
3711
4286
  const accessibleKey = inSwitcherOrder(apps).map(({
3712
4287
  slug
3713
4288
  }) => slug).join(",");
4289
+ const personId = useCurrentPersonId();
3714
4290
  const [settled, setSettled] = React.useState();
3715
4291
  React.useEffect(() => {
3716
4292
  let current = true;
3717
4293
  const accessible = accessibleKey.split(",").flatMap(slug => SEARCHABLE_PRODUCTS.filter(product => product.slug === slug));
3718
4294
  Promise.all(accessible.map(({
3719
4295
  slug
3720
- }) => canSearch(slug))).then(allowed => {
4296
+ }) => canSearch(slug, personId))).then(allowed => {
3721
4297
  if (current) {
3722
4298
  setSettled({
3723
4299
  accessibleKey,
4300
+ personId,
3724
4301
  products: accessible.filter((_, index) => allowed[index])
3725
4302
  });
3726
4303
  }
@@ -3728,8 +4305,8 @@ true;
3728
4305
  return () => {
3729
4306
  current = false;
3730
4307
  };
3731
- }, [accessibleKey]);
3732
- if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey) {
4308
+ }, [accessibleKey, personId]);
4309
+ if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey && settled.personId === personId) {
3733
4310
  return {
3734
4311
  products: settled.products,
3735
4312
  status: "done"
@@ -3750,11 +4327,13 @@ true;
3750
4327
  const isTypingKey = event => event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey;
3751
4328
  const isTypingIn = target => target instanceof Element && target.closest('input, select, textarea, [contenteditable]:not([contenteditable="false"])') !== null;
3752
4329
  function ProductSearch({
3753
- apps
4330
+ apps,
4331
+ env
3754
4332
  }) {
3755
4333
  const searchable = useSearchableProducts(apps);
3756
4334
  if (searchable.status === "done" && searchable.products.length > 0) {
3757
4335
  return /*#__PURE__*/React.createElement(SearchButtonAndDialog, {
4336
+ env: env,
3758
4337
  products: searchable.products
3759
4338
  });
3760
4339
  } else {
@@ -3762,6 +4341,7 @@ true;
3762
4341
  }
3763
4342
  }
3764
4343
  function SearchButtonAndDialog({
4344
+ env,
3765
4345
  products
3766
4346
  }) {
3767
4347
  const [open, setOpen] = React.useState(false);
@@ -3799,10 +4379,15 @@ true;
3799
4379
  onClose: () => setOpen(false),
3800
4380
  open: open
3801
4381
  }, /*#__PURE__*/React.createElement(SearchPanel, {
4382
+ env: env,
3802
4383
  products: products
3803
4384
  })));
3804
4385
  }
4386
+ // Keys the recents list for navigation, so the highlight stays put as more
4387
+ // recents load and as their lookups answer.
4388
+ const RECENTS = "recents";
3805
4389
  function SearchPanel({
4390
+ env,
3806
4391
  products
3807
4392
  }) {
3808
4393
  var _a, _b;
@@ -3819,7 +4404,23 @@ true;
3819
4404
  var _a;
3820
4405
  return (_a = groups === null || groups === void 0 ? void 0 : groups.flatMap(group => group.results)) !== null && _a !== void 0 ? _a : [];
3821
4406
  }, [groups]);
3822
- const navigation = useResultNavigation(results);
4407
+ const recents = useRecentVisits(env);
4408
+ const options = hasTerm ? results.map(({
4409
+ attributes
4410
+ }) => ({
4411
+ label: attributes.title,
4412
+ url: attributes.url
4413
+ })) : recents.visits.map(visit => {
4414
+ const {
4415
+ title,
4416
+ url
4417
+ } = describeVisit(visit);
4418
+ return {
4419
+ label: title,
4420
+ url
4421
+ };
4422
+ });
4423
+ const navigation = useResultNavigation(options, hasTerm ? results : RECENTS);
3823
4424
  const {
3824
4425
  highlightedIndex
3825
4426
  } = navigation;
@@ -3845,8 +4446,8 @@ true;
3845
4446
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("input", {
3846
4447
  "aria-activedescendant": highlightedIndex === null ? undefined : resultId(listboxId, highlightedIndex),
3847
4448
  "aria-autocomplete": "list",
3848
- "aria-controls": results.length > 0 ? listboxId : undefined,
3849
- "aria-expanded": results.length > 0,
4449
+ "aria-controls": options.length > 0 ? listboxId : undefined,
4450
+ "aria-expanded": options.length > 0,
3850
4451
  "aria-label": "Search",
3851
4452
  autoFocus: true,
3852
4453
  onChange: event => setTerm(event.target.value),
@@ -3862,17 +4463,22 @@ true;
3862
4463
  onSelect: setSelectedSlug,
3863
4464
  products: products,
3864
4465
  selected: product
3865
- }, /*#__PURE__*/React.createElement(SearchResults, {
4466
+ }, hasTerm ? (/*#__PURE__*/React.createElement(SearchResults, {
3866
4467
  highlightedIndex: highlightedIndex,
3867
4468
  id: listboxId,
3868
4469
  label: `${product.appName} results`,
3869
4470
  onHighlight: navigation.highlightIndex,
3870
4471
  search: search,
3871
4472
  term: term
3872
- })), /*#__PURE__*/React.createElement("div", {
4473
+ })) : (/*#__PURE__*/React.createElement(RecentlyVisited, {
4474
+ highlightedIndex: highlightedIndex,
4475
+ id: listboxId,
4476
+ onHighlight: navigation.highlightIndex,
4477
+ ...recents
4478
+ }))), /*#__PURE__*/React.createElement("div", {
3873
4479
  "aria-live": "polite",
3874
4480
  className: styles$c.visuallyHidden
3875
- }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.attributes.title : null), /*#__PURE__*/React.createElement(KeyboardHints, {
4481
+ }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.label : null), /*#__PURE__*/React.createElement(KeyboardHints, {
3876
4482
  copied: navigation.copied
3877
4483
  }));
3878
4484
  }
@@ -3891,249 +4497,6 @@ true;
3891
4497
  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];
3892
4498
  }
3893
4499
 
3894
- function parseValue(element) {
3895
- const datatype = element.getAttribute("datatype");
3896
- switch (datatype) {
3897
- case "boolean":
3898
- return element.content === "true" || element.content === "1";
3899
- default:
3900
- return element.content;
3901
- }
3902
- }
3903
- function getAllPcoMeta() {
3904
- const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3905
- const meta = {};
3906
- elements.forEach(e => {
3907
- const property = e.getAttribute("property");
3908
- meta[property.substring("pco:".length)] = parseValue(e);
3909
- });
3910
- return meta;
3911
- }
3912
- // usePcoMeta only reads each tag's content. Recently Visited also needs the
3913
- // pco:recently_visitable tag's `metadata` attribute, which can change while
3914
- // the content stays the same, so it is tracked in its own store.
3915
- function getRecentlyVisitableMetadata() {
3916
- var _a, _b;
3917
- 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;
3918
- }
3919
- const store = createStore(getAllPcoMeta());
3920
- const recentlyVisitableMetadataStore = createStore({
3921
- metadata: getRecentlyVisitableMetadata()
3922
- });
3923
- function sync() {
3924
- const nextState = getAllPcoMeta();
3925
- if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3926
- const metadata = getRecentlyVisitableMetadata();
3927
- if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3928
- recentlyVisitableMetadataStore.setState({
3929
- metadata
3930
- });
3931
- }
3932
- }
3933
- const observer = new MutationObserver(sync);
3934
- let hasTriggeredObserve = false;
3935
- function observe() {
3936
- if (hasTriggeredObserve) return;
3937
- hasTriggeredObserve = true;
3938
- // The module-load snapshot above can predate meta tags that appear later
3939
- // in <head> (e.g. pco:resource, yielded right before </head>) if this
3940
- // module's own script tag isn't deferred and runs mid-parse. Those tags
3941
- // never mutate afterward, so the observer alone would never catch them.
3942
- // Re-read once now, since observe() is only called once a consumer is
3943
- // actually mounting, which is always after the document has fully parsed.
3944
- sync();
3945
- observer.observe(document.head, {
3946
- attributes: true,
3947
- childList: true,
3948
- subtree: true
3949
- });
3950
- }
3951
- function usePcoMeta() {
3952
- observe();
3953
- return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3954
- }
3955
- function useRecentlyVisitableMetadata() {
3956
- observe();
3957
- return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3958
- }
3959
-
3960
- const MAX_ENTRIES = 20;
3961
- const FORMAT_VERSION = 1;
3962
- const MAX_TEXT_LENGTH = 200;
3963
- const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3964
- function isId(value) {
3965
- return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3966
- }
3967
- // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3968
- function isPcrl(value) {
3969
- const segments = value.split("/");
3970
- return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3971
- }
3972
- function isNonBlankString(value) {
3973
- return typeof value === "string" && value.trim() !== "";
3974
- }
3975
- function isRecord(value) {
3976
- return typeof value === "object" && value !== null && !Array.isArray(value);
3977
- }
3978
- function entryKey(entry) {
3979
- if (entry.type === "resource") {
3980
- return `resource:${entry.pcrl}`;
3981
- } else {
3982
- return `page:${entry.product}:${entry.path}`;
3983
- }
3984
- }
3985
- function isEntry(value) {
3986
- if (!isRecord(value)) {
3987
- return false;
3988
- } else if (value.type === "resource") {
3989
- return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3990
- } else if (value.type === "page") {
3991
- return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3992
- } else {
3993
- return false;
3994
- }
3995
- }
3996
- // Cuts on code points so a surrogate pair is never split.
3997
- function clampText(value) {
3998
- return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3999
- }
4000
- function clampEntry(entry) {
4001
- if (entry.type === "resource") {
4002
- return entry;
4003
- } else {
4004
- return {
4005
- ...entry,
4006
- details: clampText(entry.details),
4007
- title: clampText(entry.title)
4008
- };
4009
- }
4010
- }
4011
- // Recents are disposable: anything unexpected reads as an empty list (or, for
4012
- // a single bad entry, a list without it) rather than throwing.
4013
- function parseRecentlyVisited(value, personId) {
4014
- let parsed = null;
4015
- try {
4016
- parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
4017
- } catch {
4018
- parsed = null;
4019
- }
4020
- if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
4021
- return parsed.entries.filter(isEntry);
4022
- } else {
4023
- return [];
4024
- }
4025
- }
4026
- function addVisit(entries, visit) {
4027
- const key = entryKey(visit);
4028
- return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES);
4029
- }
4030
- function serializeRecentlyVisited(entries, personId) {
4031
- return JSON.stringify({
4032
- entries,
4033
- person: personId,
4034
- v: FORMAT_VERSION
4035
- });
4036
- }
4037
-
4038
- // The product whose URL, in this environment, has the current page's host.
4039
- // Reversing pcoUrl handles every environment @planningcenter/url knows about,
4040
- // and guarantees the stored product rebuilds a working link.
4041
- function currentProduct() {
4042
- var _a;
4043
- return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
4044
- }
4045
- function detailsFromMetadata(metadata) {
4046
- var _a;
4047
- try {
4048
- const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
4049
- return typeof details === "string" ? details : "";
4050
- } catch {
4051
- return "";
4052
- }
4053
- }
4054
- // pco:resource is the more specific signal, so it wins when both tags are
4055
- // present. A resource that isn't a PCRL is ignored rather than recorded.
4056
- function visitFromPage({
4057
- metadata,
4058
- resource,
4059
- title
4060
- }) {
4061
- const product = currentProduct();
4062
- if (resource && isPcrl(resource)) {
4063
- return {
4064
- pcrl: resource,
4065
- type: "resource"
4066
- };
4067
- } else if (title && title.trim() !== "" && product) {
4068
- return {
4069
- details: detailsFromMetadata(metadata),
4070
- path: window.location.pathname,
4071
- product,
4072
- title,
4073
- type: "page"
4074
- };
4075
- } else {
4076
- return null;
4077
- }
4078
- }
4079
- // The entry Recently Visited would record for the current page, or null when
4080
- // the page isn't eligible. Shared by the recorder and the debug panel so the
4081
- // panel always shows what actually gets stored.
4082
- function useCurrentVisit() {
4083
- const {
4084
- recently_visitable: title,
4085
- resource
4086
- } = usePcoMeta();
4087
- const metadata = useRecentlyVisitableMetadata();
4088
- return React.useMemo(() => visitFromPage({
4089
- metadata,
4090
- resource,
4091
- title
4092
- }), [metadata, resource, title]);
4093
- }
4094
- // Products hand the person's ID to AppsProvider, not to the topbar itself,
4095
- // and several pass it as a number.
4096
- function useCurrentPersonId() {
4097
- const {
4098
- userId
4099
- } = useStore();
4100
- return userId == null ? undefined : String(userId);
4101
- }
4102
-
4103
- // Per-origin localStorage is a stopgap: each product keeps its own list until
4104
- // PCA-409 moves this to storage shared across product subdomains. Only this
4105
- // file should need to change when it does.
4106
- function storageKey({
4107
- env,
4108
- personId
4109
- }) {
4110
- return `topbar.recently_visited.${env}.${personId}`;
4111
- }
4112
- // localStorage doesn't notify the tab that wrote it, so readers in this tab
4113
- // learn about new visits from this counter instead.
4114
- const changes = createStore({
4115
- version: 0
4116
- });
4117
- function readRecentlyVisited(scope) {
4118
- try {
4119
- return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
4120
- } catch {
4121
- return [];
4122
- }
4123
- }
4124
- function recordVisit(visit, scope) {
4125
- if (isEntry(visit)) {
4126
- try {
4127
- localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
4128
- changes.setState({
4129
- version: changes.getState().version + 1
4130
- });
4131
- } catch {
4132
- // Storage can be full or blocked; recents are disposable.
4133
- }
4134
- }
4135
- }
4136
-
4137
4500
  function useRecordRecentlyVisited({
4138
4501
  enabled,
4139
4502
  env
@@ -4423,7 +4786,8 @@ true;
4423
4786
  margin: "auto"
4424
4787
  }
4425
4788
  }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, {
4426
- apps: props.apps
4789
+ apps: props.apps,
4790
+ env: props.env
4427
4791
  })) : search({
4428
4792
  hideRoutes: () => setRoutesVisible(false),
4429
4793
  showRoutes: () => setRoutesVisible(true)
@@ -32556,7 +32920,7 @@ true;
32556
32920
  setProductSwitcherVisible(false);
32557
32921
  setRoutesMenuVisible(visible => !visible);
32558
32922
  } : null
32559
- }, /*#__PURE__*/React.createElement(CurrentRouteComponent, null, capitalize(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React.createElement("button", {
32923
+ }, /*#__PURE__*/React.createElement(CurrentRouteComponent, null, capitalize$1(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React.createElement("button", {
32560
32924
  type: "button",
32561
32925
  className: styles$5["profile-button"],
32562
32926
  onClick: () => {
@@ -32749,8 +33113,24 @@ true;
32749
33113
  className: styles$4["debug-pco-resource-field-value"]
32750
33114
  }, value));
32751
33115
  }
33116
+ // Only staff ghosting or impersonating a person get pco:staff-access-level,
33117
+ // which keeps the panel away from regular users on staging.
32752
33118
  function DebugPcoResource({
32753
33119
  env
33120
+ }) {
33121
+ const {
33122
+ "staff-access-level": staffAccessLevel
33123
+ } = usePcoMeta();
33124
+ if (isPresent(staffAccessLevel)) {
33125
+ return /*#__PURE__*/React.createElement(DebugPanel, {
33126
+ env: env
33127
+ });
33128
+ } else {
33129
+ return null;
33130
+ }
33131
+ }
33132
+ function DebugPanel({
33133
+ env
32754
33134
  }) {
32755
33135
  const {
32756
33136
  recently_visitable: recentlyVisitable,
@@ -32759,12 +33139,17 @@ true;
32759
33139
  const [lookup, setLookup] = React.useState({
32760
33140
  status: "idle"
32761
33141
  });
32762
- const hasResource = isPresent(resource);
33142
+ const visit = useCurrentVisit();
33143
+ const personId = useCurrentPersonId();
33144
+ const recentlyVisited = useRecentlyVisited({
33145
+ env,
33146
+ personId
33147
+ });
33148
+ const pcrl = (visit === null || visit === void 0 ? void 0 : visit.type) === "resource" ? visit.pcrl : null;
32763
33149
  const hasRecentlyVisitable = isPresent(recentlyVisitable);
33150
+ const hasInvalidResource = isPresent(resource) && !isPcrl(resource);
32764
33151
  React.useEffect(() => {
32765
- // pco:resource is the more specific signal, so it always wins when both
32766
- // tags are present.
32767
- const url = hasResource ? pcrlLookupUrl(resource, env) : null;
33152
+ const url = pcrl ? pcrlLookupUrl(pcrl, env) : null;
32768
33153
  if (!url) {
32769
33154
  setLookup({
32770
33155
  status: "idle"
@@ -32796,7 +33181,7 @@ true;
32796
33181
  return () => {
32797
33182
  cancelled = true;
32798
33183
  };
32799
- }, [resource, env, hasResource]);
33184
+ }, [pcrl, env]);
32800
33185
  return /*#__PURE__*/React.createElement("div", {
32801
33186
  className: classnames("staff-only", styles$4["debug-pco-resource"])
32802
33187
  }, /*#__PURE__*/React.createElement("div", {
@@ -32811,19 +33196,27 @@ true;
32811
33196
  }), /*#__PURE__*/React.createElement(Field, {
32812
33197
  label: "pco:recently_visitable",
32813
33198
  value: describeTag("recently_visitable", recentlyVisitable)
32814
- }), !hasResource && !hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33199
+ }), hasInvalidResource && (/*#__PURE__*/React.createElement("p", {
32815
33200
  className: styles$4["debug-pco-resource-note"]
32816
- }, "This page is not eligible for Recently Visited")), hasResource && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33201
+ }, "pco:resource isn't a PCRL (product/v2/type/id), so Recently Visited ignores it.")), visit === null && (/*#__PURE__*/React.createElement("p", {
32817
33202
  className: styles$4["debug-pco-resource-note"]
32818
- }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), hasResource && lookup.status === "loading" && (/*#__PURE__*/React.createElement("p", {
33203
+ }, "This page is not eligible for Recently Visited")), pcrl && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
32819
33204
  className: styles$4["debug-pco-resource-note"]
32820
- }, "Looking up resource\u2026")), hasResource && lookup.status === "error" && (/*#__PURE__*/React.createElement("p", {
33205
+ }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), pcrl && lookup.status === "loading" && (/*#__PURE__*/React.createElement("p", {
33206
+ className: styles$4["debug-pco-resource-note"]
33207
+ }, "Looking up resource\u2026")), pcrl && lookup.status === "error" && (/*#__PURE__*/React.createElement("p", {
33208
+ className: styles$4["debug-pco-resource-note"]
33209
+ }, lookup.message)), pcrl && lookup.status === "loaded" && (/*#__PURE__*/React.createElement("pre", {
33210
+ className: styles$4["debug-pco-resource-json"]
33211
+ }, JSON.stringify(lookup.json, null, 2))), (visit === null || visit === void 0 ? void 0 : visit.type) === "page" && (/*#__PURE__*/React.createElement("p", {
32821
33212
  className: styles$4["debug-pco-resource-note"]
32822
- }, lookup.message)), hasResource && lookup.status === "loaded" && (/*#__PURE__*/React.createElement("pre", {
33213
+ }, "Recently Visited title: ", visit.title)), /*#__PURE__*/React.createElement("span", {
33214
+ className: styles$4["debug-pco-resource-field-label"]
33215
+ }, "Recently Visited"), personId ? (/*#__PURE__*/React.createElement("pre", {
32823
33216
  className: styles$4["debug-pco-resource-json"]
32824
- }, JSON.stringify(lookup.json, null, 2))), !hasResource && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33217
+ }, JSON.stringify(recentlyVisited, null, 2))) : (/*#__PURE__*/React.createElement("p", {
32825
33218
  className: styles$4["debug-pco-resource-note"]
32826
- }, "Recently Visited title: ", recentlyVisitable)));
33219
+ }, "No current person, so nothing is recorded.")));
32827
33220
  }
32828
33221
 
32829
33222
  var styles$3 = {"drawer-wrap":"drawer-wrap-J0rSL","is-visible":"is-visible-KTME8","drawer":"drawer-j7f4l"};
@@ -33028,15 +33421,50 @@ true;
33028
33421
  }));
33029
33422
  };
33030
33423
 
33424
+ const collections = {
33425
+ general: general__namespace,
33426
+ toolbar: toolbar__namespace
33427
+ };
33428
+ const paths = collections;
33429
+ function Icon({
33430
+ name,
33431
+ size = 16,
33432
+ style,
33433
+ theme,
33434
+ title,
33435
+ ...props
33436
+ }) {
33437
+ const [collection, symbol] = name.split(".");
33438
+ return /*#__PURE__*/React.createElement("svg", {
33439
+ "aria-hidden": title ? undefined : true,
33440
+ fill: "currentColor",
33441
+ height: size,
33442
+ role: title ? "img" : undefined,
33443
+ style: {
33444
+ color: theme && `var(${theme})`,
33445
+ flexShrink: 0,
33446
+ ...style
33447
+ },
33448
+ viewBox: "0 0 16 16",
33449
+ width: size,
33450
+ ...props
33451
+ }, title && /*#__PURE__*/React.createElement("title", null, title), /*#__PURE__*/React.createElement("path", {
33452
+ d: paths[collection][symbol]
33453
+ }));
33454
+ }
33455
+
33031
33456
  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"};
33032
33457
 
33033
33458
  const COLOR_MODES = [{
33459
+ icon: "toolbar.sun",
33034
33460
  label: "Light",
33035
33461
  value: "light"
33036
33462
  }, {
33463
+ icon: "toolbar.moon",
33037
33464
  label: "Dark",
33038
33465
  value: "dark"
33039
33466
  }, {
33467
+ icon: "toolbar.display",
33040
33468
  label: "System",
33041
33469
  value: "system"
33042
33470
  }];
@@ -33054,8 +33482,9 @@ true;
33054
33482
  }, "Appearance", " "), /*#__PURE__*/React.createElement("div", {
33055
33483
  className: styles$2["color-mode-switcher-modes"]
33056
33484
  }, COLOR_MODES.map(({
33057
- value,
33058
- label
33485
+ icon,
33486
+ label,
33487
+ value
33059
33488
  }) => (/*#__PURE__*/React.createElement("label", {
33060
33489
  key: value
33061
33490
  }, /*#__PURE__*/React.createElement("input", {
@@ -33072,8 +33501,9 @@ true;
33072
33501
  document.documentElement.setAttribute(RESOLVED_COLOR_MODE_ATTRIBUTE, resolveColorMode(colorMode));
33073
33502
  updateColorMode(colorMode);
33074
33503
  }
33075
- }), /*#__PURE__*/React.createElement(ColorModeIcon, {
33076
- mode: value
33504
+ }), /*#__PURE__*/React.createElement(Icon, {
33505
+ name: icon,
33506
+ size: 15
33077
33507
  }), label)))));
33078
33508
  }
33079
33509
  function updateColorMode(mode) {
@@ -33088,28 +33518,6 @@ true;
33088
33518
  method: "POST"
33089
33519
  });
33090
33520
  }
33091
- const COLOR_MODE_ICON_PATHS = {
33092
- 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",
33093
- 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",
33094
- 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"
33095
- };
33096
- function ColorModeIcon({
33097
- mode
33098
- }) {
33099
- return /*#__PURE__*/React.createElement("svg", {
33100
- width: "16",
33101
- height: "16",
33102
- viewBox: "0 0 14 14",
33103
- xmlns: "http://www.w3.org/2000/svg",
33104
- "aria-hidden": "true"
33105
- }, /*#__PURE__*/React.createElement("path", {
33106
- d: COLOR_MODE_ICON_PATHS[mode],
33107
- strokeWidth: "1.5",
33108
- fill: "none",
33109
- strokeLinecap: "round",
33110
- strokeLinejoin: "round"
33111
- }));
33112
- }
33113
33521
 
33114
33522
  const requestClearConnectedPeopleCache = () => remove();
33115
33523
  function UserProfile({
@@ -33172,10 +33580,10 @@ true;
33172
33580
  alignment: "center",
33173
33581
  spacing: 0.5,
33174
33582
  width: "100%"
33175
- }, /*#__PURE__*/React.createElement(tapestryReact.Icon, {
33583
+ }, /*#__PURE__*/React.createElement(Icon, {
33176
33584
  name: mfaConfigured ? "general.lock" : "general.unlock",
33177
- size: "sm",
33178
- color: mfaConfigured ? "var(--t-icon-color-status-success)" : "var(--t-icon-color-status-error)"
33585
+ size: 12,
33586
+ theme: mfaConfigured ? "--t-icon-color-status-success" : "--t-icon-color-status-error"
33179
33587
  }), /*#__PURE__*/React.createElement(tapestryReact.Text, {
33180
33588
  size: 4,
33181
33589
  weight: 400,
@@ -33223,9 +33631,10 @@ true;
33223
33631
  }, /*#__PURE__*/React.createElement(tapestryReact.Text, {
33224
33632
  color: "var(--text-color-default-secondary)",
33225
33633
  size: 3
33226
- }, "Log out"), /*#__PURE__*/React.createElement(tapestryReact.Icon, {
33634
+ }, "Log out"), /*#__PURE__*/React.createElement(Icon, {
33227
33635
  name: "general.intoBox",
33228
- fill: "var(--t-icon-color-secondary)"
33636
+ size: 14,
33637
+ theme: "--t-icon-color-secondary"
33229
33638
  })))), env !== "production" && /*#__PURE__*/React.createElement(DebugPcoResource, {
33230
33639
  env: env
33231
33640
  }), hasConnectedPeople && (/*#__PURE__*/React.createElement(tapestryReact.StackView, {
@@ -33287,26 +33696,42 @@ true;
33287
33696
 
33288
33697
  var ActionType;
33289
33698
  (function (ActionType) {
33699
+ ActionType["clearPending"] = "clearPending";
33290
33700
  ActionType["close"] = "close";
33291
33701
  ActionType["mount"] = "mount";
33292
33702
  ActionType["open"] = "open";
33703
+ ActionType["setPending"] = "setPending";
33293
33704
  ActionType["unmount"] = "unmount";
33294
33705
  })(ActionType || (ActionType = {}));
33295
33706
  const initialState = {
33296
33707
  currentContent: null,
33297
- open: false
33708
+ open: false,
33709
+ pending: null
33298
33710
  };
33299
33711
  const reducer = (state, action) => {
33712
+ var _a, _b;
33300
33713
  switch (action.type) {
33301
33714
  case ActionType.mount:
33302
33715
  return {
33303
33716
  ...state,
33304
- currentContent: action.payload.content
33717
+ currentContent: action.payload.content,
33718
+ 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
33305
33719
  };
33306
33720
  case ActionType.unmount:
33307
33721
  return {
33308
33722
  ...state,
33309
- currentContent: null
33723
+ currentContent: null,
33724
+ pending: null
33725
+ };
33726
+ case ActionType.setPending:
33727
+ return {
33728
+ ...state,
33729
+ pending: action.payload
33730
+ };
33731
+ case ActionType.clearPending:
33732
+ return {
33733
+ ...state,
33734
+ pending: null
33310
33735
  };
33311
33736
  case ActionType.open:
33312
33737
  return {
@@ -33327,11 +33752,179 @@ true;
33327
33752
 
33328
33753
  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"};
33329
33754
 
33330
- const pcoUrl = pcoUrl$1;
33755
+ // PCO Box serves TLS by default, and a bare http:// frame inside an https
33756
+ // product page is blocked as mixed content, so follow the page's scheme.
33757
+ const iframeSrc = () => `${window.location.protocol}//pco.test/iframe`;
33758
+ const STAFF_PINK = "var(--t-fill-color-product-staff)";
33759
+ // The pco.test domain is only ever served by a local PCO Box, so the tool
33760
+ // stays hidden in staging and production — and on Topbar's own dev server,
33761
+ // which runs on localhost and has no pco.test to frame.
33762
+ function isPcoTestHost() {
33763
+ const {
33764
+ hostname
33765
+ } = window.location;
33766
+ return hostname === "pco.test" || hostname.endsWith(".pco.test");
33767
+ }
33768
+ // Flattened from the source artwork's two outline paths — its gradients,
33769
+ // filters and clip paths carry nothing at icon size. Those paths are authored
33770
+ // on a 128px canvas, hence the transform: it fits the pair into the same ~24px
33771
+ // optical box the sibling toolbar icons occupy. Filled explicitly rather than
33772
+ // with `currentColor` so the mark stays staff pink instead of inheriting the
33773
+ // toolbar's text color and its hover/active states.
33774
+ 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";
33775
+ 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";
33776
+ function ToolsIcon() {
33777
+ return /*#__PURE__*/React.createElement("svg", {
33778
+ "aria-hidden": "true",
33779
+ width: "32",
33780
+ height: "32",
33781
+ viewBox: "0 0 32 32",
33782
+ xmlns: "http://www.w3.org/2000/svg"
33783
+ }, /*#__PURE__*/React.createElement("g", {
33784
+ fill: STAFF_PINK,
33785
+ transform: "translate(2.92 2.66) scale(.204)"
33786
+ }, /*#__PURE__*/React.createElement("path", {
33787
+ d: WRENCH
33788
+ }), /*#__PURE__*/React.createElement("path", {
33789
+ d: SCREWDRIVER
33790
+ })));
33791
+ }
33792
+ // A cross-origin frame's own history object is unreachable (touching it throws
33793
+ // a SecurityError), but the frame's navigations do land in the top-level joint
33794
+ // session history, so the *parent's* back/forward traverse them while leaving
33795
+ // the host page in place. Counting frame loads tracks how deep we are, which is
33796
+ // what keeps a click on Back from stepping past the frame's first page and
33797
+ // navigating the host app instead. Loads we caused are attributed to the
33798
+ // traversal that requested them rather than counted as new navigations.
33799
+ function useFrameHistory() {
33800
+ const [steps, setSteps] = React.useState({
33801
+ back: 0,
33802
+ forward: 0
33803
+ });
33804
+ // Traversal has to read the current counts outside of render, so the ref is
33805
+ // the source of truth and state exists to paint the buttons.
33806
+ const stepsRef = React.useRef(steps);
33807
+ const pendingTraversal = React.useRef(null);
33808
+ const frameIsLoaded = React.useRef(false);
33809
+ const moveTo = React.useCallback(next => {
33810
+ stepsRef.current = next;
33811
+ setSteps(next);
33812
+ }, []);
33813
+ const onFrameLoad = React.useCallback(() => {
33814
+ if (!frameIsLoaded.current) {
33815
+ // The frame's first page is the floor, not a step.
33816
+ frameIsLoaded.current = true;
33817
+ } else {
33818
+ const traversal = pendingTraversal.current;
33819
+ pendingTraversal.current = null;
33820
+ const {
33821
+ back,
33822
+ forward
33823
+ } = stepsRef.current;
33824
+ if (traversal === "back") {
33825
+ moveTo({
33826
+ back: back - 1,
33827
+ forward: forward + 1
33828
+ });
33829
+ } else if (traversal === "forward") {
33830
+ moveTo({
33831
+ back: back + 1,
33832
+ forward: forward - 1
33833
+ });
33834
+ } else {
33835
+ moveTo({
33836
+ back: back + 1,
33837
+ forward: 0
33838
+ });
33839
+ }
33840
+ }
33841
+ }, [moveTo]);
33842
+ // Guarded here rather than trusting the button's disabled state: traversing
33843
+ // with no frame step left would navigate the host app out from under us.
33844
+ const traverse = React.useCallback(direction => {
33845
+ if (stepsRef.current[direction] > 0) {
33846
+ pendingTraversal.current = direction;
33847
+ window.history[direction]();
33848
+ }
33849
+ }, []);
33850
+ return {
33851
+ onFrameLoad,
33852
+ steps,
33853
+ traverse
33854
+ };
33855
+ }
33856
+ function NavButton({
33857
+ direction,
33858
+ disabled,
33859
+ onClick
33860
+ }) {
33861
+ return /*#__PURE__*/React.createElement(tapestryReact.Button
33862
+ // Tapestry's `disabled` suppresses the click but emits no ARIA state.
33863
+ , {
33864
+ "aria-disabled": disabled,
33865
+ disabled: disabled,
33866
+ hover: {
33867
+ backgroundColor: "var(--t-fill-color-neutral-070)"
33868
+ },
33869
+ icon: {
33870
+ fill: "var(--t-icon-color-dim)",
33871
+ name: direction === "back" ? "general.leftChevron" : "general.rightChevron",
33872
+ size: "md"
33873
+ },
33874
+ onClick: onClick,
33875
+ opacity: disabled ? 0.35 : 1,
33876
+ theme: false,
33877
+ title: direction === "back" ? "Back" : "Forward"
33878
+ });
33879
+ }
33880
+ function DeveloperToolsComponent({
33881
+ closeDrawer
33882
+ }) {
33883
+ const {
33884
+ onFrameLoad,
33885
+ steps,
33886
+ traverse
33887
+ } = useFrameHistory();
33888
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(DrawerHeader, {
33889
+ closeDrawer: closeDrawer
33890
+ }, /*#__PURE__*/React.createElement(tapestryReact.StackView, {
33891
+ alignment: "center",
33892
+ axis: "horizontal",
33893
+ spacing: 0.25
33894
+ }, /*#__PURE__*/React.createElement(NavButton, {
33895
+ direction: "back",
33896
+ disabled: steps.back === 0,
33897
+ onClick: () => traverse("back")
33898
+ }), /*#__PURE__*/React.createElement(NavButton, {
33899
+ direction: "forward",
33900
+ disabled: steps.forward === 0,
33901
+ onClick: () => traverse("forward")
33902
+ }), /*#__PURE__*/React.createElement(DrawerHeading, {
33903
+ paddingLeft: 0.5
33904
+ }, "Developer Tools"))), /*#__PURE__*/React.createElement(tapestryReact.Box, {
33905
+ as: "iframe",
33906
+ border: "none",
33907
+ flex: "1",
33908
+ minHeight: 0,
33909
+ onLoad: onFrameLoad,
33910
+ src: iframeSrc(),
33911
+ title: "Developer Tools",
33912
+ width: "100%"
33913
+ }));
33914
+ }
33915
+ const DeveloperTools = {
33916
+ component: DeveloperToolsComponent,
33917
+ icons: {
33918
+ component: ToolsIcon
33919
+ },
33920
+ label: "Developer Tools",
33921
+ // No space: `name` becomes an element id and a pendo attribute.
33922
+ name: "DeveloperTools"
33923
+ };
33331
33924
 
33332
33925
  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"};
33333
33926
 
33334
- 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";
33927
+ 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";
33335
33928
  // Same stops as the AskAIBanner border gradient, so the two stay in sync.
33336
33929
  const GRADIENT_STOPS = [{
33337
33930
  color: "var(--t-fill-color-tag-teal-040)",
@@ -33380,7 +33973,7 @@ true;
33380
33973
  stopColor: color
33381
33974
  }
33382
33975
  })))))), /*#__PURE__*/React.createElement("path", {
33383
- d: SPARKLE_PATH,
33976
+ d: SPARKLE_PATH$1,
33384
33977
  fill: isGradient ? `url(#${gradientId})` : "currentColor"
33385
33978
  }));
33386
33979
  }
@@ -33788,16 +34381,24 @@ true;
33788
34381
  // not the path, so updating it is a same-document navigation that doesn't
33789
34382
  // reload the iframe, but also lets the route survive a forced reload (e.g.
33790
34383
  // Turbo re-parenting the topbar).
33791
- function buildPicobotSrc(origin, path) {
34384
+ //
34385
+ // `panel` loads picobot as its own toolbar panel rather than inside Helpdesk.
34386
+ function buildPicobotSrc(origin, path, {
34387
+ panel = false
34388
+ } = {}) {
33792
34389
  const [rawPath, queryPart] = path.replace(/^\/+/, "").split("?", 2);
33793
34390
  const safePath = rawPath.split("/").filter(segment => segment && segment !== "." && segment !== "..").join("/");
33794
34391
  const fragment = queryPart !== undefined ? `${safePath}?${queryPart}` : safePath;
33795
- const base = new URL("topbar", origin).toString();
34392
+ const url = new URL("topbar", origin);
34393
+ if (panel) url.searchParams.set("panel", "1");
34394
+ const base = url.toString();
33796
34395
  return fragment ? `${base}#${fragment}` : base;
33797
34396
  }
33798
34397
 
33799
34398
  const LOAD_TIMEOUT_MS = 3000;
33800
- function createPicobotIframe(picobotOrigin) {
34399
+ function createPicobotIframe(picobotOrigin, {
34400
+ panel = false
34401
+ } = {}) {
33801
34402
  return /*#__PURE__*/React.forwardRef(function PicobotIframe({
33802
34403
  currentPath: iframePath,
33803
34404
  onChangePath: onIframeChangePath,
@@ -33853,7 +34454,9 @@ true;
33853
34454
  (_b = (_a = iframeRef.current) === null || _a === void 0 ? void 0 : _a.contentWindow) === null || _b === void 0 ? void 0 : _b.postMessage(message, picobotOrigin);
33854
34455
  }
33855
34456
  }), []);
33856
- const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath);
34457
+ const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath, {
34458
+ panel
34459
+ });
33857
34460
  return /*#__PURE__*/React.createElement("iframe", {
33858
34461
  ref: iframeRef,
33859
34462
  src: iframeSrc,
@@ -33892,22 +34495,28 @@ true;
33892
34495
  currentPath,
33893
34496
  env,
33894
34497
  helpdeskConfig,
33895
- onChangePath
34498
+ isPicobotLoading,
34499
+ onChangePath,
34500
+ openPicobot
33896
34501
  }) {
33897
34502
  const PicobotIframe = React.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot")), [env]);
34503
+ const loadingFallback = /*#__PURE__*/React.createElement(StackView$1, {
34504
+ flex: 1,
34505
+ axis: "vertical"
34506
+ }, /*#__PURE__*/React.createElement(DrawerHeader, {
34507
+ closeDrawer: closeDrawer
34508
+ }, /*#__PURE__*/React.createElement(StackView$1, {
34509
+ axis: "horizontal",
34510
+ spacing: 1
34511
+ }, /*#__PURE__*/React.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React.createElement(LoadingIndicator$1, null))));
34512
+ // The add-on reads its args once, so mounting before the gate answers
34513
+ // would leave out openPicobot for good.
34514
+ if (isPicobotLoading) return loadingFallback;
33898
34515
  return /*#__PURE__*/React.createElement(addOns.UiExtensionFirstPartyComponent, {
33899
34516
  grow: 1,
33900
34517
  display: "flex",
33901
34518
  extensionId: env === "staging" ? "42" : "11",
33902
- loadingFallback: /*#__PURE__*/React.createElement(StackView$1, {
33903
- flex: 1,
33904
- axis: "vertical"
33905
- }, /*#__PURE__*/React.createElement(DrawerHeader, {
33906
- closeDrawer: closeDrawer
33907
- }, /*#__PURE__*/React.createElement(StackView$1, {
33908
- axis: "horizontal",
33909
- spacing: 1
33910
- }, /*#__PURE__*/React.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React.createElement(LoadingIndicator$1, null)))),
34519
+ loadingFallback: loadingFallback,
33911
34520
  additionalComponents: {
33912
34521
  AskAIBanner: {
33913
34522
  component: AskAIBanner,
@@ -34005,7 +34614,11 @@ true;
34005
34614
  currentPageParams: location.search,
34006
34615
  currentPath,
34007
34616
  helpdeskConfig,
34008
- onChangePath
34617
+ onChangePath,
34618
+ // Its presence tells helpdesk that Picobot has its own panel.
34619
+ ...(openPicobot && {
34620
+ openPicobot
34621
+ })
34009
34622
  }
34010
34623
  });
34011
34624
  }
@@ -34552,6 +35165,142 @@ true;
34552
35165
  name: "Notifications"
34553
35166
  };
34554
35167
 
35168
+ 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";
35169
+ // Helpdesk reads this context off the email path to open its email form the
35170
+ // same way a handoff inside its own drawer does.
35171
+ function emailPathWithContext(context = {}) {
35172
+ const params = new URLSearchParams();
35173
+ for (const key of ["conversationId", "source", "turnIndex"]) {
35174
+ const value = context[key];
35175
+ if (value !== null && value !== undefined) params.set(key, String(value));
35176
+ }
35177
+ return `email?${params}`;
35178
+ }
35179
+ function PicobotComponent({
35180
+ clearPending,
35181
+ closeDrawer,
35182
+ currentPath,
35183
+ env,
35184
+ onChangePath,
35185
+ openTool,
35186
+ pending
35187
+ }) {
35188
+ const PicobotIframe = React.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot"), {
35189
+ panel: true
35190
+ }), [env]);
35191
+ const picobotRef = React.useRef(null);
35192
+ const [isPicobotReady, setIsPicobotReady] = React.useState(false);
35193
+ // Picobot has no listener until it sends READY, so openPicobot's draft or
35194
+ // conversation waits for it.
35195
+ React.useEffect(function handOverPendingOncePicobotIsReady() {
35196
+ var _a, _b;
35197
+ if (!pending || !isPicobotReady) return;
35198
+ const {
35199
+ conversationId,
35200
+ draft,
35201
+ ticketExternalId
35202
+ } = pending;
35203
+ if (conversationId) {
35204
+ (_a = picobotRef.current) === null || _a === void 0 ? void 0 : _a.postMessage({
35205
+ payload: {
35206
+ conversation_id: conversationId,
35207
+ // Picobot asks whether this resolved the ticket, and closes it.
35208
+ ...(ticketExternalId && {
35209
+ external_id: ticketExternalId,
35210
+ source: "ticket_submitted"
35211
+ })
35212
+ },
35213
+ type: "LOAD_CONVERSATION"
35214
+ });
35215
+ } else if (draft) {
35216
+ // Not both: Picobot cancels a conversation load when a prefill
35217
+ // starts a new conversation.
35218
+ (_b = picobotRef.current) === null || _b === void 0 ? void 0 : _b.postMessage({
35219
+ payload: {
35220
+ source: "email_form",
35221
+ text: draft
35222
+ },
35223
+ type: "PREFILL_COMPOSER"
35224
+ });
35225
+ }
35226
+ clearPending();
35227
+ }, [pending, isPicobotReady, clearPending]);
35228
+ const [iframeErrored, setIframeErrored] = React.useState(false);
35229
+ const [mountKey, setMountKey] = React.useState(0);
35230
+ const handleRetry = React.useCallback(() => {
35231
+ setIframeErrored(false);
35232
+ setIsPicobotReady(false);
35233
+ setMountKey(key => key + 1);
35234
+ }, []);
35235
+ const handleMessage = React.useCallback(data => {
35236
+ var _a;
35237
+ const message = data;
35238
+ switch (message === null || message === void 0 ? void 0 : message.type) {
35239
+ case "CLOSE_DRAWER":
35240
+ return closeDrawer();
35241
+ case "READY":
35242
+ return setIsPicobotReady(true);
35243
+ case "PICOBOT_SUPPORT_HANDOFF":
35244
+ return openTool("helpdesk", ((_a = message.payload) === null || _a === void 0 ? void 0 : _a.method) === "zoom" ? "phone" : emailPathWithContext(message.payload));
35245
+ case "IFRAME_LOAD_ERROR":
35246
+ console.warn("topbar: Picobot iframe failed to load");
35247
+ return setIframeErrored(true);
35248
+ }
35249
+ }, [closeDrawer, openTool]);
35250
+ if (iframeErrored) {
35251
+ return /*#__PURE__*/React.createElement(LoadError, {
35252
+ onContactSupport: () => openTool("helpdesk", "email"),
35253
+ onRetry: handleRetry
35254
+ });
35255
+ }
35256
+ return /*#__PURE__*/React.createElement(PicobotIframe, {
35257
+ key: mountKey,
35258
+ ref: picobotRef,
35259
+ currentPath: currentPath,
35260
+ onChangePath: onChangePath,
35261
+ onMessage: handleMessage
35262
+ });
35263
+ }
35264
+ function LoadError({
35265
+ onContactSupport,
35266
+ onRetry
35267
+ }) {
35268
+ return /*#__PURE__*/React.createElement(tapestryReact.StackView, {
35269
+ axis: "vertical",
35270
+ flex: 1,
35271
+ alignment: "center",
35272
+ distribution: "center",
35273
+ padding: 3,
35274
+ spacing: 1
35275
+ }, /*#__PURE__*/React.createElement(tapestryReact.Heading, {
35276
+ level: 2
35277
+ }, "Picobot is temporarily unavailable"), /*#__PURE__*/React.createElement(tapestryReact.Text, {
35278
+ size: 5,
35279
+ color: "var(--t-text-color-secondary)"
35280
+ }, "Try again or contact support."), /*#__PURE__*/React.createElement(tapestryReact.Box, {
35281
+ paddingTop: 2
35282
+ }, /*#__PURE__*/React.createElement(tapestryReact.Button, {
35283
+ onClick: onRetry,
35284
+ iconLeft: "general.refresh",
35285
+ theme: "primary",
35286
+ variant: "fill",
35287
+ title: "Retry"
35288
+ })), /*#__PURE__*/React.createElement(tapestryReact.Button, {
35289
+ onClick: onContactSupport,
35290
+ variant: "naked",
35291
+ theme: "primary",
35292
+ title: "Contact support"
35293
+ }));
35294
+ }
35295
+ const Picobot = {
35296
+ component: PicobotComponent,
35297
+ icons: {
35298
+ staticDotPath: SPARKLE_PATH,
35299
+ staticPath: SPARKLE_PATH
35300
+ },
35301
+ name: "Picobot"
35302
+ };
35303
+
34555
35304
  const {
34556
35305
  LoadingIndicator,
34557
35306
  StackView
@@ -34947,6 +35696,41 @@ true;
34947
35696
  };
34948
35697
  };
34949
35698
 
35699
+ // Picobot's API rejects requests without a version header, and apiRequest
35700
+ // sets its own headers, so the version rides in on the fetch.
35701
+ const picobotFetch = (url, opts) => defaultFetch(url, {
35702
+ ...opts,
35703
+ headers: {
35704
+ ...(opts === null || opts === void 0 ? void 0 : opts.headers),
35705
+ "X-PCO-API-Version": "2026-08-11"
35706
+ }
35707
+ });
35708
+ // Routes wait on the gate, so a slow Picobot can't hold them up for long.
35709
+ const GATE_TIMEOUT_MS = 3000;
35710
+ const usePicobotConfig = ({
35711
+ isEnabled
35712
+ }) => {
35713
+ // Undefined until Picobot answers.
35714
+ const [panelEnabled, setPanelEnabled] = React.useState();
35715
+ React.useEffect(() => {
35716
+ if (!isEnabled) return undefined;
35717
+ // Whatever settles first wins, so a late answer can't reopen the gate.
35718
+ const settle = enabled => setPanelEnabled(current => current !== null && current !== void 0 ? current : enabled);
35719
+ const timeout = setTimeout(() => settle(false), GATE_TIMEOUT_MS);
35720
+ apiRequest(picobotFetch, `${window.location.origin}/~api/picobot/v2/me`).then(({
35721
+ json
35722
+ }) => {
35723
+ var _a, _b;
35724
+ 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);
35725
+ }).catch(() => settle(false)).finally(() => clearTimeout(timeout));
35726
+ return () => clearTimeout(timeout);
35727
+ }, [isEnabled]);
35728
+ return {
35729
+ displayPicobotIcon: panelEnabled === true,
35730
+ isLoading: isEnabled && panelEnabled === undefined
35731
+ };
35732
+ };
35733
+
34950
35734
  const Profile = {
34951
35735
  component: UserProfile,
34952
35736
  icons: {
@@ -34957,10 +35741,16 @@ true;
34957
35741
  };
34958
35742
 
34959
35743
  const DURATION = 100;
35744
+ // Exempt paths match regardless of query, so `email?conversationId=…` is
35745
+ // exempt along with `email`.
34960
35746
  function isToolPathExemptFromStorage(toolName, path) {
34961
35747
  var _a, _b;
34962
35748
  const tool = toolsRouteMap[toolName];
34963
- 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;
35749
+ const [pathWithoutQuery] = path.split("?");
35750
+ 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;
35751
+ }
35752
+ function isConversationPath(path) {
35753
+ return Boolean(path === null || path === void 0 ? void 0 : path.startsWith("conversations/"));
34964
35754
  }
34965
35755
  function scrollToolDrawerToTop(toolName) {
34966
35756
  var _a;
@@ -34973,6 +35763,7 @@ true;
34973
35763
  Chat,
34974
35764
  Helpdesk,
34975
35765
  Notifications,
35766
+ Picobot,
34976
35767
  Profile,
34977
35768
  Tasks
34978
35769
  };
@@ -35000,7 +35791,8 @@ true;
35000
35791
  });
35001
35792
  const {
35002
35793
  currentContent,
35003
- open
35794
+ open,
35795
+ pending
35004
35796
  } = state;
35005
35797
  const mounted = !!currentContent;
35006
35798
  const storeData = useStore();
@@ -35013,6 +35805,15 @@ true;
35013
35805
  ...(env === "development" ? _storeDataOverride !== null && _storeDataOverride !== void 0 ? _storeDataOverride : {} : {})
35014
35806
  };
35015
35807
  const updateStore = useUpdateStore();
35808
+ const {
35809
+ displayPicobotIcon,
35810
+ isLoading: isPicobotGateLoading
35811
+ } = usePicobotConfig({
35812
+ isEnabled: Boolean(userId)
35813
+ });
35814
+ // Picobot is a kill-switched tool: until its gate says the panel is
35815
+ // enabled, its routes are held, and if it never does, they're ignored.
35816
+ const routableTool = React.useCallback(name => name === Picobot.name.toLowerCase() && !displayPicobotIcon ? undefined : toolsRouteMap[name], [displayPicobotIcon]);
35016
35817
  React.useEffect(() => {
35017
35818
  if (open) {
35018
35819
  document.body.style.overflow = "hidden";
@@ -35100,7 +35901,9 @@ true;
35100
35901
  });
35101
35902
  }, [clearRoute]);
35102
35903
  React.useEffect(() => {
35103
- if (!toolbarFragmentRoute) return;
35904
+ if (!toolbarFragmentRoute || !routableTool(toolbarFragmentRoute.tool)) {
35905
+ return;
35906
+ }
35104
35907
  const isExempt = isToolPathExemptFromStorage(toolbarFragmentRoute.tool, toolbarFragmentRoute.path);
35105
35908
  if (isExempt) return;
35106
35909
  const nextState = {
@@ -35110,12 +35913,32 @@ true;
35110
35913
  if (!shallowCompare(nextState, toolStateCookie)) {
35111
35914
  setToolStateCookie(nextState);
35112
35915
  }
35113
- }, [toolbarFragmentRoute, toolStateCookie]);
35916
+ }, [toolbarFragmentRoute, toolStateCookie, routableTool]);
35114
35917
  React.useEffect(() => {
35115
- if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && (currentContent !== toolsRouteMap[toolbarFragmentRoute.tool] || !open)) {
35116
- setToolAndOpenDrawer(toolsRouteMap[toolbarFragmentRoute.tool]);
35918
+ // Helpdesk conversations may belong to Picobot, so they wait for its gate
35919
+ // rather than flashing open in Helpdesk first.
35920
+ const mayRedirectToPicobot = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path) && (isPicobotGateLoading || displayPicobotIcon);
35921
+ const tool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && !mayRedirectToPicobot && routableTool(toolbarFragmentRoute.tool);
35922
+ if (tool && (currentContent !== tool || !open)) {
35923
+ setToolAndOpenDrawer(tool);
35117
35924
  }
35118
- }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, currentContent, open]);
35925
+ }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path, currentContent, open, routableTool, isPicobotGateLoading, displayPicobotIcon]);
35926
+ const openTool = React.useCallback((name, path, pendingValue) => {
35927
+ const tool = routableTool(name.toLowerCase());
35928
+ if (!tool) return;
35929
+ dispatch({
35930
+ payload: pendingValue === undefined ? null : {
35931
+ tool: tool.name,
35932
+ value: pendingValue
35933
+ },
35934
+ type: ActionType.setPending
35935
+ });
35936
+ onChangeToolbarFragmentRoute(tool.name.toLowerCase(), path);
35937
+ scrollToolDrawerToTop(tool.name);
35938
+ }, [onChangeToolbarFragmentRoute, routableTool]);
35939
+ const clearPending = React.useCallback(() => dispatch({
35940
+ type: ActionType.clearPending
35941
+ }), []);
35119
35942
  const toggleDrawer = content => {
35120
35943
  if (!open) {
35121
35944
  return openDrawer(content);
@@ -35132,12 +35955,52 @@ true;
35132
35955
  joltPromise: storeData === null || storeData === void 0 ? void 0 : storeData.joltPromise,
35133
35956
  userId
35134
35957
  });
35135
- const TOOLS = [Notifications, Tasks, displayChatIcon && Chat].filter(Boolean);
35958
+ // Picobot conversations moved from Helpdesk to the Picobot panel, so
35959
+ // send old links and saved paths there once the panel is enabled.
35960
+ React.useEffect(() => {
35961
+ if (!displayPicobotIcon) return;
35962
+ if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path)) {
35963
+ onChangeToolbarFragmentRoute(Picobot.name.toLowerCase(), toolbarFragmentRoute.path);
35964
+ }
35965
+ }, [displayPicobotIcon, toolbarFragmentRoute, onChangeToolbarFragmentRoute]);
35966
+ React.useEffect(() => {
35967
+ if (!displayPicobotIcon) return;
35968
+ const {
35969
+ helpdesk,
35970
+ picobot
35971
+ } = toolStateCookie;
35972
+ if (!isConversationPath(helpdesk)) return;
35973
+ setToolStateCookie({
35974
+ ...toolStateCookie,
35975
+ helpdesk: "",
35976
+ picobot: picobot || helpdesk
35977
+ });
35978
+ }, [displayPicobotIcon, toolStateCookie]);
35979
+ const openPicobot = React.useCallback(options => {
35980
+ var _a;
35981
+ // Moving an open Picobot off its path reloads or navigates the iframe,
35982
+ // which would race the options it's being handed.
35983
+ const path = currentContent === Picobot ? (_a = toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path) !== null && _a !== void 0 ? _a : "" : "";
35984
+ openTool(Picobot.name, path, options);
35985
+ }, [openTool, currentContent, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path]);
35986
+ React.useEffect(() => {
35987
+ if (!displayPicobotIcon) return undefined;
35988
+ window.topbar = window.topbar || {};
35989
+ window.topbar.openPicobot = openPicobot;
35990
+ return () => {
35991
+ delete window.topbar.openPicobot;
35992
+ };
35993
+ }, [displayPicobotIcon, openPicobot]);
35994
+ const TOOLS = [Notifications, Tasks, displayChatIcon && Chat, displayPicobotIcon && Picobot, isPcoTestHost() && DeveloperTools].filter(Boolean);
35136
35995
  const handleToolOnClick = tool => {
35137
35996
  toggleDrawer(tool);
35138
35997
  scrollToolDrawerToTop(tool.name);
35139
35998
  };
35140
35999
  const toolSpecificProps = {
36000
+ Helpdesk: {
36001
+ isPicobotLoading: isPicobotGateLoading,
36002
+ openPicobot: displayPicobotIcon ? openPicobot : undefined
36003
+ },
35141
36004
  Profile: {
35142
36005
  appName,
35143
36006
  closeDrawer,
@@ -35160,6 +36023,7 @@ true;
35160
36023
  const routeIsForCurrentTool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && toolsRouteMap[toolbarFragmentRoute.tool] === currentContent;
35161
36024
  const currentToolProps = {
35162
36025
  appName,
36026
+ clearPending,
35163
36027
  closeDrawer,
35164
36028
  currentPath: routeIsForCurrentTool ? toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path : null,
35165
36029
  env,
@@ -35168,6 +36032,8 @@ true;
35168
36032
  joltPromise,
35169
36033
  onChangePath,
35170
36034
  openDrawer: () => currentContent && openDrawer(currentContent),
36035
+ openTool,
36036
+ 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,
35171
36037
  userId,
35172
36038
  ...(currentContent ? toolSpecificProps[currentContent.name] : {})
35173
36039
  };
@@ -35195,21 +36061,20 @@ true;
35195
36061
  name: "toolbar.planningCenter",
35196
36062
  size: "24px"
35197
36063
  })), [HelpDeskTool, ...TOOLS].map(tool => {
36064
+ var _a;
35198
36065
  const current = open && currentContent === tool;
35199
36066
  const indicator = indicatorState[tool.name];
35200
36067
  return /*#__PURE__*/React.createElement(ToolbarButton, {
35201
36068
  current: current,
35202
36069
  onClick: () => handleToolOnClick(tool),
35203
36070
  id: getDataPendoAttribute(tool.name),
35204
- "aria-label": `Open ${tool.name} drawer`,
36071
+ "aria-label": `Open ${(_a = tool.label) !== null && _a !== void 0 ? _a : tool.name} drawer`,
35205
36072
  "data-pendo": getDataPendoAttribute(tool.name),
35206
36073
  key: tool.name
35207
- }, !indicator && (/*#__PURE__*/React.createElement(ToolbarButtonIcon, {
35208
- path: tool.icons.staticPath
35209
- })), indicator && (/*#__PURE__*/React.createElement(ToolbarButtonIcon, {
35210
- path: tool.icons.staticDotPath,
35211
- dot: true
35212
- })));
36074
+ }, /*#__PURE__*/React.createElement(ToolbarToolIcon, {
36075
+ icons: tool.icons,
36076
+ dot: !!indicator
36077
+ }));
35213
36078
  })), /*#__PURE__*/React.createElement(DrawerWrap, {
35214
36079
  isVisible: open
35215
36080
  }, mounted && (/*#__PURE__*/React.createElement(Drawer, {
@@ -35233,6 +36098,19 @@ true;
35233
36098
  ...restProps
35234
36099
  }, children);
35235
36100
  };
36101
+ function ToolbarToolIcon({
36102
+ dot,
36103
+ icons
36104
+ }) {
36105
+ if ("component" in icons) {
36106
+ return /*#__PURE__*/React.createElement(icons.component, null);
36107
+ } else {
36108
+ return /*#__PURE__*/React.createElement(ToolbarButtonIcon, {
36109
+ path: dot ? icons.staticDotPath : icons.staticPath,
36110
+ dot: dot
36111
+ });
36112
+ }
36113
+ }
35236
36114
  function ToolbarButtonIcon({
35237
36115
  dot,
35238
36116
  path