@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.
package/dist/topbar.js CHANGED
@@ -180,12 +180,14 @@ function inferRailsCsrfToken() {
180
180
  }
181
181
  function apiRequest(fetch, url, {
182
182
  method = "GET",
183
- data = null
183
+ data = null,
184
+ headers = {}
184
185
  } = {}) {
185
186
  const options = {
186
187
  headers: {
187
188
  Accept: "application/json",
188
- "Content-Type": "application/json"
189
+ "Content-Type": "application/json",
190
+ ...headers
189
191
  },
190
192
  method,
191
193
  redirect: "follow"
@@ -805,7 +807,7 @@ function useMediaQuery(query) {
805
807
  return matches;
806
808
  }
807
809
 
808
- const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
810
+ const capitalize$1 = s => s.charAt(0).toUpperCase() + s.slice(1);
809
811
  const RESOLVED_COLOR_MODE_ATTRIBUTE = "data-resolved-color-mode";
810
812
  function resolveColorMode(colorMode) {
811
813
  if (colorMode === "system") {
@@ -3329,19 +3331,513 @@ function ProductTabs({
3329
3331
  }, children));
3330
3332
  }
3331
3333
 
3334
+ function parseValue(element) {
3335
+ const datatype = element.getAttribute("datatype");
3336
+ switch (datatype) {
3337
+ case "boolean":
3338
+ return element.content === "true" || element.content === "1";
3339
+ default:
3340
+ return element.content;
3341
+ }
3342
+ }
3343
+ function getAllPcoMeta() {
3344
+ const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3345
+ const meta = {};
3346
+ elements.forEach(e => {
3347
+ const property = e.getAttribute("property");
3348
+ meta[property.substring("pco:".length)] = parseValue(e);
3349
+ });
3350
+ return meta;
3351
+ }
3352
+ // usePcoMeta only reads each tag's content. Recently Visited also needs the
3353
+ // pco:recently_visitable tag's `metadata` attribute, which can change while
3354
+ // the content stays the same, so it is tracked in its own store.
3355
+ function getRecentlyVisitableMetadata() {
3356
+ var _a, _b;
3357
+ 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;
3358
+ }
3359
+ const store = createStore(getAllPcoMeta());
3360
+ const recentlyVisitableMetadataStore = createStore({
3361
+ metadata: getRecentlyVisitableMetadata()
3362
+ });
3363
+ function sync() {
3364
+ const nextState = getAllPcoMeta();
3365
+ if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3366
+ const metadata = getRecentlyVisitableMetadata();
3367
+ if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3368
+ recentlyVisitableMetadataStore.setState({
3369
+ metadata
3370
+ });
3371
+ }
3372
+ }
3373
+ const observer = new MutationObserver(sync);
3374
+ let hasTriggeredObserve = false;
3375
+ function observe() {
3376
+ if (hasTriggeredObserve) return;
3377
+ hasTriggeredObserve = true;
3378
+ // The module-load snapshot above can predate meta tags that appear later
3379
+ // in <head> (e.g. pco:resource, yielded right before </head>) if this
3380
+ // module's own script tag isn't deferred and runs mid-parse. Those tags
3381
+ // never mutate afterward, so the observer alone would never catch them.
3382
+ // Re-read once now, since observe() is only called once a consumer is
3383
+ // actually mounting, which is always after the document has fully parsed.
3384
+ sync();
3385
+ observer.observe(document.head, {
3386
+ attributes: true,
3387
+ childList: true,
3388
+ subtree: true
3389
+ });
3390
+ }
3391
+ function usePcoMeta() {
3392
+ observe();
3393
+ return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3394
+ }
3395
+ function useRecentlyVisitableMetadata() {
3396
+ observe();
3397
+ return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3398
+ }
3399
+
3400
+ const MAX_ENTRIES$1 = 20;
3401
+ const FORMAT_VERSION = 1;
3402
+ const MAX_TEXT_LENGTH = 200;
3403
+ const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3404
+ function isId(value) {
3405
+ return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3406
+ }
3407
+ // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3408
+ function isPcrl(value) {
3409
+ const segments = value.split("/");
3410
+ return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3411
+ }
3412
+ function isNonBlankString(value) {
3413
+ return typeof value === "string" && value.trim() !== "";
3414
+ }
3415
+ function isRecord(value) {
3416
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3417
+ }
3418
+ function entryKey(entry) {
3419
+ if (entry.type === "resource") {
3420
+ return `resource:${entry.pcrl}`;
3421
+ } else {
3422
+ return `page:${entry.product}:${entry.path}`;
3423
+ }
3424
+ }
3425
+ function isEntry(value) {
3426
+ if (!isRecord(value)) {
3427
+ return false;
3428
+ } else if (value.type === "resource") {
3429
+ return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3430
+ } else if (value.type === "page") {
3431
+ return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3432
+ } else {
3433
+ return false;
3434
+ }
3435
+ }
3436
+ // Cuts on code points so a surrogate pair is never split.
3437
+ function clampText(value) {
3438
+ return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3439
+ }
3440
+ function clampEntry(entry) {
3441
+ if (entry.type === "resource") {
3442
+ return entry;
3443
+ } else {
3444
+ return {
3445
+ ...entry,
3446
+ details: clampText(entry.details),
3447
+ title: clampText(entry.title)
3448
+ };
3449
+ }
3450
+ }
3451
+ // Recents are disposable: anything unexpected reads as an empty list (or, for
3452
+ // a single bad entry, a list without it) rather than throwing.
3453
+ function parseRecentlyVisited(value, personId) {
3454
+ let parsed = null;
3455
+ try {
3456
+ parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
3457
+ } catch {
3458
+ parsed = null;
3459
+ }
3460
+ if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
3461
+ return parsed.entries.filter(isEntry);
3462
+ } else {
3463
+ return [];
3464
+ }
3465
+ }
3466
+ function addVisit(entries, visit) {
3467
+ const key = entryKey(visit);
3468
+ return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES$1);
3469
+ }
3470
+ function serializeRecentlyVisited(entries, personId) {
3471
+ return JSON.stringify({
3472
+ entries,
3473
+ person: personId,
3474
+ v: FORMAT_VERSION
3475
+ });
3476
+ }
3477
+
3478
+ // The product whose URL, in this environment, has the current page's host.
3479
+ // Reversing pcoUrl handles every environment @planningcenter/url knows about,
3480
+ // and guarantees the stored product rebuilds a working link.
3481
+ function currentProduct() {
3482
+ var _a;
3483
+ return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
3484
+ }
3485
+ function detailsFromMetadata(metadata) {
3486
+ var _a;
3487
+ try {
3488
+ const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
3489
+ return typeof details === "string" ? details : "";
3490
+ } catch {
3491
+ return "";
3492
+ }
3493
+ }
3494
+ // pco:resource is the more specific signal, so it wins when both tags are
3495
+ // present. A resource that isn't a PCRL is ignored rather than recorded.
3496
+ function visitFromPage({
3497
+ metadata,
3498
+ resource,
3499
+ title
3500
+ }) {
3501
+ const product = currentProduct();
3502
+ if (resource && isPcrl(resource)) {
3503
+ return {
3504
+ pcrl: resource,
3505
+ type: "resource"
3506
+ };
3507
+ } else if (title && title.trim() !== "" && product) {
3508
+ return {
3509
+ details: detailsFromMetadata(metadata),
3510
+ path: window.location.pathname,
3511
+ product,
3512
+ title,
3513
+ type: "page"
3514
+ };
3515
+ } else {
3516
+ return null;
3517
+ }
3518
+ }
3519
+ // The entry Recently Visited would record for the current page, or null when
3520
+ // the page isn't eligible. Shared by the recorder and the debug panel so the
3521
+ // panel always shows what actually gets stored.
3522
+ function useCurrentVisit() {
3523
+ const {
3524
+ recently_visitable: title,
3525
+ resource
3526
+ } = usePcoMeta();
3527
+ const metadata = useRecentlyVisitableMetadata();
3528
+ return React.useMemo(() => visitFromPage({
3529
+ metadata,
3530
+ resource,
3531
+ title
3532
+ }), [metadata, resource, title]);
3533
+ }
3534
+ // Products hand the person's ID to AppsProvider, not to the topbar itself,
3535
+ // and several pass it as a number.
3536
+ function useCurrentPersonId() {
3537
+ const {
3538
+ userId
3539
+ } = useStore();
3540
+ return userId == null ? undefined : String(userId);
3541
+ }
3542
+
3543
+ // Per-origin localStorage is a stopgap: each product keeps its own list until
3544
+ // PCA-409 moves this to storage shared across product subdomains. Only this
3545
+ // file should need to change when it does.
3546
+ function storageKey({
3547
+ env,
3548
+ personId
3549
+ }) {
3550
+ return `topbar.recently_visited.${env}.${personId}`;
3551
+ }
3552
+ // localStorage doesn't notify the tab that wrote it, so readers in this tab
3553
+ // learn about new visits from this counter instead.
3554
+ const changes = createStore({
3555
+ version: 0
3556
+ });
3557
+ function readRecentlyVisited(scope) {
3558
+ try {
3559
+ return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
3560
+ } catch {
3561
+ return [];
3562
+ }
3563
+ }
3564
+ function recordVisit(visit, scope) {
3565
+ if (isEntry(visit)) {
3566
+ try {
3567
+ localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
3568
+ changes.setState({
3569
+ version: changes.getState().version + 1
3570
+ });
3571
+ } catch {
3572
+ // Storage can be full or blocked; recents are disposable.
3573
+ }
3574
+ }
3575
+ }
3576
+ function useRecentlyVisited({
3577
+ env,
3578
+ personId
3579
+ }) {
3580
+ const version = shim.useSyncExternalStore(changes.subscribe, () => changes.getState().version);
3581
+ // `version` isn't read, but listing it re-reads storage after each visit.
3582
+ return React.useMemo(() => personId ? readRecentlyVisited({
3583
+ env,
3584
+ personId
3585
+ }) : [], [env, personId, version]);
3586
+ }
3587
+
3588
+ // The versions home-mobile pins, so web and mobile read the same response
3589
+ // shapes. Some endpoints only exist from a version on: Registrations' me,
3590
+ // product_search_results, and pcrl_lookups need 2025-05-01.
3591
+ const API_VERSIONS = {
3592
+ calendar: "2022-07-07",
3593
+ "check-ins": "2025-05-28",
3594
+ giving: "2019-10-18",
3595
+ groups: "2023-07-10",
3596
+ home: "2018-08-01",
3597
+ people: "2023-03-21",
3598
+ publishing: "2024-03-25",
3599
+ registrations: "2025-05-01",
3600
+ services: "2018-11-01"
3601
+ };
3602
+ // For a `/~api/{product}/v2/...` URL, the header that pins its product's
3603
+ // version.
3604
+ function apiVersionHeaders(url) {
3605
+ const [,, product] = new URL(url).pathname.split("/");
3606
+ const version = API_VERSIONS[product];
3607
+ if (version) {
3608
+ return {
3609
+ "X-PCO-API-Version": version
3610
+ };
3611
+ } else {
3612
+ return {};
3613
+ }
3614
+ }
3615
+
3616
+ const CACHE_TTL_MS = 60000;
3617
+ const MAX_ENTRIES = 50;
3618
+ const cache = new Map();
3619
+ function cachedRequest(url) {
3620
+ const cached = cache.get(url);
3621
+ if (cached && cached.expiresAt > Date.now()) {
3622
+ // Re-inserting keeps the Map in recently-used order for eviction.
3623
+ cache.delete(url);
3624
+ cache.set(url, cached);
3625
+ return cached.promise;
3626
+ } else {
3627
+ return fetchIntoCache(url);
3628
+ }
3629
+ }
3630
+ function fetchIntoCache(url) {
3631
+ const promise = apiRequest(defaultFetch, url, {
3632
+ headers: apiVersionHeaders(url)
3633
+ }).then(({
3634
+ json,
3635
+ ok
3636
+ }) => {
3637
+ if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3638
+ return json;
3639
+ });
3640
+ // In-flight entries never expire, so every caller shares the one fetch.
3641
+ const entry = {
3642
+ expiresAt: Infinity,
3643
+ promise
3644
+ };
3645
+ cache.set(url, entry);
3646
+ promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3647
+ if (cache.size > MAX_ENTRIES) cache.delete(cache.keys().next().value);
3648
+ return promise;
3649
+ }
3650
+ function useCachedRequest(url) {
3651
+ const [settled, setSettled] = React.useState();
3652
+ React.useEffect(() => {
3653
+ if (url) {
3654
+ let current = true;
3655
+ cachedRequest(url).then(data => current && setSettled({
3656
+ data,
3657
+ url
3658
+ }), () => current && setSettled({
3659
+ failed: true,
3660
+ url
3661
+ }));
3662
+ return () => {
3663
+ current = false;
3664
+ };
3665
+ } else {
3666
+ return undefined;
3667
+ }
3668
+ }, [url]);
3669
+ if (!url) {
3670
+ return {
3671
+ status: "idle"
3672
+ };
3673
+ } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3674
+ return {
3675
+ data: settled && "data" in settled ? settled.data : undefined,
3676
+ status: "loading"
3677
+ };
3678
+ } else if ("data" in settled) {
3679
+ return {
3680
+ data: settled.data,
3681
+ status: "done"
3682
+ };
3683
+ } else {
3684
+ return {
3685
+ status: "error"
3686
+ };
3687
+ }
3688
+ }
3689
+ // One request per URL, for callers that fan out to a varying number of
3690
+ // endpoints. Unlike `useCachedRequest`, a URL that hasn't settled yet has no
3691
+ // earlier data to fall back on.
3692
+ function useCachedRequests(urls) {
3693
+ const [settled, setSettled] = React.useState({});
3694
+ // Keyed by value because each settle re-renders the caller, and a caller
3695
+ // that builds a fresh `urls` array on render would otherwise loop forever.
3696
+ const key = urls.join("\n");
3697
+ React.useEffect(() => {
3698
+ let current = true;
3699
+ const settle = result => current && setSettled(prev => ({
3700
+ ...prev,
3701
+ [result.url]: result
3702
+ }));
3703
+ for (const url of key.split("\n").filter(Boolean)) {
3704
+ cachedRequest(url).then(data => settle({
3705
+ data,
3706
+ url
3707
+ }), () => settle({
3708
+ failed: true,
3709
+ url
3710
+ }));
3711
+ }
3712
+ return () => {
3713
+ current = false;
3714
+ };
3715
+ }, [key]);
3716
+ return urls.map(url => {
3717
+ const result = settled[url];
3718
+ if (!result) {
3719
+ return {
3720
+ status: "loading"
3721
+ };
3722
+ } else if ("data" in result) {
3723
+ return {
3724
+ data: result.data,
3725
+ status: "done"
3726
+ };
3727
+ } else {
3728
+ return {
3729
+ status: "error"
3730
+ };
3731
+ }
3732
+ });
3733
+ }
3734
+
3735
+ const pcoUrl = pcoUrl$1;
3736
+
3737
+ // Resolves resource entries with one pcrl_lookups request per product.
3738
+ // pcrl_lookups reports forbidden and deleted resources the same way, so both
3739
+ // become `unresolved`, as does every entry in a batch whose request failed.
3740
+ function useHydratedRecentlyVisited(entries, env) {
3741
+ const batches = React.useMemo(() => batchByProduct(entries), [entries]);
3742
+ const urls = React.useMemo(() => batches.map(([product, pcrls]) => pcrlLookupsUrl(product, pcrls)), [batches]);
3743
+ const requests = useCachedRequests(urls);
3744
+ const lookups = new Map();
3745
+ batches.forEach(([, pcrls], index) => {
3746
+ const request = requests[index];
3747
+ for (const pcrl of pcrls) lookups.set(pcrl, lookupFor(pcrl, request));
3748
+ });
3749
+ return entries.map(entry => {
3750
+ var _a;
3751
+ if (entry.type === "page") {
3752
+ return {
3753
+ entry,
3754
+ status: "page",
3755
+ url: pageUrl(entry, env)
3756
+ };
3757
+ } else {
3758
+ const lookup = (_a = lookups.get(entry.pcrl)) !== null && _a !== void 0 ? _a : {
3759
+ status: "unresolved"
3760
+ };
3761
+ if (lookup.status === "resolved") {
3762
+ return {
3763
+ ...lookup,
3764
+ entry,
3765
+ url: lookup.result.attributes.url
3766
+ };
3767
+ } else {
3768
+ return {
3769
+ entry,
3770
+ status: lookup.status
3771
+ };
3772
+ }
3773
+ }
3774
+ });
3775
+ }
3776
+ function lookupFor(pcrl, request) {
3777
+ if (request.status === "done") {
3778
+ const result = request.data.data.find(({
3779
+ attributes
3780
+ }) => attributes.pcrl === pcrl);
3781
+ return result ? {
3782
+ result,
3783
+ status: "resolved"
3784
+ } : {
3785
+ status: "unresolved"
3786
+ };
3787
+ } else if (request.status === "error") {
3788
+ return {
3789
+ status: "unresolved"
3790
+ };
3791
+ } else {
3792
+ return {
3793
+ status: "loading"
3794
+ };
3795
+ }
3796
+ }
3797
+ const RECENTLY_VISITED_PAGE_SIZE = 10;
3798
+ // A PCRL can only be looked up through its own product's API, which may not be
3799
+ // the product it was visited in. Each page of entries is batched on its own,
3800
+ // so loading the next page leaves earlier pages' URLs, and so their cache
3801
+ // keys, alone. Sorting keeps them stable as a page reorders.
3802
+ function batchByProduct(entries) {
3803
+ var _a;
3804
+ const batches = [];
3805
+ for (let start = 0; start < entries.length; start += RECENTLY_VISITED_PAGE_SIZE) {
3806
+ const page = new Map();
3807
+ for (const entry of entries.slice(start, start + RECENTLY_VISITED_PAGE_SIZE)) {
3808
+ if (entry.type === "resource") {
3809
+ const [product] = entry.pcrl.split("/");
3810
+ page.set(product, [...((_a = page.get(product)) !== null && _a !== void 0 ? _a : []), entry.pcrl]);
3811
+ }
3812
+ }
3813
+ for (const [product, pcrls] of page) {
3814
+ batches.push([product, [...pcrls].sort()]);
3815
+ }
3816
+ }
3817
+ return batches;
3818
+ }
3819
+ function pcrlLookupsUrl(product, pcrls) {
3820
+ const params = new URLSearchParams({
3821
+ "where[pcrl]": pcrls.join(",")
3822
+ });
3823
+ return `${window.location.origin}/~api/${product}/v2/pcrl_lookups?${params}`;
3824
+ }
3825
+ // Built from the product rather than the current origin, so it stays right
3826
+ // once PCA-409 shares one list across product subdomains.
3827
+ function pageUrl(entry, env) {
3828
+ return `${pcoUrl(env)(entry.product)}${entry.path}`;
3829
+ }
3830
+
3332
3831
  var styles$a = {"result":"result-WCnxr"};
3333
3832
 
3334
3833
  function SearchResult({
3335
3834
  highlighted,
3336
3835
  id,
3337
3836
  onHighlight,
3338
- result
3837
+ subtitle,
3838
+ title,
3839
+ url
3339
3840
  }) {
3340
- const {
3341
- metadata,
3342
- title,
3343
- url
3344
- } = result.attributes;
3345
3841
  const ref = React.useRef(null);
3346
3842
  React.useEffect(() => {
3347
3843
  var _a;
@@ -3362,10 +3858,7 @@ function SearchResult({
3362
3858
  ref: ref,
3363
3859
  role: "option",
3364
3860
  tabIndex: -1
3365
- }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, summarize(metadata)));
3366
- }
3367
- function summarize(metadata) {
3368
- return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3861
+ }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, subtitle));
3369
3862
  }
3370
3863
 
3371
3864
  const resultId = (listboxId, index) => `${listboxId}-result-${index}`;
@@ -3437,83 +3930,127 @@ function ResultGroups({
3437
3930
  id: resultId(id, resultIndex),
3438
3931
  key: result.id,
3439
3932
  onHighlight: () => onHighlight(resultIndex),
3440
- result: result
3933
+ subtitle: summarize(result.attributes.metadata),
3934
+ title: result.attributes.title,
3935
+ url: result.attributes.url
3441
3936
  });
3442
3937
  }));
3443
3938
  }));
3444
3939
  }
3940
+ function summarize(metadata) {
3941
+ return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3942
+ }
3445
3943
 
3446
- const CACHE_TTL_MS = 60000;
3447
- const MAX_ENTRIES$1 = 50;
3448
- const cache = new Map();
3449
- function cachedRequest(url) {
3450
- const cached = cache.get(url);
3451
- if (cached && cached.expiresAt > Date.now()) {
3452
- // Re-inserting keeps the Map in recently-used order for eviction.
3453
- cache.delete(url);
3454
- cache.set(url, cached);
3455
- return cached.promise;
3944
+ function useRecentVisits(env) {
3945
+ const personId = useCurrentPersonId();
3946
+ const entries = useRecentlyVisited({
3947
+ env,
3948
+ personId
3949
+ });
3950
+ const [count, setCount] = React.useState(RECENTLY_VISITED_PAGE_SIZE);
3951
+ const shown = React.useMemo(() => entries.slice(0, count), [entries, count]);
3952
+ const hydrated = useHydratedRecentlyVisited(shown, env);
3953
+ // Resources show up once they're found. Ones that can't be found anymore,
3954
+ // or that the person can no longer see, are left out.
3955
+ const visits = hydrated.filter(isShown);
3956
+ const loading = hydrated.some(({
3957
+ status
3958
+ }) => status === "loading");
3959
+ const showMore = React.useCallback(() => setCount(current => current + RECENTLY_VISITED_PAGE_SIZE), []);
3960
+ return {
3961
+ count,
3962
+ hasMore: entries.length > count,
3963
+ loading,
3964
+ showMore,
3965
+ visits
3966
+ };
3967
+ }
3968
+ function isShown(visit) {
3969
+ return visit.status === "page" || visit.status === "resolved";
3970
+ }
3971
+ function RecentlyVisited({
3972
+ count,
3973
+ hasMore,
3974
+ highlightedIndex,
3975
+ id,
3976
+ loading,
3977
+ onHighlight,
3978
+ showMore,
3979
+ visits
3980
+ }) {
3981
+ const headingId = `${id}-heading`;
3982
+ if (visits.length === 0 && !hasMore && !loading) {
3983
+ return /*#__PURE__*/React.createElement("p", null, "Pages you visit will show up here.");
3456
3984
  } else {
3457
- return fetchIntoCache(url);
3985
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
3986
+ id: headingId
3987
+ }, "Recently visited"), /*#__PURE__*/React.createElement("div", {
3988
+ "aria-labelledby": headingId,
3989
+ id: id,
3990
+ role: "listbox"
3991
+ }, visits.map((visit, index) => (/*#__PURE__*/React.createElement(SearchResult, {
3992
+ highlighted: index === highlightedIndex,
3993
+ id: resultId(id, index),
3994
+ key: entryKey(visit.entry),
3995
+ onHighlight: () => onHighlight(index),
3996
+ ...describeVisit(visit)
3997
+ })))), hasMore && /*#__PURE__*/React.createElement(ShowMoreWhenVisible, {
3998
+ key: count,
3999
+ onVisible: showMore
4000
+ }));
3458
4001
  }
3459
4002
  }
3460
- function fetchIntoCache(url) {
3461
- const promise = apiRequest(defaultFetch, url).then(({
3462
- json,
3463
- ok
3464
- }) => {
3465
- if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3466
- return json;
4003
+ function ShowMoreWhenVisible({
4004
+ onVisible
4005
+ }) {
4006
+ const ref = React.useRef(null);
4007
+ React.useEffect(() => {
4008
+ const observer = new IntersectionObserver(([entry]) => {
4009
+ if (entry.isIntersecting) onVisible();
4010
+ });
4011
+ if (ref.current) observer.observe(ref.current);
4012
+ return () => observer.disconnect();
4013
+ }, [onVisible]);
4014
+ return /*#__PURE__*/React.createElement("div", {
4015
+ ref: ref
3467
4016
  });
3468
- // In-flight entries never expire, so every caller shares the one fetch.
3469
- const entry = {
3470
- expiresAt: Infinity,
3471
- promise
3472
- };
3473
- cache.set(url, entry);
3474
- promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3475
- if (cache.size > MAX_ENTRIES$1) cache.delete(cache.keys().next().value);
3476
- return promise;
3477
4017
  }
3478
- function useCachedRequest(url) {
3479
- const [settled, setSettled] = React.useState();
3480
- React.useEffect(() => {
3481
- if (url) {
3482
- let current = true;
3483
- cachedRequest(url).then(data => current && setSettled({
3484
- data,
3485
- url
3486
- }), () => current && setSettled({
3487
- failed: true,
3488
- url
3489
- }));
3490
- return () => {
3491
- current = false;
3492
- };
3493
- } else {
3494
- return undefined;
3495
- }
3496
- }, [url]);
3497
- if (!url) {
3498
- return {
3499
- status: "idle"
3500
- };
3501
- } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3502
- return {
3503
- data: settled && "data" in settled ? settled.data : undefined,
3504
- status: "loading"
3505
- };
3506
- } else if ("data" in settled) {
4018
+ // Unlike search results, recents come from every product, so each one names
4019
+ // its product and type.
4020
+ function describeVisit(visit) {
4021
+ const subtitle = (...parts) => parts.filter(Boolean).join(" · ");
4022
+ if (visit.status === "page") {
4023
+ const {
4024
+ details,
4025
+ product,
4026
+ title
4027
+ } = visit.entry;
3507
4028
  return {
3508
- data: settled.data,
3509
- status: "done"
4029
+ subtitle: subtitle(productName(product), details),
4030
+ title,
4031
+ url: visit.url
3510
4032
  };
3511
4033
  } else {
4034
+ const {
4035
+ metadata,
4036
+ product,
4037
+ title,
4038
+ type
4039
+ } = visit.result.attributes;
3512
4040
  return {
3513
- status: "error"
4041
+ subtitle: subtitle(productName(product), type, summarize(metadata)),
4042
+ title,
4043
+ url: visit.url
3514
4044
  };
3515
4045
  }
3516
4046
  }
4047
+ // check-ins → Check-Ins
4048
+ function productName(slug) {
4049
+ return slug.split("-").map(capitalize).join("-");
4050
+ }
4051
+ function capitalize(word) {
4052
+ return word.charAt(0).toUpperCase() + word.slice(1);
4053
+ }
3517
4054
 
3518
4055
  const SEARCH_DEBOUNCE_MS = 300;
3519
4056
  function useProductSearch(product, term) {
@@ -3604,15 +4141,16 @@ const verticalStep = key => ({
3604
4141
  ArrowDown: 1,
3605
4142
  ArrowUp: -1
3606
4143
  })[key];
3607
- function useResultNavigation(results) {
3608
- // Remembering which results the index belongs to puts the highlight back on
3609
- // the first result whenever new ones arrive, without an effect to reset it.
4144
+ // Remembering which list the index belongs to puts the highlight back on the
4145
+ // first option whenever a new list replaces it, without an effect to reset it.
4146
+ // A list that grows or fills in under the same `list` keeps its highlight.
4147
+ function useResultNavigation(options, list) {
3610
4148
  const [highlight, setHighlight] = React.useState({
3611
4149
  index: 0,
3612
- results
4150
+ list
3613
4151
  });
3614
- const highlightedIndex = highlight.results === results ? highlight.index : 0;
3615
- const highlighted = results[highlightedIndex];
4152
+ const highlightedIndex = highlight.list === list ? highlight.index : 0;
4153
+ const highlighted = options[highlightedIndex];
3616
4154
  const [copiedUrl, setCopiedUrl] = React.useState(null);
3617
4155
  React.useEffect(() => {
3618
4156
  if (copiedUrl) {
@@ -3622,30 +4160,30 @@ function useResultNavigation(results) {
3622
4160
  return undefined;
3623
4161
  }
3624
4162
  }, [copiedUrl]);
3625
- const highlightIndex = React.useCallback(index => setHighlight(current => current.results === results && current.index === index ? current : {
4163
+ const highlightIndex = React.useCallback(index => setHighlight(current => current.list === list && current.index === index ? current : {
3626
4164
  index,
3627
- results
3628
- }), [results]);
4165
+ list
4166
+ }), [list]);
3629
4167
  const handleKeyDown = event => {
3630
4168
  const step = verticalStep(event.key);
3631
- if (step && results.length > 0) {
4169
+ if (step && options.length > 0) {
3632
4170
  event.preventDefault();
3633
- highlightIndex((highlightedIndex + step + results.length) % results.length);
4171
+ highlightIndex((highlightedIndex + step + options.length) % options.length);
3634
4172
  } else if (event.key === "Enter" && highlighted && !event.nativeEvent.isComposing) {
3635
4173
  event.preventDefault();
3636
- open(highlighted.attributes.url, {
4174
+ open(highlighted.url, {
3637
4175
  newTab: hasCommandKey(event)
3638
4176
  });
3639
4177
  } else if (isCopyShortcut(event) && highlighted && !hasSelectedText(event.currentTarget)) {
3640
4178
  event.preventDefault();
3641
4179
  const {
3642
4180
  url
3643
- } = highlighted.attributes;
4181
+ } = highlighted;
3644
4182
  navigator.clipboard.writeText(url).then(() => setCopiedUrl(url));
3645
4183
  }
3646
4184
  };
3647
4185
  return {
3648
- copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.attributes.url),
4186
+ copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.url),
3649
4187
  handleKeyDown,
3650
4188
  highlightIndex,
3651
4189
  highlighted,
@@ -3670,29 +4208,66 @@ function hasSelectedText(element) {
3670
4208
  }
3671
4209
 
3672
4210
  const SEARCHABLE_PRODUCTS = [{
4211
+ appName: "Calendar",
4212
+ slug: "calendar"
4213
+ }, {
4214
+ appName: "Check-Ins",
4215
+ slug: "check-ins"
4216
+ }, {
4217
+ appName: "Giving",
4218
+ slug: "giving"
4219
+ }, {
3673
4220
  appName: "Groups",
3674
4221
  slug: "groups"
3675
4222
  }, {
3676
4223
  appName: "People",
3677
4224
  slug: "people"
4225
+ }, {
4226
+ appName: "Publishing",
4227
+ slug: "publishing"
4228
+ }, {
4229
+ appName: "Registrations",
4230
+ slug: "registrations"
3678
4231
  }, {
3679
4232
  appName: "Services",
3680
4233
  slug: "services"
3681
4234
  }];
3682
4235
  const PERMISSION_CHECKS = {
4236
+ "check-ins": () => fetchMe("check-ins", "permission").then(({
4237
+ permission
4238
+ }) => permission !== null),
4239
+ giving: personId => {
4240
+ if (personId === undefined) {
4241
+ return Promise.reject(new Error("No person to check Giving access for"));
4242
+ } else {
4243
+ return fetchAttributes(`giving/v2/person_permissions/${personId}`, {
4244
+ "fields[PersonPermissions]": "can_search"
4245
+ }).then(({
4246
+ can_search
4247
+ }) => can_search);
4248
+ }
4249
+ },
3683
4250
  people: () => fetchMe("people", "people_permissions,site_administrator").then(({
3684
4251
  people_permissions,
3685
4252
  site_administrator
3686
4253
  }) => site_administrator || people_permissions !== null),
4254
+ registrations: () => fetchMe("registrations", "can_search").then(({
4255
+ can_search
4256
+ }) => can_search),
3687
4257
  services: () => fetchMe("services", "can_search").then(({
3688
4258
  can_search
3689
4259
  }) => can_search)
3690
4260
  };
3691
4261
  function fetchMe(product, fields) {
3692
- const params = new URLSearchParams({
4262
+ return fetchAttributes(`${product}/v2/me`, {
3693
4263
  "fields[Person]": fields
3694
4264
  });
3695
- return apiRequest(defaultFetch, `${window.location.origin}/~api/${product}/v2/me?${params}`).then(({
4265
+ }
4266
+ function fetchAttributes(path, params) {
4267
+ const url = `${window.location.origin}/~api/${path}?${new URLSearchParams(params)}`;
4268
+ return apiRequest(defaultFetch, url, {
4269
+ headers: apiVersionHeaders(url)
4270
+ }).then(({
3696
4271
  json,
3697
4272
  ok
3698
4273
  }) => {
@@ -3703,7 +4278,7 @@ function fetchMe(product, fields) {
3703
4278
  // Permissions rarely change mid-session, so a successful check is kept until
3704
4279
  // the page reloads. A failed one is dropped so a remount tries again.
3705
4280
  const permissionCache = new Map();
3706
- function canSearch(slug) {
4281
+ function canSearch(slug, personId) {
3707
4282
  const check = PERMISSION_CHECKS[slug];
3708
4283
  const cached = permissionCache.get(slug);
3709
4284
  if (!check) {
@@ -3711,7 +4286,7 @@ function canSearch(slug) {
3711
4286
  } else if (cached) {
3712
4287
  return cached;
3713
4288
  } else {
3714
- const promise = check().catch(() => {
4289
+ const promise = check(personId).catch(() => {
3715
4290
  permissionCache.delete(slug);
3716
4291
  return false;
3717
4292
  });
@@ -3725,16 +4300,18 @@ function useSearchableProducts(apps) {
3725
4300
  const accessibleKey = inSwitcherOrder(apps).map(({
3726
4301
  slug
3727
4302
  }) => slug).join(",");
4303
+ const personId = useCurrentPersonId();
3728
4304
  const [settled, setSettled] = React.useState();
3729
4305
  React.useEffect(() => {
3730
4306
  let current = true;
3731
4307
  const accessible = accessibleKey.split(",").flatMap(slug => SEARCHABLE_PRODUCTS.filter(product => product.slug === slug));
3732
4308
  Promise.all(accessible.map(({
3733
4309
  slug
3734
- }) => canSearch(slug))).then(allowed => {
4310
+ }) => canSearch(slug, personId))).then(allowed => {
3735
4311
  if (current) {
3736
4312
  setSettled({
3737
4313
  accessibleKey,
4314
+ personId,
3738
4315
  products: accessible.filter((_, index) => allowed[index])
3739
4316
  });
3740
4317
  }
@@ -3742,8 +4319,8 @@ function useSearchableProducts(apps) {
3742
4319
  return () => {
3743
4320
  current = false;
3744
4321
  };
3745
- }, [accessibleKey]);
3746
- if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey) {
4322
+ }, [accessibleKey, personId]);
4323
+ if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey && settled.personId === personId) {
3747
4324
  return {
3748
4325
  products: settled.products,
3749
4326
  status: "done"
@@ -3764,11 +4341,13 @@ const isLegacySearchShortcut = event => event.key === "/" && !event.altKey && !e
3764
4341
  const isTypingKey = event => event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey;
3765
4342
  const isTypingIn = target => target instanceof Element && target.closest('input, select, textarea, [contenteditable]:not([contenteditable="false"])') !== null;
3766
4343
  function ProductSearch({
3767
- apps
4344
+ apps,
4345
+ env
3768
4346
  }) {
3769
4347
  const searchable = useSearchableProducts(apps);
3770
4348
  if (searchable.status === "done" && searchable.products.length > 0) {
3771
4349
  return /*#__PURE__*/React.createElement(SearchButtonAndDialog, {
4350
+ env: env,
3772
4351
  products: searchable.products
3773
4352
  });
3774
4353
  } else {
@@ -3776,6 +4355,7 @@ function ProductSearch({
3776
4355
  }
3777
4356
  }
3778
4357
  function SearchButtonAndDialog({
4358
+ env,
3779
4359
  products
3780
4360
  }) {
3781
4361
  const [open, setOpen] = React.useState(false);
@@ -3813,10 +4393,15 @@ function SearchButtonAndDialog({
3813
4393
  onClose: () => setOpen(false),
3814
4394
  open: open
3815
4395
  }, /*#__PURE__*/React.createElement(SearchPanel, {
4396
+ env: env,
3816
4397
  products: products
3817
4398
  })));
3818
4399
  }
4400
+ // Keys the recents list for navigation, so the highlight stays put as more
4401
+ // recents load and as their lookups answer.
4402
+ const RECENTS = "recents";
3819
4403
  function SearchPanel({
4404
+ env,
3820
4405
  products
3821
4406
  }) {
3822
4407
  var _a, _b;
@@ -3833,7 +4418,23 @@ function SearchPanel({
3833
4418
  var _a;
3834
4419
  return (_a = groups === null || groups === void 0 ? void 0 : groups.flatMap(group => group.results)) !== null && _a !== void 0 ? _a : [];
3835
4420
  }, [groups]);
3836
- const navigation = useResultNavigation(results);
4421
+ const recents = useRecentVisits(env);
4422
+ const options = hasTerm ? results.map(({
4423
+ attributes
4424
+ }) => ({
4425
+ label: attributes.title,
4426
+ url: attributes.url
4427
+ })) : recents.visits.map(visit => {
4428
+ const {
4429
+ title,
4430
+ url
4431
+ } = describeVisit(visit);
4432
+ return {
4433
+ label: title,
4434
+ url
4435
+ };
4436
+ });
4437
+ const navigation = useResultNavigation(options, hasTerm ? results : RECENTS);
3837
4438
  const {
3838
4439
  highlightedIndex
3839
4440
  } = navigation;
@@ -3859,8 +4460,8 @@ function SearchPanel({
3859
4460
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("input", {
3860
4461
  "aria-activedescendant": highlightedIndex === null ? undefined : resultId(listboxId, highlightedIndex),
3861
4462
  "aria-autocomplete": "list",
3862
- "aria-controls": results.length > 0 ? listboxId : undefined,
3863
- "aria-expanded": results.length > 0,
4463
+ "aria-controls": options.length > 0 ? listboxId : undefined,
4464
+ "aria-expanded": options.length > 0,
3864
4465
  "aria-label": "Search",
3865
4466
  autoFocus: true,
3866
4467
  onChange: event => setTerm(event.target.value),
@@ -3876,17 +4477,22 @@ function SearchPanel({
3876
4477
  onSelect: setSelectedSlug,
3877
4478
  products: products,
3878
4479
  selected: product
3879
- }, /*#__PURE__*/React.createElement(SearchResults, {
4480
+ }, hasTerm ? (/*#__PURE__*/React.createElement(SearchResults, {
3880
4481
  highlightedIndex: highlightedIndex,
3881
4482
  id: listboxId,
3882
4483
  label: `${product.appName} results`,
3883
4484
  onHighlight: navigation.highlightIndex,
3884
4485
  search: search,
3885
4486
  term: term
3886
- })), /*#__PURE__*/React.createElement("div", {
4487
+ })) : (/*#__PURE__*/React.createElement(RecentlyVisited, {
4488
+ highlightedIndex: highlightedIndex,
4489
+ id: listboxId,
4490
+ onHighlight: navigation.highlightIndex,
4491
+ ...recents
4492
+ }))), /*#__PURE__*/React.createElement("div", {
3887
4493
  "aria-live": "polite",
3888
4494
  className: styles$c.visuallyHidden
3889
- }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.attributes.title : null), /*#__PURE__*/React.createElement(KeyboardHints, {
4495
+ }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.label : null), /*#__PURE__*/React.createElement(KeyboardHints, {
3890
4496
  copied: navigation.copied
3891
4497
  }));
3892
4498
  }
@@ -3905,249 +4511,6 @@ function initialProduct(products) {
3905
4511
  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];
3906
4512
  }
3907
4513
 
3908
- function parseValue(element) {
3909
- const datatype = element.getAttribute("datatype");
3910
- switch (datatype) {
3911
- case "boolean":
3912
- return element.content === "true" || element.content === "1";
3913
- default:
3914
- return element.content;
3915
- }
3916
- }
3917
- function getAllPcoMeta() {
3918
- const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3919
- const meta = {};
3920
- elements.forEach(e => {
3921
- const property = e.getAttribute("property");
3922
- meta[property.substring("pco:".length)] = parseValue(e);
3923
- });
3924
- return meta;
3925
- }
3926
- // usePcoMeta only reads each tag's content. Recently Visited also needs the
3927
- // pco:recently_visitable tag's `metadata` attribute, which can change while
3928
- // the content stays the same, so it is tracked in its own store.
3929
- function getRecentlyVisitableMetadata() {
3930
- var _a, _b;
3931
- 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;
3932
- }
3933
- const store = createStore(getAllPcoMeta());
3934
- const recentlyVisitableMetadataStore = createStore({
3935
- metadata: getRecentlyVisitableMetadata()
3936
- });
3937
- function sync() {
3938
- const nextState = getAllPcoMeta();
3939
- if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3940
- const metadata = getRecentlyVisitableMetadata();
3941
- if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3942
- recentlyVisitableMetadataStore.setState({
3943
- metadata
3944
- });
3945
- }
3946
- }
3947
- const observer = new MutationObserver(sync);
3948
- let hasTriggeredObserve = false;
3949
- function observe() {
3950
- if (hasTriggeredObserve) return;
3951
- hasTriggeredObserve = true;
3952
- // The module-load snapshot above can predate meta tags that appear later
3953
- // in <head> (e.g. pco:resource, yielded right before </head>) if this
3954
- // module's own script tag isn't deferred and runs mid-parse. Those tags
3955
- // never mutate afterward, so the observer alone would never catch them.
3956
- // Re-read once now, since observe() is only called once a consumer is
3957
- // actually mounting, which is always after the document has fully parsed.
3958
- sync();
3959
- observer.observe(document.head, {
3960
- attributes: true,
3961
- childList: true,
3962
- subtree: true
3963
- });
3964
- }
3965
- function usePcoMeta() {
3966
- observe();
3967
- return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3968
- }
3969
- function useRecentlyVisitableMetadata() {
3970
- observe();
3971
- return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3972
- }
3973
-
3974
- const MAX_ENTRIES = 20;
3975
- const FORMAT_VERSION = 1;
3976
- const MAX_TEXT_LENGTH = 200;
3977
- const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3978
- function isId(value) {
3979
- return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3980
- }
3981
- // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3982
- function isPcrl(value) {
3983
- const segments = value.split("/");
3984
- return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3985
- }
3986
- function isNonBlankString(value) {
3987
- return typeof value === "string" && value.trim() !== "";
3988
- }
3989
- function isRecord(value) {
3990
- return typeof value === "object" && value !== null && !Array.isArray(value);
3991
- }
3992
- function entryKey(entry) {
3993
- if (entry.type === "resource") {
3994
- return `resource:${entry.pcrl}`;
3995
- } else {
3996
- return `page:${entry.product}:${entry.path}`;
3997
- }
3998
- }
3999
- function isEntry(value) {
4000
- if (!isRecord(value)) {
4001
- return false;
4002
- } else if (value.type === "resource") {
4003
- return typeof value.pcrl === "string" && isPcrl(value.pcrl);
4004
- } else if (value.type === "page") {
4005
- return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
4006
- } else {
4007
- return false;
4008
- }
4009
- }
4010
- // Cuts on code points so a surrogate pair is never split.
4011
- function clampText(value) {
4012
- return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
4013
- }
4014
- function clampEntry(entry) {
4015
- if (entry.type === "resource") {
4016
- return entry;
4017
- } else {
4018
- return {
4019
- ...entry,
4020
- details: clampText(entry.details),
4021
- title: clampText(entry.title)
4022
- };
4023
- }
4024
- }
4025
- // Recents are disposable: anything unexpected reads as an empty list (or, for
4026
- // a single bad entry, a list without it) rather than throwing.
4027
- function parseRecentlyVisited(value, personId) {
4028
- let parsed = null;
4029
- try {
4030
- parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
4031
- } catch {
4032
- parsed = null;
4033
- }
4034
- if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
4035
- return parsed.entries.filter(isEntry);
4036
- } else {
4037
- return [];
4038
- }
4039
- }
4040
- function addVisit(entries, visit) {
4041
- const key = entryKey(visit);
4042
- return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES);
4043
- }
4044
- function serializeRecentlyVisited(entries, personId) {
4045
- return JSON.stringify({
4046
- entries,
4047
- person: personId,
4048
- v: FORMAT_VERSION
4049
- });
4050
- }
4051
-
4052
- // The product whose URL, in this environment, has the current page's host.
4053
- // Reversing pcoUrl handles every environment @planningcenter/url knows about,
4054
- // and guarantees the stored product rebuilds a working link.
4055
- function currentProduct() {
4056
- var _a;
4057
- return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
4058
- }
4059
- function detailsFromMetadata(metadata) {
4060
- var _a;
4061
- try {
4062
- const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
4063
- return typeof details === "string" ? details : "";
4064
- } catch {
4065
- return "";
4066
- }
4067
- }
4068
- // pco:resource is the more specific signal, so it wins when both tags are
4069
- // present. A resource that isn't a PCRL is ignored rather than recorded.
4070
- function visitFromPage({
4071
- metadata,
4072
- resource,
4073
- title
4074
- }) {
4075
- const product = currentProduct();
4076
- if (resource && isPcrl(resource)) {
4077
- return {
4078
- pcrl: resource,
4079
- type: "resource"
4080
- };
4081
- } else if (title && title.trim() !== "" && product) {
4082
- return {
4083
- details: detailsFromMetadata(metadata),
4084
- path: window.location.pathname,
4085
- product,
4086
- title,
4087
- type: "page"
4088
- };
4089
- } else {
4090
- return null;
4091
- }
4092
- }
4093
- // The entry Recently Visited would record for the current page, or null when
4094
- // the page isn't eligible. Shared by the recorder and the debug panel so the
4095
- // panel always shows what actually gets stored.
4096
- function useCurrentVisit() {
4097
- const {
4098
- recently_visitable: title,
4099
- resource
4100
- } = usePcoMeta();
4101
- const metadata = useRecentlyVisitableMetadata();
4102
- return React.useMemo(() => visitFromPage({
4103
- metadata,
4104
- resource,
4105
- title
4106
- }), [metadata, resource, title]);
4107
- }
4108
- // Products hand the person's ID to AppsProvider, not to the topbar itself,
4109
- // and several pass it as a number.
4110
- function useCurrentPersonId() {
4111
- const {
4112
- userId
4113
- } = useStore();
4114
- return userId == null ? undefined : String(userId);
4115
- }
4116
-
4117
- // Per-origin localStorage is a stopgap: each product keeps its own list until
4118
- // PCA-409 moves this to storage shared across product subdomains. Only this
4119
- // file should need to change when it does.
4120
- function storageKey({
4121
- env,
4122
- personId
4123
- }) {
4124
- return `topbar.recently_visited.${env}.${personId}`;
4125
- }
4126
- // localStorage doesn't notify the tab that wrote it, so readers in this tab
4127
- // learn about new visits from this counter instead.
4128
- const changes = createStore({
4129
- version: 0
4130
- });
4131
- function readRecentlyVisited(scope) {
4132
- try {
4133
- return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
4134
- } catch {
4135
- return [];
4136
- }
4137
- }
4138
- function recordVisit(visit, scope) {
4139
- if (isEntry(visit)) {
4140
- try {
4141
- localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
4142
- changes.setState({
4143
- version: changes.getState().version + 1
4144
- });
4145
- } catch {
4146
- // Storage can be full or blocked; recents are disposable.
4147
- }
4148
- }
4149
- }
4150
-
4151
4514
  function useRecordRecentlyVisited({
4152
4515
  enabled,
4153
4516
  env
@@ -4437,7 +4800,8 @@ const NotSmallTopbar = ({
4437
4800
  margin: "auto"
4438
4801
  }
4439
4802
  }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, {
4440
- apps: props.apps
4803
+ apps: props.apps,
4804
+ env: props.env
4441
4805
  })) : search({
4442
4806
  hideRoutes: () => setRoutesVisible(false),
4443
4807
  showRoutes: () => setRoutesVisible(true)
@@ -32570,7 +32934,7 @@ const SmallTopbar = ({
32570
32934
  setProductSwitcherVisible(false);
32571
32935
  setRoutesMenuVisible(visible => !visible);
32572
32936
  } : null
32573
- }, /*#__PURE__*/React.createElement(CurrentRouteComponent, null, capitalize(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React.createElement("button", {
32937
+ }, /*#__PURE__*/React.createElement(CurrentRouteComponent, null, capitalize$1(currentRouteText)), props.routes.length > 0 && /*#__PURE__*/React.createElement(DisclosureChevronIcon, null)), /*#__PURE__*/React.createElement("button", {
32574
32938
  type: "button",
32575
32939
  className: styles$5["profile-button"],
32576
32940
  onClick: () => {
@@ -32763,8 +33127,24 @@ function Field({
32763
33127
  className: styles$4["debug-pco-resource-field-value"]
32764
33128
  }, value));
32765
33129
  }
33130
+ // Only staff ghosting or impersonating a person get pco:staff-access-level,
33131
+ // which keeps the panel away from regular users on staging.
32766
33132
  function DebugPcoResource({
32767
33133
  env
33134
+ }) {
33135
+ const {
33136
+ "staff-access-level": staffAccessLevel
33137
+ } = usePcoMeta();
33138
+ if (isPresent(staffAccessLevel)) {
33139
+ return /*#__PURE__*/React.createElement(DebugPanel, {
33140
+ env: env
33141
+ });
33142
+ } else {
33143
+ return null;
33144
+ }
33145
+ }
33146
+ function DebugPanel({
33147
+ env
32768
33148
  }) {
32769
33149
  const {
32770
33150
  recently_visitable: recentlyVisitable,
@@ -32773,12 +33153,17 @@ function DebugPcoResource({
32773
33153
  const [lookup, setLookup] = React.useState({
32774
33154
  status: "idle"
32775
33155
  });
32776
- const hasResource = isPresent(resource);
33156
+ const visit = useCurrentVisit();
33157
+ const personId = useCurrentPersonId();
33158
+ const recentlyVisited = useRecentlyVisited({
33159
+ env,
33160
+ personId
33161
+ });
33162
+ const pcrl = (visit === null || visit === void 0 ? void 0 : visit.type) === "resource" ? visit.pcrl : null;
32777
33163
  const hasRecentlyVisitable = isPresent(recentlyVisitable);
33164
+ const hasInvalidResource = isPresent(resource) && !isPcrl(resource);
32778
33165
  React.useEffect(() => {
32779
- // pco:resource is the more specific signal, so it always wins when both
32780
- // tags are present.
32781
- const url = hasResource ? pcrlLookupUrl(resource, env) : null;
33166
+ const url = pcrl ? pcrlLookupUrl(pcrl, env) : null;
32782
33167
  if (!url) {
32783
33168
  setLookup({
32784
33169
  status: "idle"
@@ -32810,7 +33195,7 @@ function DebugPcoResource({
32810
33195
  return () => {
32811
33196
  cancelled = true;
32812
33197
  };
32813
- }, [resource, env, hasResource]);
33198
+ }, [pcrl, env]);
32814
33199
  return /*#__PURE__*/React.createElement("div", {
32815
33200
  className: classnames("staff-only", styles$4["debug-pco-resource"])
32816
33201
  }, /*#__PURE__*/React.createElement("div", {
@@ -32825,19 +33210,27 @@ function DebugPcoResource({
32825
33210
  }), /*#__PURE__*/React.createElement(Field, {
32826
33211
  label: "pco:recently_visitable",
32827
33212
  value: describeTag("recently_visitable", recentlyVisitable)
32828
- }), !hasResource && !hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33213
+ }), hasInvalidResource && (/*#__PURE__*/React.createElement("p", {
32829
33214
  className: styles$4["debug-pco-resource-note"]
32830
- }, "This page is not eligible for Recently Visited")), hasResource && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33215
+ }, "pco:resource isn't a PCRL (product/v2/type/id), so Recently Visited ignores it.")), visit === null && (/*#__PURE__*/React.createElement("p", {
32831
33216
  className: styles$4["debug-pco-resource-note"]
32832
- }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), hasResource && lookup.status === "loading" && (/*#__PURE__*/React.createElement("p", {
33217
+ }, "This page is not eligible for Recently Visited")), pcrl && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
32833
33218
  className: styles$4["debug-pco-resource-note"]
32834
- }, "Looking up resource\u2026")), hasResource && lookup.status === "error" && (/*#__PURE__*/React.createElement("p", {
33219
+ }, "Both tags are present \u2014 pco:resource takes precedence for the Recently Visited entry.")), pcrl && lookup.status === "loading" && (/*#__PURE__*/React.createElement("p", {
33220
+ className: styles$4["debug-pco-resource-note"]
33221
+ }, "Looking up resource\u2026")), pcrl && lookup.status === "error" && (/*#__PURE__*/React.createElement("p", {
33222
+ className: styles$4["debug-pco-resource-note"]
33223
+ }, lookup.message)), pcrl && lookup.status === "loaded" && (/*#__PURE__*/React.createElement("pre", {
33224
+ className: styles$4["debug-pco-resource-json"]
33225
+ }, JSON.stringify(lookup.json, null, 2))), (visit === null || visit === void 0 ? void 0 : visit.type) === "page" && (/*#__PURE__*/React.createElement("p", {
32835
33226
  className: styles$4["debug-pco-resource-note"]
32836
- }, lookup.message)), hasResource && lookup.status === "loaded" && (/*#__PURE__*/React.createElement("pre", {
33227
+ }, "Recently Visited title: ", visit.title)), /*#__PURE__*/React.createElement("span", {
33228
+ className: styles$4["debug-pco-resource-field-label"]
33229
+ }, "Recently Visited"), personId ? (/*#__PURE__*/React.createElement("pre", {
32837
33230
  className: styles$4["debug-pco-resource-json"]
32838
- }, JSON.stringify(lookup.json, null, 2))), !hasResource && hasRecentlyVisitable && (/*#__PURE__*/React.createElement("p", {
33231
+ }, JSON.stringify(recentlyVisited, null, 2))) : (/*#__PURE__*/React.createElement("p", {
32839
33232
  className: styles$4["debug-pco-resource-note"]
32840
- }, "Recently Visited title: ", recentlyVisitable)));
33233
+ }, "No current person, so nothing is recorded.")));
32841
33234
  }
32842
33235
 
32843
33236
  var styles$3 = {"drawer-wrap":"drawer-wrap-J0rSL","is-visible":"is-visible-KTME8","drawer":"drawer-j7f4l"};
@@ -33042,15 +33435,50 @@ const SwitchView = ({
33042
33435
  }));
33043
33436
  };
33044
33437
 
33438
+ const collections = {
33439
+ general: general__namespace,
33440
+ toolbar: toolbar__namespace
33441
+ };
33442
+ const paths = collections;
33443
+ function Icon({
33444
+ name,
33445
+ size = 16,
33446
+ style,
33447
+ theme,
33448
+ title,
33449
+ ...props
33450
+ }) {
33451
+ const [collection, symbol] = name.split(".");
33452
+ return /*#__PURE__*/React.createElement("svg", {
33453
+ "aria-hidden": title ? undefined : true,
33454
+ fill: "currentColor",
33455
+ height: size,
33456
+ role: title ? "img" : undefined,
33457
+ style: {
33458
+ color: theme && `var(${theme})`,
33459
+ flexShrink: 0,
33460
+ ...style
33461
+ },
33462
+ viewBox: "0 0 16 16",
33463
+ width: size,
33464
+ ...props
33465
+ }, title && /*#__PURE__*/React.createElement("title", null, title), /*#__PURE__*/React.createElement("path", {
33466
+ d: paths[collection][symbol]
33467
+ }));
33468
+ }
33469
+
33045
33470
  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"};
33046
33471
 
33047
33472
  const COLOR_MODES = [{
33473
+ icon: "toolbar.sun",
33048
33474
  label: "Light",
33049
33475
  value: "light"
33050
33476
  }, {
33477
+ icon: "toolbar.moon",
33051
33478
  label: "Dark",
33052
33479
  value: "dark"
33053
33480
  }, {
33481
+ icon: "toolbar.display",
33054
33482
  label: "System",
33055
33483
  value: "system"
33056
33484
  }];
@@ -33068,8 +33496,9 @@ function ColorModeSwitcher() {
33068
33496
  }, "Appearance", " "), /*#__PURE__*/React.createElement("div", {
33069
33497
  className: styles$2["color-mode-switcher-modes"]
33070
33498
  }, COLOR_MODES.map(({
33071
- value,
33072
- label
33499
+ icon,
33500
+ label,
33501
+ value
33073
33502
  }) => (/*#__PURE__*/React.createElement("label", {
33074
33503
  key: value
33075
33504
  }, /*#__PURE__*/React.createElement("input", {
@@ -33086,8 +33515,9 @@ function ColorModeSwitcher() {
33086
33515
  document.documentElement.setAttribute(RESOLVED_COLOR_MODE_ATTRIBUTE, resolveColorMode(colorMode));
33087
33516
  updateColorMode(colorMode);
33088
33517
  }
33089
- }), /*#__PURE__*/React.createElement(ColorModeIcon, {
33090
- mode: value
33518
+ }), /*#__PURE__*/React.createElement(Icon, {
33519
+ name: icon,
33520
+ size: 15
33091
33521
  }), label)))));
33092
33522
  }
33093
33523
  function updateColorMode(mode) {
@@ -33102,28 +33532,6 @@ function updateColorMode(mode) {
33102
33532
  method: "POST"
33103
33533
  });
33104
33534
  }
33105
- const COLOR_MODE_ICON_PATHS = {
33106
- 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",
33107
- 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",
33108
- 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"
33109
- };
33110
- function ColorModeIcon({
33111
- mode
33112
- }) {
33113
- return /*#__PURE__*/React.createElement("svg", {
33114
- width: "16",
33115
- height: "16",
33116
- viewBox: "0 0 14 14",
33117
- xmlns: "http://www.w3.org/2000/svg",
33118
- "aria-hidden": "true"
33119
- }, /*#__PURE__*/React.createElement("path", {
33120
- d: COLOR_MODE_ICON_PATHS[mode],
33121
- strokeWidth: "1.5",
33122
- fill: "none",
33123
- strokeLinecap: "round",
33124
- strokeLinejoin: "round"
33125
- }));
33126
- }
33127
33535
 
33128
33536
  const requestClearConnectedPeopleCache = () => remove();
33129
33537
  function UserProfile({
@@ -33186,10 +33594,10 @@ function UserProfile({
33186
33594
  alignment: "center",
33187
33595
  spacing: 0.5,
33188
33596
  width: "100%"
33189
- }, /*#__PURE__*/React.createElement(tapestryReact.Icon, {
33597
+ }, /*#__PURE__*/React.createElement(Icon, {
33190
33598
  name: mfaConfigured ? "general.lock" : "general.unlock",
33191
- size: "sm",
33192
- color: mfaConfigured ? "var(--t-icon-color-status-success)" : "var(--t-icon-color-status-error)"
33599
+ size: 12,
33600
+ theme: mfaConfigured ? "--t-icon-color-status-success" : "--t-icon-color-status-error"
33193
33601
  }), /*#__PURE__*/React.createElement(tapestryReact.Text, {
33194
33602
  size: 4,
33195
33603
  weight: 400,
@@ -33237,9 +33645,10 @@ function UserProfile({
33237
33645
  }, /*#__PURE__*/React.createElement(tapestryReact.Text, {
33238
33646
  color: "var(--text-color-default-secondary)",
33239
33647
  size: 3
33240
- }, "Log out"), /*#__PURE__*/React.createElement(tapestryReact.Icon, {
33648
+ }, "Log out"), /*#__PURE__*/React.createElement(Icon, {
33241
33649
  name: "general.intoBox",
33242
- fill: "var(--t-icon-color-secondary)"
33650
+ size: 14,
33651
+ theme: "--t-icon-color-secondary"
33243
33652
  })))), env !== "production" && /*#__PURE__*/React.createElement(DebugPcoResource, {
33244
33653
  env: env
33245
33654
  }), hasConnectedPeople && (/*#__PURE__*/React.createElement(tapestryReact.StackView, {
@@ -33301,26 +33710,42 @@ function UserProfile({
33301
33710
 
33302
33711
  var ActionType;
33303
33712
  (function (ActionType) {
33713
+ ActionType["clearPending"] = "clearPending";
33304
33714
  ActionType["close"] = "close";
33305
33715
  ActionType["mount"] = "mount";
33306
33716
  ActionType["open"] = "open";
33717
+ ActionType["setPending"] = "setPending";
33307
33718
  ActionType["unmount"] = "unmount";
33308
33719
  })(ActionType || (ActionType = {}));
33309
33720
  const initialState = {
33310
33721
  currentContent: null,
33311
- open: false
33722
+ open: false,
33723
+ pending: null
33312
33724
  };
33313
33725
  const reducer = (state, action) => {
33726
+ var _a, _b;
33314
33727
  switch (action.type) {
33315
33728
  case ActionType.mount:
33316
33729
  return {
33317
33730
  ...state,
33318
- currentContent: action.payload.content
33731
+ currentContent: action.payload.content,
33732
+ 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
33319
33733
  };
33320
33734
  case ActionType.unmount:
33321
33735
  return {
33322
33736
  ...state,
33323
- currentContent: null
33737
+ currentContent: null,
33738
+ pending: null
33739
+ };
33740
+ case ActionType.setPending:
33741
+ return {
33742
+ ...state,
33743
+ pending: action.payload
33744
+ };
33745
+ case ActionType.clearPending:
33746
+ return {
33747
+ ...state,
33748
+ pending: null
33324
33749
  };
33325
33750
  case ActionType.open:
33326
33751
  return {
@@ -33341,11 +33766,179 @@ const useToolbarReducer = overrides => React__namespace.useReducer(reducer, {
33341
33766
 
33342
33767
  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"};
33343
33768
 
33344
- const pcoUrl = pcoUrl$1;
33769
+ // PCO Box serves TLS by default, and a bare http:// frame inside an https
33770
+ // product page is blocked as mixed content, so follow the page's scheme.
33771
+ const iframeSrc = () => `${window.location.protocol}//pco.test/iframe`;
33772
+ const STAFF_PINK = "var(--t-fill-color-product-staff)";
33773
+ // The pco.test domain is only ever served by a local PCO Box, so the tool
33774
+ // stays hidden in staging and production — and on Topbar's own dev server,
33775
+ // which runs on localhost and has no pco.test to frame.
33776
+ function isPcoTestHost() {
33777
+ const {
33778
+ hostname
33779
+ } = window.location;
33780
+ return hostname === "pco.test" || hostname.endsWith(".pco.test");
33781
+ }
33782
+ // Flattened from the source artwork's two outline paths — its gradients,
33783
+ // filters and clip paths carry nothing at icon size. Those paths are authored
33784
+ // on a 128px canvas, hence the transform: it fits the pair into the same ~24px
33785
+ // optical box the sibling toolbar icons occupy. Filled explicitly rather than
33786
+ // with `currentColor` so the mark stays staff pink instead of inheriting the
33787
+ // toolbar's text color and its hover/active states.
33788
+ 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";
33789
+ 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";
33790
+ function ToolsIcon() {
33791
+ return /*#__PURE__*/React.createElement("svg", {
33792
+ "aria-hidden": "true",
33793
+ width: "32",
33794
+ height: "32",
33795
+ viewBox: "0 0 32 32",
33796
+ xmlns: "http://www.w3.org/2000/svg"
33797
+ }, /*#__PURE__*/React.createElement("g", {
33798
+ fill: STAFF_PINK,
33799
+ transform: "translate(2.92 2.66) scale(.204)"
33800
+ }, /*#__PURE__*/React.createElement("path", {
33801
+ d: WRENCH
33802
+ }), /*#__PURE__*/React.createElement("path", {
33803
+ d: SCREWDRIVER
33804
+ })));
33805
+ }
33806
+ // A cross-origin frame's own history object is unreachable (touching it throws
33807
+ // a SecurityError), but the frame's navigations do land in the top-level joint
33808
+ // session history, so the *parent's* back/forward traverse them while leaving
33809
+ // the host page in place. Counting frame loads tracks how deep we are, which is
33810
+ // what keeps a click on Back from stepping past the frame's first page and
33811
+ // navigating the host app instead. Loads we caused are attributed to the
33812
+ // traversal that requested them rather than counted as new navigations.
33813
+ function useFrameHistory() {
33814
+ const [steps, setSteps] = React.useState({
33815
+ back: 0,
33816
+ forward: 0
33817
+ });
33818
+ // Traversal has to read the current counts outside of render, so the ref is
33819
+ // the source of truth and state exists to paint the buttons.
33820
+ const stepsRef = React.useRef(steps);
33821
+ const pendingTraversal = React.useRef(null);
33822
+ const frameIsLoaded = React.useRef(false);
33823
+ const moveTo = React.useCallback(next => {
33824
+ stepsRef.current = next;
33825
+ setSteps(next);
33826
+ }, []);
33827
+ const onFrameLoad = React.useCallback(() => {
33828
+ if (!frameIsLoaded.current) {
33829
+ // The frame's first page is the floor, not a step.
33830
+ frameIsLoaded.current = true;
33831
+ } else {
33832
+ const traversal = pendingTraversal.current;
33833
+ pendingTraversal.current = null;
33834
+ const {
33835
+ back,
33836
+ forward
33837
+ } = stepsRef.current;
33838
+ if (traversal === "back") {
33839
+ moveTo({
33840
+ back: back - 1,
33841
+ forward: forward + 1
33842
+ });
33843
+ } else if (traversal === "forward") {
33844
+ moveTo({
33845
+ back: back + 1,
33846
+ forward: forward - 1
33847
+ });
33848
+ } else {
33849
+ moveTo({
33850
+ back: back + 1,
33851
+ forward: 0
33852
+ });
33853
+ }
33854
+ }
33855
+ }, [moveTo]);
33856
+ // Guarded here rather than trusting the button's disabled state: traversing
33857
+ // with no frame step left would navigate the host app out from under us.
33858
+ const traverse = React.useCallback(direction => {
33859
+ if (stepsRef.current[direction] > 0) {
33860
+ pendingTraversal.current = direction;
33861
+ window.history[direction]();
33862
+ }
33863
+ }, []);
33864
+ return {
33865
+ onFrameLoad,
33866
+ steps,
33867
+ traverse
33868
+ };
33869
+ }
33870
+ function NavButton({
33871
+ direction,
33872
+ disabled,
33873
+ onClick
33874
+ }) {
33875
+ return /*#__PURE__*/React.createElement(tapestryReact.Button
33876
+ // Tapestry's `disabled` suppresses the click but emits no ARIA state.
33877
+ , {
33878
+ "aria-disabled": disabled,
33879
+ disabled: disabled,
33880
+ hover: {
33881
+ backgroundColor: "var(--t-fill-color-neutral-070)"
33882
+ },
33883
+ icon: {
33884
+ fill: "var(--t-icon-color-dim)",
33885
+ name: direction === "back" ? "general.leftChevron" : "general.rightChevron",
33886
+ size: "md"
33887
+ },
33888
+ onClick: onClick,
33889
+ opacity: disabled ? 0.35 : 1,
33890
+ theme: false,
33891
+ title: direction === "back" ? "Back" : "Forward"
33892
+ });
33893
+ }
33894
+ function DeveloperToolsComponent({
33895
+ closeDrawer
33896
+ }) {
33897
+ const {
33898
+ onFrameLoad,
33899
+ steps,
33900
+ traverse
33901
+ } = useFrameHistory();
33902
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(DrawerHeader, {
33903
+ closeDrawer: closeDrawer
33904
+ }, /*#__PURE__*/React.createElement(tapestryReact.StackView, {
33905
+ alignment: "center",
33906
+ axis: "horizontal",
33907
+ spacing: 0.25
33908
+ }, /*#__PURE__*/React.createElement(NavButton, {
33909
+ direction: "back",
33910
+ disabled: steps.back === 0,
33911
+ onClick: () => traverse("back")
33912
+ }), /*#__PURE__*/React.createElement(NavButton, {
33913
+ direction: "forward",
33914
+ disabled: steps.forward === 0,
33915
+ onClick: () => traverse("forward")
33916
+ }), /*#__PURE__*/React.createElement(DrawerHeading, {
33917
+ paddingLeft: 0.5
33918
+ }, "Developer Tools"))), /*#__PURE__*/React.createElement(tapestryReact.Box, {
33919
+ as: "iframe",
33920
+ border: "none",
33921
+ flex: "1",
33922
+ minHeight: 0,
33923
+ onLoad: onFrameLoad,
33924
+ src: iframeSrc(),
33925
+ title: "Developer Tools",
33926
+ width: "100%"
33927
+ }));
33928
+ }
33929
+ const DeveloperTools = {
33930
+ component: DeveloperToolsComponent,
33931
+ icons: {
33932
+ component: ToolsIcon
33933
+ },
33934
+ label: "Developer Tools",
33935
+ // No space: `name` becomes an element id and a pendo attribute.
33936
+ name: "DeveloperTools"
33937
+ };
33345
33938
 
33346
33939
  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"};
33347
33940
 
33348
- 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";
33941
+ 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";
33349
33942
  // Same stops as the AskAIBanner border gradient, so the two stay in sync.
33350
33943
  const GRADIENT_STOPS = [{
33351
33944
  color: "var(--t-fill-color-tag-teal-040)",
@@ -33394,7 +33987,7 @@ function SparkleIcon({
33394
33987
  stopColor: color
33395
33988
  }
33396
33989
  })))))), /*#__PURE__*/React.createElement("path", {
33397
- d: SPARKLE_PATH,
33990
+ d: SPARKLE_PATH$1,
33398
33991
  fill: isGradient ? `url(#${gradientId})` : "currentColor"
33399
33992
  }));
33400
33993
  }
@@ -33802,16 +34395,24 @@ const DocumentationSearch = ({
33802
34395
  // not the path, so updating it is a same-document navigation that doesn't
33803
34396
  // reload the iframe, but also lets the route survive a forced reload (e.g.
33804
34397
  // Turbo re-parenting the topbar).
33805
- function buildPicobotSrc(origin, path) {
34398
+ //
34399
+ // `panel` loads picobot as its own toolbar panel rather than inside Helpdesk.
34400
+ function buildPicobotSrc(origin, path, {
34401
+ panel = false
34402
+ } = {}) {
33806
34403
  const [rawPath, queryPart] = path.replace(/^\/+/, "").split("?", 2);
33807
34404
  const safePath = rawPath.split("/").filter(segment => segment && segment !== "." && segment !== "..").join("/");
33808
34405
  const fragment = queryPart !== undefined ? `${safePath}?${queryPart}` : safePath;
33809
- const base = new URL("topbar", origin).toString();
34406
+ const url = new URL("topbar", origin);
34407
+ if (panel) url.searchParams.set("panel", "1");
34408
+ const base = url.toString();
33810
34409
  return fragment ? `${base}#${fragment}` : base;
33811
34410
  }
33812
34411
 
33813
34412
  const LOAD_TIMEOUT_MS = 3000;
33814
- function createPicobotIframe(picobotOrigin) {
34413
+ function createPicobotIframe(picobotOrigin, {
34414
+ panel = false
34415
+ } = {}) {
33815
34416
  return /*#__PURE__*/React.forwardRef(function PicobotIframe({
33816
34417
  currentPath: iframePath,
33817
34418
  onChangePath: onIframeChangePath,
@@ -33867,7 +34468,9 @@ function createPicobotIframe(picobotOrigin) {
33867
34468
  (_b = (_a = iframeRef.current) === null || _a === void 0 ? void 0 : _a.contentWindow) === null || _b === void 0 ? void 0 : _b.postMessage(message, picobotOrigin);
33868
34469
  }
33869
34470
  }), []);
33870
- const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath);
34471
+ const iframeSrc = buildPicobotSrc(picobotOrigin, loadedPath, {
34472
+ panel
34473
+ });
33871
34474
  return /*#__PURE__*/React.createElement("iframe", {
33872
34475
  ref: iframeRef,
33873
34476
  src: iframeSrc,
@@ -33906,22 +34509,28 @@ function HelpdeskComponent({
33906
34509
  currentPath,
33907
34510
  env,
33908
34511
  helpdeskConfig,
33909
- onChangePath
34512
+ isPicobotLoading,
34513
+ onChangePath,
34514
+ openPicobot
33910
34515
  }) {
33911
34516
  const PicobotIframe = React.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot")), [env]);
34517
+ const loadingFallback = /*#__PURE__*/React.createElement(StackView$1, {
34518
+ flex: 1,
34519
+ axis: "vertical"
34520
+ }, /*#__PURE__*/React.createElement(DrawerHeader, {
34521
+ closeDrawer: closeDrawer
34522
+ }, /*#__PURE__*/React.createElement(StackView$1, {
34523
+ axis: "horizontal",
34524
+ spacing: 1
34525
+ }, /*#__PURE__*/React.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React.createElement(LoadingIndicator$1, null))));
34526
+ // The add-on reads its args once, so mounting before the gate answers
34527
+ // would leave out openPicobot for good.
34528
+ if (isPicobotLoading) return loadingFallback;
33912
34529
  return /*#__PURE__*/React.createElement(addOns.UiExtensionFirstPartyComponent, {
33913
34530
  grow: 1,
33914
34531
  display: "flex",
33915
34532
  extensionId: env === "staging" ? "42" : "11",
33916
- loadingFallback: /*#__PURE__*/React.createElement(StackView$1, {
33917
- flex: 1,
33918
- axis: "vertical"
33919
- }, /*#__PURE__*/React.createElement(DrawerHeader, {
33920
- closeDrawer: closeDrawer
33921
- }, /*#__PURE__*/React.createElement(StackView$1, {
33922
- axis: "horizontal",
33923
- spacing: 1
33924
- }, /*#__PURE__*/React.createElement(DrawerHeading, null, "Help and support"), /*#__PURE__*/React.createElement(LoadingIndicator$1, null)))),
34533
+ loadingFallback: loadingFallback,
33925
34534
  additionalComponents: {
33926
34535
  AskAIBanner: {
33927
34536
  component: AskAIBanner,
@@ -34019,7 +34628,11 @@ function HelpdeskComponent({
34019
34628
  currentPageParams: location.search,
34020
34629
  currentPath,
34021
34630
  helpdeskConfig,
34022
- onChangePath
34631
+ onChangePath,
34632
+ // Its presence tells helpdesk that Picobot has its own panel.
34633
+ ...(openPicobot && {
34634
+ openPicobot
34635
+ })
34023
34636
  }
34024
34637
  });
34025
34638
  }
@@ -34566,6 +35179,142 @@ const Notifications = {
34566
35179
  name: "Notifications"
34567
35180
  };
34568
35181
 
35182
+ 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";
35183
+ // Helpdesk reads this context off the email path to open its email form the
35184
+ // same way a handoff inside its own drawer does.
35185
+ function emailPathWithContext(context = {}) {
35186
+ const params = new URLSearchParams();
35187
+ for (const key of ["conversationId", "source", "turnIndex"]) {
35188
+ const value = context[key];
35189
+ if (value !== null && value !== undefined) params.set(key, String(value));
35190
+ }
35191
+ return `email?${params}`;
35192
+ }
35193
+ function PicobotComponent({
35194
+ clearPending,
35195
+ closeDrawer,
35196
+ currentPath,
35197
+ env,
35198
+ onChangePath,
35199
+ openTool,
35200
+ pending
35201
+ }) {
35202
+ const PicobotIframe = React.useMemo(() => createPicobotIframe(pcoUrl(env)("picobot"), {
35203
+ panel: true
35204
+ }), [env]);
35205
+ const picobotRef = React.useRef(null);
35206
+ const [isPicobotReady, setIsPicobotReady] = React.useState(false);
35207
+ // Picobot has no listener until it sends READY, so openPicobot's draft or
35208
+ // conversation waits for it.
35209
+ React.useEffect(function handOverPendingOncePicobotIsReady() {
35210
+ var _a, _b;
35211
+ if (!pending || !isPicobotReady) return;
35212
+ const {
35213
+ conversationId,
35214
+ draft,
35215
+ ticketExternalId
35216
+ } = pending;
35217
+ if (conversationId) {
35218
+ (_a = picobotRef.current) === null || _a === void 0 ? void 0 : _a.postMessage({
35219
+ payload: {
35220
+ conversation_id: conversationId,
35221
+ // Picobot asks whether this resolved the ticket, and closes it.
35222
+ ...(ticketExternalId && {
35223
+ external_id: ticketExternalId,
35224
+ source: "ticket_submitted"
35225
+ })
35226
+ },
35227
+ type: "LOAD_CONVERSATION"
35228
+ });
35229
+ } else if (draft) {
35230
+ // Not both: Picobot cancels a conversation load when a prefill
35231
+ // starts a new conversation.
35232
+ (_b = picobotRef.current) === null || _b === void 0 ? void 0 : _b.postMessage({
35233
+ payload: {
35234
+ source: "email_form",
35235
+ text: draft
35236
+ },
35237
+ type: "PREFILL_COMPOSER"
35238
+ });
35239
+ }
35240
+ clearPending();
35241
+ }, [pending, isPicobotReady, clearPending]);
35242
+ const [iframeErrored, setIframeErrored] = React.useState(false);
35243
+ const [mountKey, setMountKey] = React.useState(0);
35244
+ const handleRetry = React.useCallback(() => {
35245
+ setIframeErrored(false);
35246
+ setIsPicobotReady(false);
35247
+ setMountKey(key => key + 1);
35248
+ }, []);
35249
+ const handleMessage = React.useCallback(data => {
35250
+ var _a;
35251
+ const message = data;
35252
+ switch (message === null || message === void 0 ? void 0 : message.type) {
35253
+ case "CLOSE_DRAWER":
35254
+ return closeDrawer();
35255
+ case "READY":
35256
+ return setIsPicobotReady(true);
35257
+ case "PICOBOT_SUPPORT_HANDOFF":
35258
+ return openTool("helpdesk", ((_a = message.payload) === null || _a === void 0 ? void 0 : _a.method) === "zoom" ? "phone" : emailPathWithContext(message.payload));
35259
+ case "IFRAME_LOAD_ERROR":
35260
+ console.warn("topbar: Picobot iframe failed to load");
35261
+ return setIframeErrored(true);
35262
+ }
35263
+ }, [closeDrawer, openTool]);
35264
+ if (iframeErrored) {
35265
+ return /*#__PURE__*/React.createElement(LoadError, {
35266
+ onContactSupport: () => openTool("helpdesk", "email"),
35267
+ onRetry: handleRetry
35268
+ });
35269
+ }
35270
+ return /*#__PURE__*/React.createElement(PicobotIframe, {
35271
+ key: mountKey,
35272
+ ref: picobotRef,
35273
+ currentPath: currentPath,
35274
+ onChangePath: onChangePath,
35275
+ onMessage: handleMessage
35276
+ });
35277
+ }
35278
+ function LoadError({
35279
+ onContactSupport,
35280
+ onRetry
35281
+ }) {
35282
+ return /*#__PURE__*/React.createElement(tapestryReact.StackView, {
35283
+ axis: "vertical",
35284
+ flex: 1,
35285
+ alignment: "center",
35286
+ distribution: "center",
35287
+ padding: 3,
35288
+ spacing: 1
35289
+ }, /*#__PURE__*/React.createElement(tapestryReact.Heading, {
35290
+ level: 2
35291
+ }, "Picobot is temporarily unavailable"), /*#__PURE__*/React.createElement(tapestryReact.Text, {
35292
+ size: 5,
35293
+ color: "var(--t-text-color-secondary)"
35294
+ }, "Try again or contact support."), /*#__PURE__*/React.createElement(tapestryReact.Box, {
35295
+ paddingTop: 2
35296
+ }, /*#__PURE__*/React.createElement(tapestryReact.Button, {
35297
+ onClick: onRetry,
35298
+ iconLeft: "general.refresh",
35299
+ theme: "primary",
35300
+ variant: "fill",
35301
+ title: "Retry"
35302
+ })), /*#__PURE__*/React.createElement(tapestryReact.Button, {
35303
+ onClick: onContactSupport,
35304
+ variant: "naked",
35305
+ theme: "primary",
35306
+ title: "Contact support"
35307
+ }));
35308
+ }
35309
+ const Picobot = {
35310
+ component: PicobotComponent,
35311
+ icons: {
35312
+ staticDotPath: SPARKLE_PATH,
35313
+ staticPath: SPARKLE_PATH
35314
+ },
35315
+ name: "Picobot"
35316
+ };
35317
+
34569
35318
  const {
34570
35319
  LoadingIndicator,
34571
35320
  StackView
@@ -34961,6 +35710,41 @@ const useChatConfig = ({
34961
35710
  };
34962
35711
  };
34963
35712
 
35713
+ // Picobot's API rejects requests without a version header, and apiRequest
35714
+ // sets its own headers, so the version rides in on the fetch.
35715
+ const picobotFetch = (url, opts) => defaultFetch(url, {
35716
+ ...opts,
35717
+ headers: {
35718
+ ...(opts === null || opts === void 0 ? void 0 : opts.headers),
35719
+ "X-PCO-API-Version": "2026-08-11"
35720
+ }
35721
+ });
35722
+ // Routes wait on the gate, so a slow Picobot can't hold them up for long.
35723
+ const GATE_TIMEOUT_MS = 3000;
35724
+ const usePicobotConfig = ({
35725
+ isEnabled
35726
+ }) => {
35727
+ // Undefined until Picobot answers.
35728
+ const [panelEnabled, setPanelEnabled] = React.useState();
35729
+ React.useEffect(() => {
35730
+ if (!isEnabled) return undefined;
35731
+ // Whatever settles first wins, so a late answer can't reopen the gate.
35732
+ const settle = enabled => setPanelEnabled(current => current !== null && current !== void 0 ? current : enabled);
35733
+ const timeout = setTimeout(() => settle(false), GATE_TIMEOUT_MS);
35734
+ apiRequest(picobotFetch, `${window.location.origin}/~api/picobot/v2/me`).then(({
35735
+ json
35736
+ }) => {
35737
+ var _a, _b;
35738
+ 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);
35739
+ }).catch(() => settle(false)).finally(() => clearTimeout(timeout));
35740
+ return () => clearTimeout(timeout);
35741
+ }, [isEnabled]);
35742
+ return {
35743
+ displayPicobotIcon: panelEnabled === true,
35744
+ isLoading: isEnabled && panelEnabled === undefined
35745
+ };
35746
+ };
35747
+
34964
35748
  const Profile = {
34965
35749
  component: UserProfile,
34966
35750
  icons: {
@@ -34971,10 +35755,16 @@ const Profile = {
34971
35755
  };
34972
35756
 
34973
35757
  const DURATION = 100;
35758
+ // Exempt paths match regardless of query, so `email?conversationId=…` is
35759
+ // exempt along with `email`.
34974
35760
  function isToolPathExemptFromStorage(toolName, path) {
34975
35761
  var _a, _b;
34976
35762
  const tool = toolsRouteMap[toolName];
34977
- 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;
35763
+ const [pathWithoutQuery] = path.split("?");
35764
+ 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;
35765
+ }
35766
+ function isConversationPath(path) {
35767
+ return Boolean(path === null || path === void 0 ? void 0 : path.startsWith("conversations/"));
34978
35768
  }
34979
35769
  function scrollToolDrawerToTop(toolName) {
34980
35770
  var _a;
@@ -34987,6 +35777,7 @@ const TOOLS_MAP = {
34987
35777
  Chat,
34988
35778
  Helpdesk,
34989
35779
  Notifications,
35780
+ Picobot,
34990
35781
  Profile,
34991
35782
  Tasks
34992
35783
  };
@@ -35014,7 +35805,8 @@ function Toolbar({
35014
35805
  });
35015
35806
  const {
35016
35807
  currentContent,
35017
- open
35808
+ open,
35809
+ pending
35018
35810
  } = state;
35019
35811
  const mounted = !!currentContent;
35020
35812
  const storeData = useStore();
@@ -35027,6 +35819,15 @@ function Toolbar({
35027
35819
  ...(env === "development" ? _storeDataOverride !== null && _storeDataOverride !== void 0 ? _storeDataOverride : {} : {})
35028
35820
  };
35029
35821
  const updateStore = useUpdateStore();
35822
+ const {
35823
+ displayPicobotIcon,
35824
+ isLoading: isPicobotGateLoading
35825
+ } = usePicobotConfig({
35826
+ isEnabled: Boolean(userId)
35827
+ });
35828
+ // Picobot is a kill-switched tool: until its gate says the panel is
35829
+ // enabled, its routes are held, and if it never does, they're ignored.
35830
+ const routableTool = React.useCallback(name => name === Picobot.name.toLowerCase() && !displayPicobotIcon ? undefined : toolsRouteMap[name], [displayPicobotIcon]);
35030
35831
  React.useEffect(() => {
35031
35832
  if (open) {
35032
35833
  document.body.style.overflow = "hidden";
@@ -35114,7 +35915,9 @@ function Toolbar({
35114
35915
  });
35115
35916
  }, [clearRoute]);
35116
35917
  React.useEffect(() => {
35117
- if (!toolbarFragmentRoute) return;
35918
+ if (!toolbarFragmentRoute || !routableTool(toolbarFragmentRoute.tool)) {
35919
+ return;
35920
+ }
35118
35921
  const isExempt = isToolPathExemptFromStorage(toolbarFragmentRoute.tool, toolbarFragmentRoute.path);
35119
35922
  if (isExempt) return;
35120
35923
  const nextState = {
@@ -35124,12 +35927,32 @@ function Toolbar({
35124
35927
  if (!shallowCompare(nextState, toolStateCookie)) {
35125
35928
  setToolStateCookie(nextState);
35126
35929
  }
35127
- }, [toolbarFragmentRoute, toolStateCookie]);
35930
+ }, [toolbarFragmentRoute, toolStateCookie, routableTool]);
35128
35931
  React.useEffect(() => {
35129
- if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && (currentContent !== toolsRouteMap[toolbarFragmentRoute.tool] || !open)) {
35130
- setToolAndOpenDrawer(toolsRouteMap[toolbarFragmentRoute.tool]);
35932
+ // Helpdesk conversations may belong to Picobot, so they wait for its gate
35933
+ // rather than flashing open in Helpdesk first.
35934
+ const mayRedirectToPicobot = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path) && (isPicobotGateLoading || displayPicobotIcon);
35935
+ const tool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && !mayRedirectToPicobot && routableTool(toolbarFragmentRoute.tool);
35936
+ if (tool && (currentContent !== tool || !open)) {
35937
+ setToolAndOpenDrawer(tool);
35131
35938
  }
35132
- }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, currentContent, open]);
35939
+ }, [setToolAndOpenDrawer, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path, currentContent, open, routableTool, isPicobotGateLoading, displayPicobotIcon]);
35940
+ const openTool = React.useCallback((name, path, pendingValue) => {
35941
+ const tool = routableTool(name.toLowerCase());
35942
+ if (!tool) return;
35943
+ dispatch({
35944
+ payload: pendingValue === undefined ? null : {
35945
+ tool: tool.name,
35946
+ value: pendingValue
35947
+ },
35948
+ type: ActionType.setPending
35949
+ });
35950
+ onChangeToolbarFragmentRoute(tool.name.toLowerCase(), path);
35951
+ scrollToolDrawerToTop(tool.name);
35952
+ }, [onChangeToolbarFragmentRoute, routableTool]);
35953
+ const clearPending = React.useCallback(() => dispatch({
35954
+ type: ActionType.clearPending
35955
+ }), []);
35133
35956
  const toggleDrawer = content => {
35134
35957
  if (!open) {
35135
35958
  return openDrawer(content);
@@ -35146,12 +35969,52 @@ function Toolbar({
35146
35969
  joltPromise: storeData === null || storeData === void 0 ? void 0 : storeData.joltPromise,
35147
35970
  userId
35148
35971
  });
35149
- const TOOLS = [Notifications, Tasks, displayChatIcon && Chat].filter(Boolean);
35972
+ // Picobot conversations moved from Helpdesk to the Picobot panel, so
35973
+ // send old links and saved paths there once the panel is enabled.
35974
+ React.useEffect(() => {
35975
+ if (!displayPicobotIcon) return;
35976
+ if ((toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) === Helpdesk.name.toLowerCase() && isConversationPath(toolbarFragmentRoute.path)) {
35977
+ onChangeToolbarFragmentRoute(Picobot.name.toLowerCase(), toolbarFragmentRoute.path);
35978
+ }
35979
+ }, [displayPicobotIcon, toolbarFragmentRoute, onChangeToolbarFragmentRoute]);
35980
+ React.useEffect(() => {
35981
+ if (!displayPicobotIcon) return;
35982
+ const {
35983
+ helpdesk,
35984
+ picobot
35985
+ } = toolStateCookie;
35986
+ if (!isConversationPath(helpdesk)) return;
35987
+ setToolStateCookie({
35988
+ ...toolStateCookie,
35989
+ helpdesk: "",
35990
+ picobot: picobot || helpdesk
35991
+ });
35992
+ }, [displayPicobotIcon, toolStateCookie]);
35993
+ const openPicobot = React.useCallback(options => {
35994
+ var _a;
35995
+ // Moving an open Picobot off its path reloads or navigates the iframe,
35996
+ // which would race the options it's being handed.
35997
+ const path = currentContent === Picobot ? (_a = toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path) !== null && _a !== void 0 ? _a : "" : "";
35998
+ openTool(Picobot.name, path, options);
35999
+ }, [openTool, currentContent, toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path]);
36000
+ React.useEffect(() => {
36001
+ if (!displayPicobotIcon) return undefined;
36002
+ window.topbar = window.topbar || {};
36003
+ window.topbar.openPicobot = openPicobot;
36004
+ return () => {
36005
+ delete window.topbar.openPicobot;
36006
+ };
36007
+ }, [displayPicobotIcon, openPicobot]);
36008
+ const TOOLS = [Notifications, Tasks, displayChatIcon && Chat, displayPicobotIcon && Picobot, isPcoTestHost() && DeveloperTools].filter(Boolean);
35150
36009
  const handleToolOnClick = tool => {
35151
36010
  toggleDrawer(tool);
35152
36011
  scrollToolDrawerToTop(tool.name);
35153
36012
  };
35154
36013
  const toolSpecificProps = {
36014
+ Helpdesk: {
36015
+ isPicobotLoading: isPicobotGateLoading,
36016
+ openPicobot: displayPicobotIcon ? openPicobot : undefined
36017
+ },
35155
36018
  Profile: {
35156
36019
  appName,
35157
36020
  closeDrawer,
@@ -35174,6 +36037,7 @@ function Toolbar({
35174
36037
  const routeIsForCurrentTool = (toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.tool) && toolsRouteMap[toolbarFragmentRoute.tool] === currentContent;
35175
36038
  const currentToolProps = {
35176
36039
  appName,
36040
+ clearPending,
35177
36041
  closeDrawer,
35178
36042
  currentPath: routeIsForCurrentTool ? toolbarFragmentRoute === null || toolbarFragmentRoute === void 0 ? void 0 : toolbarFragmentRoute.path : null,
35179
36043
  env,
@@ -35182,6 +36046,8 @@ function Toolbar({
35182
36046
  joltPromise,
35183
36047
  onChangePath,
35184
36048
  openDrawer: () => currentContent && openDrawer(currentContent),
36049
+ openTool,
36050
+ 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,
35185
36051
  userId,
35186
36052
  ...(currentContent ? toolSpecificProps[currentContent.name] : {})
35187
36053
  };
@@ -35209,21 +36075,20 @@ function Toolbar({
35209
36075
  name: "toolbar.planningCenter",
35210
36076
  size: "24px"
35211
36077
  })), [HelpDeskTool, ...TOOLS].map(tool => {
36078
+ var _a;
35212
36079
  const current = open && currentContent === tool;
35213
36080
  const indicator = indicatorState[tool.name];
35214
36081
  return /*#__PURE__*/React.createElement(ToolbarButton, {
35215
36082
  current: current,
35216
36083
  onClick: () => handleToolOnClick(tool),
35217
36084
  id: getDataPendoAttribute(tool.name),
35218
- "aria-label": `Open ${tool.name} drawer`,
36085
+ "aria-label": `Open ${(_a = tool.label) !== null && _a !== void 0 ? _a : tool.name} drawer`,
35219
36086
  "data-pendo": getDataPendoAttribute(tool.name),
35220
36087
  key: tool.name
35221
- }, !indicator && (/*#__PURE__*/React.createElement(ToolbarButtonIcon, {
35222
- path: tool.icons.staticPath
35223
- })), indicator && (/*#__PURE__*/React.createElement(ToolbarButtonIcon, {
35224
- path: tool.icons.staticDotPath,
35225
- dot: true
35226
- })));
36088
+ }, /*#__PURE__*/React.createElement(ToolbarToolIcon, {
36089
+ icons: tool.icons,
36090
+ dot: !!indicator
36091
+ }));
35227
36092
  })), /*#__PURE__*/React.createElement(DrawerWrap, {
35228
36093
  isVisible: open
35229
36094
  }, mounted && (/*#__PURE__*/React.createElement(Drawer, {
@@ -35247,6 +36112,19 @@ const ToolbarButton = ({
35247
36112
  ...restProps
35248
36113
  }, children);
35249
36114
  };
36115
+ function ToolbarToolIcon({
36116
+ dot,
36117
+ icons
36118
+ }) {
36119
+ if ("component" in icons) {
36120
+ return /*#__PURE__*/React.createElement(icons.component, null);
36121
+ } else {
36122
+ return /*#__PURE__*/React.createElement(ToolbarButtonIcon, {
36123
+ path: dot ? icons.staticDotPath : icons.staticPath,
36124
+ dot: dot
36125
+ });
36126
+ }
36127
+ }
35250
36128
  function ToolbarButtonIcon({
35251
36129
  dot,
35252
36130
  path