@the-portland-company/shell 0.36.3 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## 0.37.1 — 2026-07-27
2
+
3
+ ### Fixed — cross-app navigation kept the user's active organization
4
+
5
+ Every link the Relationship tree emitted was bare: `Forms` / `Surveys` / `Polls`
6
+ went to their standalone apps, and every core route went to `app.politogyvrm.com`,
7
+ with no `?org=` selector. A multi-org user working in one organization clicked a
8
+ feature and landed in whichever organization the destination resolved by default
9
+ (reported: working in "We Need Steve", clicked Contacts, arrived in another org's
10
+ contacts). Intra-app links already carried the selector, so the jump only
11
+ happened when crossing apps.
12
+
13
+ `standardPolitogyNav` now stamps `?org=<full-uuid>` on EVERY link it builds, in
14
+ every tree — the Campaign and Petition trees already did. The full UUID is the
15
+ only collision-free selector (contacts-api matches exact id first, then a short
16
+ suffix that can collide across a user's orgs), and `isNavItemActive` already
17
+ excludes `org` from active-detection, so nothing changes about which row is
18
+ selected. The app-owned `contactsItem` override is still used verbatim; the
19
+ `politogy_active_org` cookie remains the fallback for a bare URL.
20
+
21
+ The same treatment applies to the chrome's own cross-app links — "Buy More Fuel"
22
+ / "Add fuel" and "View all" quarantined contacts, which all point into VRM core.
23
+
24
+ ## 0.37.0 — 2026-07-27
25
+
26
+ ### Fixed — organization logo renders in every app, not just VRM core
27
+
28
+ `organizations.logo_url` is authored by VRM core and stored as a ROOT-RELATIVE
29
+ core route (`/api/storage/public?bucket=organization-logos&path=<org>/logo.png`
30
+ — core's signed-storage proxy). VRM core rendered it fine because the path
31
+ resolved against its own origin; every feature app resolved the same path
32
+ against ITS origin (`forms.politogyvrm.com/api/storage/public`), 404'd, and
33
+ showed a broken image in the sidebar org pill and the org dropdown.
34
+
35
+ `resolveOrgLogoUrl` (exported from `./native`) now absolutizes a relative logo
36
+ path against `CORE_APP_ORIGIN` at the point of render, for all three org-badge
37
+ sites (active pill, dropdown rows, non-switcher pill). Absolute, protocol-
38
+ relative and `data:` URLs pass through untouched; empty values still fall back
39
+ to initials. This is the same treatment `absolutizeAvatarUrl` already applied to
40
+ `profiles.avatar_url` in the chrome API — the org logo was simply missed.
41
+
1
42
  ## 0.36.0 — 2026-07-24
2
43
 
3
44
  ### Added — cross-subdomain active-org + favicon cache-busting
@@ -150,6 +150,16 @@ var CORE_APP_ORIGIN = "https://app.politogyvrm.com";
150
150
  var CORE_BILLING_PATH = "/organization-settings/subscription-billing/text-email";
151
151
  var CORE_BILLING_URL = `${CORE_APP_ORIGIN}${CORE_BILLING_PATH}`;
152
152
  var QUARANTINED_CONTACTS_URL = `${CORE_APP_ORIGIN}/relationship/contacts/quarantined`;
153
+ function withOrgParam(url, orgId) {
154
+ if (!orgId) return url;
155
+ return `${url}${url.includes("?") ? "&" : "?"}org=${orgId}`;
156
+ }
157
+ function resolveOrgLogoUrl(url) {
158
+ const raw = url?.trim();
159
+ if (!raw) return void 0;
160
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(raw)) return raw;
161
+ return `${CORE_APP_ORIGIN}${raw.startsWith("/") ? "" : "/"}${raw}`;
162
+ }
153
163
  function topBarIconButtonClass(tone = "default") {
154
164
  const toneClass = tone === "danger" || tone === "warning" ? "text-[color:var(--color-race-red)] hover:text-[color:var(--color-race-red)]" : "text-[color:var(--topbar-icon-color,var(--muted))] hover:text-[color:var(--topbar-icon-hover-color,var(--foreground))] hover:bg-[color:var(--topbar-icon-hover-bg,transparent)]";
155
165
  return [
@@ -498,7 +508,7 @@ function FuelRow({
498
508
  ) })
499
509
  ] });
500
510
  }
501
- function LowFuelHint({ lowFuel }) {
511
+ function LowFuelHint({ lowFuel, orgId }) {
502
512
  const [dismissed, setDismissed] = react.useState(() => {
503
513
  if (typeof window === "undefined") return false;
504
514
  try {
@@ -542,7 +552,7 @@ function LowFuelHint({ lowFuel }) {
542
552
  /* @__PURE__ */ jsxRuntime.jsx(
543
553
  "a",
544
554
  {
545
- href: CORE_BILLING_URL,
555
+ href: withOrgParam(CORE_BILLING_URL, orgId),
546
556
  className: "mt-1 inline-block text-sm font-bold underline hover:opacity-90",
547
557
  children: "Buy More Fuel"
548
558
  }
@@ -609,7 +619,7 @@ function FuelAction({ orgId }) {
609
619
  label: "SMS Signal",
610
620
  channel: fuel?.signal ?? null,
611
621
  loading,
612
- addHref: CORE_BILLING_URL,
622
+ addHref: withOrgParam(CORE_BILLING_URL, orgId),
613
623
  addLabel: "Add SMS Signal fuel"
614
624
  }
615
625
  ),
@@ -619,7 +629,7 @@ function FuelAction({ orgId }) {
619
629
  label: "Email Broadcast",
620
630
  channel: fuel?.broadcast ?? null,
621
631
  loading,
622
- addHref: CORE_BILLING_URL,
632
+ addHref: withOrgParam(CORE_BILLING_URL, orgId),
623
633
  addLabel: "Add Email Broadcast fuel"
624
634
  }
625
635
  )
@@ -627,7 +637,7 @@ function FuelAction({ orgId }) {
627
637
  ] })
628
638
  }
629
639
  ),
630
- /* @__PURE__ */ jsxRuntime.jsx(LowFuelHint, { lowFuel: low })
640
+ /* @__PURE__ */ jsxRuntime.jsx(LowFuelHint, { lowFuel: low, orgId })
631
641
  ] })
632
642
  );
633
643
  }
@@ -922,7 +932,7 @@ function NotificationsAction({ orgId }) {
922
932
  /* @__PURE__ */ jsxRuntime.jsx(
923
933
  "a",
924
934
  {
925
- href: QUARANTINED_CONTACTS_URL,
935
+ href: withOrgParam(QUARANTINED_CONTACTS_URL, orgId),
926
936
  className: "text-[11px] font-semibold text-[color:var(--color-race-red)] hover:underline",
927
937
  children: "View all"
928
938
  }
@@ -1390,15 +1400,15 @@ function standardPolitogyNav(opts) {
1390
1400
  const base = opts.coreBaseUrl.replace(/\/+$/, "");
1391
1401
  const abs = (path) => `${base}${path.startsWith("/") ? path : `/${path}`}`;
1392
1402
  const icon = (key) => opts.icons?.[key];
1393
- const leaves = (children) => children.map((c) => ({ label: c.label, icon: icon(c.icon), href: abs(c.path) }));
1403
+ const orgParam = opts.activeOrgId ? `org=${opts.activeOrgId}` : "";
1404
+ const withOrg = (url) => orgParam ? `${url}${url.includes("?") ? "&" : "?"}${orgParam}` : url;
1405
+ const leaves = (children) => children.map((c) => ({ label: c.label, icon: icon(c.icon), href: withOrg(abs(c.path)) }));
1394
1406
  const branches = (children) => children.map((c) => ({
1395
1407
  label: c.label,
1396
1408
  icon: icon(c.icon),
1397
- href: abs(c.path),
1409
+ href: withOrg(abs(c.path)),
1398
1410
  children: c.children ? leaves(c.children) : void 0
1399
1411
  }));
1400
- const orgParam = opts.activeOrgId ? `org=${opts.activeOrgId}` : "";
1401
- const withOrg = (url) => orgParam ? `${url}${url.includes("?") ? "&" : "?"}${orgParam}` : url;
1402
1412
  const buildRelationship = () => {
1403
1413
  const contacts = opts.contactsItem ? {
1404
1414
  label: "Contacts",
@@ -1408,22 +1418,22 @@ function standardPolitogyNav(opts) {
1408
1418
  } : {
1409
1419
  label: "Contacts",
1410
1420
  icon: icon("users"),
1411
- href: abs("/relationship/contacts/dashboard"),
1421
+ href: withOrg(abs("/relationship/contacts/dashboard")),
1412
1422
  children: leaves(CONTACTS_DEFAULT_CHILDREN)
1413
1423
  };
1414
1424
  return [
1415
- { label: "Dashboard", icon: icon("home"), href: abs("/relationship/dashboard") },
1425
+ { label: "Dashboard", icon: icon("home"), href: withOrg(abs("/relationship/dashboard")) },
1416
1426
  contacts,
1417
1427
  {
1418
1428
  label: "Email Broadcast",
1419
1429
  icon: icon("mail"),
1420
- href: abs("/relationship/email/dashboard"),
1430
+ href: withOrg(abs("/relationship/email/dashboard")),
1421
1431
  children: leaves(EMAIL_CHILDREN)
1422
1432
  },
1423
1433
  {
1424
1434
  label: "SMS/MMS Blast",
1425
1435
  icon: icon("messageSquare"),
1426
- href: abs("/relationship/sms-mms-blast/dashboard"),
1436
+ href: withOrg(abs("/relationship/sms-mms-blast/dashboard")),
1427
1437
  children: leaves(SMS_CHILDREN)
1428
1438
  },
1429
1439
  // These two carry no top-level href in VRM core (parent row only toggles).
@@ -1431,25 +1441,25 @@ function standardPolitogyNav(opts) {
1431
1441
  {
1432
1442
  label: "Forms",
1433
1443
  icon: icon("activity"),
1434
- href: "https://forms.politogyvrm.com/",
1435
- children: APP_FORMS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
1444
+ href: withOrg("https://forms.politogyvrm.com/"),
1445
+ children: APP_FORMS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: withOrg(c.href) }))
1436
1446
  },
1437
1447
  {
1438
1448
  label: "Surveys",
1439
1449
  icon: icon("fileText"),
1440
- href: "https://surveys.politogyvrm.com/surveys",
1441
- children: APP_SURVEYS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
1450
+ href: withOrg("https://surveys.politogyvrm.com/surveys"),
1451
+ children: APP_SURVEYS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: withOrg(c.href) }))
1442
1452
  },
1443
1453
  {
1444
1454
  label: "Polls",
1445
1455
  icon: icon("barChart"),
1446
- href: "https://polls.politogyvrm.com/",
1447
- children: APP_POLLS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: c.href }))
1456
+ href: withOrg("https://polls.politogyvrm.com/"),
1457
+ children: APP_POLLS_CHILDREN.map((c) => ({ label: c.label, icon: icon(c.icon), href: withOrg(c.href) }))
1448
1458
  },
1449
1459
  {
1450
1460
  label: "Organization Settings",
1451
1461
  icon: icon("briefcase"),
1452
- href: abs("/organization-settings"),
1462
+ href: withOrg(abs("/organization-settings")),
1453
1463
  children: branches(ORG_SETTINGS_CHILDREN)
1454
1464
  }
1455
1465
  ];
@@ -1588,6 +1598,7 @@ function NavSidebar({
1588
1598
  const activeFn = nav.isActive ?? isNavItemActive;
1589
1599
  const ModeIcon = mode?.icon;
1590
1600
  const orgBg = org?.brandColor || ORG_PILL_DEFAULT;
1601
+ const orgLogoSrc = resolveOrgLogoUrl(org?.logoUrl);
1591
1602
  const activeGroupKey = react.useMemo(() => {
1592
1603
  for (let i = 0; i < nav.items.length; i++) {
1593
1604
  const item = nav.items[i];
@@ -1655,7 +1666,7 @@ function NavSidebar({
1655
1666
  {
1656
1667
  style: { backgroundColor: orgBg },
1657
1668
  className: "flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-bold text-white",
1658
- children: org.logoUrl ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: org.logoUrl, alt: "", className: "h-full w-full object-cover" }) : (org.name ?? "Org").slice(0, 2).toUpperCase()
1669
+ children: orgLogoSrc ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: orgLogoSrc, alt: "", className: "h-full w-full object-cover" }) : (org.name ?? "Org").slice(0, 2).toUpperCase()
1659
1670
  }
1660
1671
  ),
1661
1672
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -1690,7 +1701,14 @@ function NavSidebar({
1690
1701
  {
1691
1702
  style: { backgroundColor: o.brandColor || ORG_PILL_DEFAULT },
1692
1703
  className: "flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full text-[10px] font-bold text-white",
1693
- children: o.logoUrl ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: o.logoUrl, alt: "", className: "h-full w-full object-cover" }) : (o.name ?? "Org").slice(0, 2).toUpperCase()
1704
+ children: resolveOrgLogoUrl(o.logoUrl) ? /* @__PURE__ */ jsxRuntime.jsx(
1705
+ "img",
1706
+ {
1707
+ src: resolveOrgLogoUrl(o.logoUrl),
1708
+ alt: "",
1709
+ className: "h-full w-full object-cover"
1710
+ }
1711
+ ) : (o.name ?? "Org").slice(0, 2).toUpperCase()
1694
1712
  }
1695
1713
  ),
1696
1714
  /* @__PURE__ */ jsxRuntime.jsx("span", { title: o.name ?? o.id, className: "truncate", children: o.name ?? o.id.slice(0, 8) })
@@ -1712,7 +1730,7 @@ function NavSidebar({
1712
1730
  {
1713
1731
  style: { backgroundColor: orgBg },
1714
1732
  className: "flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-bold text-white",
1715
- children: org.logoUrl ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: org.logoUrl, alt: "", className: "h-full w-full object-cover" }) : (org.name ?? "Org").slice(0, 2).toUpperCase()
1733
+ children: orgLogoSrc ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: orgLogoSrc, alt: "", className: "h-full w-full object-cover" }) : (org.name ?? "Org").slice(0, 2).toUpperCase()
1716
1734
  }
1717
1735
  ),
1718
1736
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-sm font-semibold", children: org.name ?? "No organizations" })
@@ -2535,6 +2553,66 @@ function resolveActiveOrg(search) {
2535
2553
  return readActiveOrgCookie();
2536
2554
  }
2537
2555
 
2556
+ // src/nav-preference.ts
2557
+ var NAV_DISPLAY_STORAGE_KEY = "politogy_nav_display";
2558
+ var NAV_DISPLAY_COOKIE = "politogy_nav_display";
2559
+ var LEGACY_NAV_DISPLAY_KEY = "vrm.navDisplay";
2560
+ var NAV_DISPLAY_DEFAULT = "both";
2561
+ var VALID2 = ["menu", "tabs", "both"];
2562
+ function isNavDisplayPreference(v) {
2563
+ return typeof v === "string" && VALID2.includes(v);
2564
+ }
2565
+ function readNavDisplayCookie() {
2566
+ if (typeof document === "undefined") return null;
2567
+ try {
2568
+ const m = document.cookie.match(
2569
+ new RegExp(`(?:^|;\\s*)${NAV_DISPLAY_COOKIE}=([^;]+)`)
2570
+ );
2571
+ const v = m ? decodeURIComponent(m[1]) : null;
2572
+ return isNavDisplayPreference(v) ? v : null;
2573
+ } catch {
2574
+ return null;
2575
+ }
2576
+ }
2577
+ function writeNavDisplayCookie(pref) {
2578
+ if (typeof document === "undefined" || typeof window === "undefined") return;
2579
+ const host = window.location.hostname;
2580
+ const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
2581
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
2582
+ document.cookie = `${NAV_DISPLAY_COOKIE}=${encodeURIComponent(
2583
+ pref
2584
+ )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`;
2585
+ }
2586
+ function readNavDisplayLocal() {
2587
+ if (typeof localStorage === "undefined") return null;
2588
+ try {
2589
+ const current = localStorage.getItem(NAV_DISPLAY_STORAGE_KEY);
2590
+ if (isNavDisplayPreference(current)) return current;
2591
+ const legacy = localStorage.getItem(LEGACY_NAV_DISPLAY_KEY);
2592
+ return isNavDisplayPreference(legacy) ? legacy : null;
2593
+ } catch {
2594
+ return null;
2595
+ }
2596
+ }
2597
+ function writeNavDisplayLocal(pref) {
2598
+ if (typeof localStorage === "undefined") return;
2599
+ try {
2600
+ localStorage.setItem(NAV_DISPLAY_STORAGE_KEY, pref);
2601
+ } catch {
2602
+ }
2603
+ }
2604
+ function resolveNavDisplay() {
2605
+ return readNavDisplayCookie() ?? readNavDisplayLocal() ?? NAV_DISPLAY_DEFAULT;
2606
+ }
2607
+ `(function(){try{
2608
+ var KEY=${JSON.stringify(NAV_DISPLAY_STORAGE_KEY)};
2609
+ var VALID=["menu","tabs","both"];
2610
+ function fromCookie(){try{var m=document.cookie.match(/(?:^|;\\s*)${NAV_DISPLAY_COOKIE}=([^;]+)/);return m?decodeURIComponent(m[1]):null;}catch(e){return null;}}
2611
+ var cookie=fromCookie();
2612
+ if(cookie&&VALID.indexOf(cookie)!==-1){var ls=null;try{ls=localStorage.getItem(KEY);}catch(e){}
2613
+ if(cookie!==ls){try{localStorage.setItem(KEY,cookie);}catch(e){}}}
2614
+ }catch(e){}})();`;
2615
+
2538
2616
  // src/lib/breadcrumbs.ts
2539
2617
  function deriveBreadcrumbs(items, pathname, opts) {
2540
2618
  const search = opts?.search ?? (typeof window !== "undefined" ? window.location.search : "");
@@ -2927,7 +3005,6 @@ var standardNavIcons = {
2927
3005
  };
2928
3006
  var ORG_STORAGE_KEY = "vrm.activeOrg";
2929
3007
  var MODE_STORAGE_KEY = "vrm.activeMode";
2930
- var NAV_DISPLAY_KEY = "vrm.navDisplay";
2931
3008
  var CORE_BASE_URL = "https://app.politogyvrm.com";
2932
3009
  var ChromeStateContext = react.createContext(null);
2933
3010
  function useChromeState() {
@@ -2938,18 +3015,46 @@ function ChromeStateProvider({
2938
3015
  children
2939
3016
  }) {
2940
3017
  const { orgs, loading: orgsFetching, error: orgsError } = useChromeOrgs();
3018
+ const chromeMe = useChromeMe();
3019
+ const chromeFetch = useNavChromeFetcher();
2941
3020
  const [orgId, setOrgIdState] = react.useState(null);
2942
- const [navDisplay, setNavDisplayState] = react.useState("both");
3021
+ const [navDisplay, setNavDisplayState] = react.useState(NAV_DISPLAY_DEFAULT);
3022
+ const navReconciled = react.useRef(false);
2943
3023
  react.useEffect(() => {
2944
3024
  try {
2945
3025
  const storedOrg = resolveActiveOrg() ?? localStorage.getItem(ORG_STORAGE_KEY);
2946
3026
  if (storedOrg) setOrgIdState(storedOrg);
2947
- const storedNav = localStorage.getItem(NAV_DISPLAY_KEY);
2948
- if (storedNav === "menu" || storedNav === "tabs" || storedNav === "both") {
2949
- setNavDisplayState(storedNav);
2950
- }
2951
3027
  } catch {
2952
3028
  }
3029
+ setNavDisplayState(resolveNavDisplay());
3030
+ }, []);
3031
+ react.useEffect(() => {
3032
+ if (navReconciled.current || !chromeMe.me) return;
3033
+ const pref = chromeMe.me.navDisplay;
3034
+ if (!isNavDisplayPreference(pref)) return;
3035
+ navReconciled.current = true;
3036
+ setNavDisplayState(pref);
3037
+ writeNavDisplayCookie(pref);
3038
+ writeNavDisplayLocal(pref);
3039
+ }, [chromeMe]);
3040
+ react.useEffect(() => {
3041
+ const onStorage = (e) => {
3042
+ if (e.key === NAV_DISPLAY_STORAGE_KEY && isNavDisplayPreference(e.newValue)) {
3043
+ setNavDisplayState(e.newValue);
3044
+ }
3045
+ };
3046
+ const onWake = () => {
3047
+ const cookie = readNavDisplayCookie();
3048
+ if (cookie) setNavDisplayState(cookie);
3049
+ };
3050
+ window.addEventListener("storage", onStorage);
3051
+ window.addEventListener("focus", onWake);
3052
+ document.addEventListener("visibilitychange", onWake);
3053
+ return () => {
3054
+ window.removeEventListener("storage", onStorage);
3055
+ window.removeEventListener("focus", onWake);
3056
+ document.removeEventListener("visibilitychange", onWake);
3057
+ };
2953
3058
  }, []);
2954
3059
  react.useEffect(() => {
2955
3060
  if (!orgs || orgs.length === 0) return;
@@ -2963,13 +3068,20 @@ function ChromeStateProvider({
2963
3068
  }
2964
3069
  writeActiveOrgCookie(id);
2965
3070
  }, []);
2966
- const setNavDisplay = react.useCallback((m) => {
2967
- setNavDisplayState(m);
2968
- try {
2969
- localStorage.setItem(NAV_DISPLAY_KEY, m);
2970
- } catch {
2971
- }
2972
- }, []);
3071
+ const setNavDisplay = react.useCallback(
3072
+ (m) => {
3073
+ setNavDisplayState(m);
3074
+ writeNavDisplayLocal(m);
3075
+ writeNavDisplayCookie(m);
3076
+ chromeFetch?.("me/preferences", {
3077
+ method: "PATCH",
3078
+ headers: { "Content-Type": "application/json" },
3079
+ body: JSON.stringify({ navDisplay: m })
3080
+ })?.catch(() => {
3081
+ });
3082
+ },
3083
+ [chromeFetch]
3084
+ );
2973
3085
  const value = react.useMemo(() => {
2974
3086
  if (controlledOrg) {
2975
3087
  return {