@the-portland-company/shell 0.55.0 → 0.57.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.
@@ -169,6 +169,7 @@ var CORE_APP_ORIGIN = "https://app.politogyvrm.com";
169
169
  var CORE_BILLING_PATH = "/organization-settings/subscription-billing/text-email";
170
170
  var CORE_BILLING_URL = `${CORE_APP_ORIGIN}${CORE_BILLING_PATH}`;
171
171
  var QUARANTINED_CONTACTS_URL = `https://contacts.politogyvrm.com/duplicates`;
172
+ var SPECS_ORIGIN = "https://specs.politogyvrm.com";
172
173
  function withOrgParam(url, orgId) {
173
174
  if (!orgId) return url;
174
175
  return `${url}${url.includes("?") ? "&" : "?"}org=${orgId}`;
@@ -291,16 +292,46 @@ function NavThemeControl({
291
292
  )
292
293
  ] });
293
294
  }
295
+ var CHROME_FETCH_CACHE_TTL_MS = 3e4;
296
+ function chromeCacheKey(path, orgId) {
297
+ return orgId === void 0 ? path : `${path}::${orgId ?? ""}`;
298
+ }
299
+ function createChromeFetchCache(getFetchJson) {
300
+ const cache = /* @__PURE__ */ new Map();
301
+ function dedupedFetch(path, init, opts = {}) {
302
+ const key = chromeCacheKey(path, opts.orgId);
303
+ const now = Date.now();
304
+ const hit = cache.get(key);
305
+ if (hit && hit.expiresAt > now) return hit.promise;
306
+ const promise = getFetchJson()(path, init);
307
+ promise.catch(() => cache.delete(key));
308
+ cache.set(key, { promise, expiresAt: now + (opts.ttlMs ?? CHROME_FETCH_CACHE_TTL_MS) });
309
+ return promise;
310
+ }
311
+ function invalidate(path) {
312
+ for (const key of cache.keys()) {
313
+ if (key === path || key.startsWith(`${path}::`)) cache.delete(key);
314
+ }
315
+ }
316
+ return { dedupedFetch, invalidate };
317
+ }
294
318
  var NavChromeContext = react.createContext(null);
295
319
  function NavChromeProvider({
296
320
  fetchJson,
297
321
  children
298
322
  }) {
323
+ const fetchJsonRef = react.useRef(fetchJson);
324
+ fetchJsonRef.current = fetchJson;
325
+ const cacheRef = react.useRef(null);
326
+ if (!cacheRef.current) {
327
+ cacheRef.current = createChromeFetchCache(() => fetchJsonRef.current);
328
+ }
329
+ const { dedupedFetch, invalidate: invalidateChromeCache } = cacheRef.current;
299
330
  const [me, setMe] = react.useState(null);
300
331
  const [meLoaded, setMeLoaded] = react.useState(false);
301
332
  react.useEffect(() => {
302
333
  let cancelled = false;
303
- fetchJson("me").then((data) => {
334
+ dedupedFetch("me", void 0).then((data) => {
304
335
  if (!cancelled) setMe(data);
305
336
  }).catch(() => {
306
337
  }).finally(() => {
@@ -309,8 +340,8 @@ function NavChromeProvider({
309
340
  return () => {
310
341
  cancelled = true;
311
342
  };
312
- }, [fetchJson]);
313
- return /* @__PURE__ */ jsxRuntime.jsx(NavChromeContext.Provider, { value: { fetchJson, me, meLoaded }, children });
343
+ }, [dedupedFetch]);
344
+ return /* @__PURE__ */ jsxRuntime.jsx(NavChromeContext.Provider, { value: { fetchJson, dedupedFetch, invalidateChromeCache, me, meLoaded }, children });
314
345
  }
315
346
  function useChromeMe() {
316
347
  const ctx = react.useContext(NavChromeContext);
@@ -329,7 +360,7 @@ function useNavChrome(component) {
329
360
  return ctx;
330
361
  }
331
362
  function useChromeOrgs() {
332
- const { fetchJson } = useNavChrome("useChromeOrgs");
363
+ const { dedupedFetch } = useNavChrome("useChromeOrgs");
333
364
  const [orgs, setOrgs] = react.useState(null);
334
365
  const [loading, setLoading] = react.useState(true);
335
366
  const [error, setError] = react.useState(false);
@@ -337,7 +368,7 @@ function useChromeOrgs() {
337
368
  let cancelled = false;
338
369
  setLoading(true);
339
370
  setError(false);
340
- fetchJson("orgs").then((data) => {
371
+ dedupedFetch("orgs", void 0).then((data) => {
341
372
  if (cancelled) return;
342
373
  const list = data?.orgs ?? [];
343
374
  setOrgs(list);
@@ -349,7 +380,7 @@ function useChromeOrgs() {
349
380
  return () => {
350
381
  cancelled = true;
351
382
  };
352
- }, [fetchJson]);
383
+ }, [dedupedFetch]);
353
384
  return { orgs, loading, error };
354
385
  }
355
386
  function ValueSkeleton({ className }) {
@@ -633,7 +664,7 @@ function LowFuelHint({ lowFuel, orgId }) {
633
664
  );
634
665
  }
635
666
  function FuelAction({ orgId }) {
636
- const { fetchJson } = useNavChrome("FuelAction");
667
+ const { dedupedFetch } = useNavChrome("FuelAction");
637
668
  const [fuel, setFuel] = react.useState(null);
638
669
  const [loading, setLoading] = react.useState(true);
639
670
  const [error, setError] = react.useState(false);
@@ -642,9 +673,11 @@ function FuelAction({ orgId }) {
642
673
  setLoading(true);
643
674
  setError(false);
644
675
  setFuel(null);
645
- fetchJson("communications/fuel-status", {
646
- headers: orgId ? { "x-contacts-org": orgId } : void 0
647
- }).then((data) => {
676
+ dedupedFetch(
677
+ "communications/fuel-status",
678
+ { headers: orgId ? { "x-contacts-org": orgId } : void 0 },
679
+ { orgId }
680
+ ).then((data) => {
648
681
  if (cancelled) return;
649
682
  setFuel(data?.fuel ?? null);
650
683
  }).catch(() => {
@@ -655,7 +688,7 @@ function FuelAction({ orgId }) {
655
688
  return () => {
656
689
  cancelled = true;
657
690
  };
658
- }, [fetchJson, orgId]);
691
+ }, [dedupedFetch, orgId]);
659
692
  const low = !!fuel && (fuel.signal.lowFuel || fuel.broadcast.lowFuel);
660
693
  return (
661
694
  // The nudge anchors to the trigger, so it sits OUTSIDE the popover panel in
@@ -726,7 +759,6 @@ function writeNotifState(orgId, state) {
726
759
  } catch {
727
760
  }
728
761
  }
729
- var TEN_DLC_REDIRECTED_KEY = "politogy_10dlc_redirected";
730
762
  var TOASTS_MUTED_KEY = "politogy_chrome_toasts_muted";
731
763
  function readToastsMuted() {
732
764
  if (typeof window === "undefined") return false;
@@ -857,7 +889,7 @@ function NewReplyToast({
857
889
  );
858
890
  }
859
891
  function NotificationsAction({ orgId }) {
860
- const { fetchJson } = useNavChrome("NotificationsAction");
892
+ const { dedupedFetch } = useNavChrome("NotificationsAction");
861
893
  const [payload, setPayload] = react.useState(null);
862
894
  const [loading, setLoading] = react.useState(true);
863
895
  const [error, setError] = react.useState(false);
@@ -876,9 +908,11 @@ function NotificationsAction({ orgId }) {
876
908
  setLoading(true);
877
909
  setError(false);
878
910
  setPayload(null);
879
- fetchJson("notifications", {
880
- headers: orgId ? { "x-contacts-org": orgId } : void 0
881
- }).then((data) => {
911
+ dedupedFetch(
912
+ "notifications",
913
+ { headers: orgId ? { "x-contacts-org": orgId } : void 0 },
914
+ { orgId }
915
+ ).then((data) => {
882
916
  if (!cancelled) setPayload(data);
883
917
  }).catch(() => {
884
918
  if (!cancelled) setError(true);
@@ -888,29 +922,7 @@ function NotificationsAction({ orgId }) {
888
922
  return () => {
889
923
  cancelled = true;
890
924
  };
891
- }, [fetchJson, orgId]);
892
- react.useEffect(() => {
893
- if (!payload || typeof window === "undefined") return;
894
- const alert = payload.alerts.find((a) => a.kind === "10dlc_registration" && a.href);
895
- if (!alert?.href) return;
896
- try {
897
- if (window.sessionStorage.getItem(TEN_DLC_REDIRECTED_KEY) === "1") return;
898
- } catch {
899
- return;
900
- }
901
- let target;
902
- try {
903
- target = new URL(alert.href, window.location.href);
904
- } catch {
905
- return;
906
- }
907
- if (target.origin !== window.location.origin) return;
908
- try {
909
- window.sessionStorage.setItem(TEN_DLC_REDIRECTED_KEY, "1");
910
- } catch {
911
- }
912
- window.location.assign(alert.href);
913
- }, [payload]);
925
+ }, [dedupedFetch, orgId]);
914
926
  const markRead = react.useCallback(
915
927
  (id) => {
916
928
  setNotif((prev) => {
@@ -1311,6 +1323,216 @@ function SupportAction({ orgId } = {}) {
1311
1323
  ) : null
1312
1324
  ] });
1313
1325
  }
1326
+ var SPEC_OVERLAY_ACCESS_CACHE_KEY = "politogy_spec_overlay_access";
1327
+ function readSpecOverlayAccessCache() {
1328
+ if (typeof window === "undefined") return null;
1329
+ try {
1330
+ const raw = window.sessionStorage.getItem(SPEC_OVERLAY_ACCESS_CACHE_KEY);
1331
+ if (!raw) return null;
1332
+ const parsed = JSON.parse(raw);
1333
+ if (typeof parsed?.allowed === "boolean") return parsed;
1334
+ } catch {
1335
+ }
1336
+ return null;
1337
+ }
1338
+ function writeSpecOverlayAccessCache(access) {
1339
+ if (typeof window === "undefined") return;
1340
+ try {
1341
+ window.sessionStorage.setItem(SPEC_OVERLAY_ACCESS_CACHE_KEY, JSON.stringify(access));
1342
+ } catch {
1343
+ }
1344
+ }
1345
+ var locationChangeListeners = /* @__PURE__ */ new Set();
1346
+ var historyPatched = false;
1347
+ function ensureHistoryPatched() {
1348
+ if (historyPatched || typeof window === "undefined") return;
1349
+ historyPatched = true;
1350
+ const notify = () => locationChangeListeners.forEach((fn) => fn());
1351
+ const originalPushState = window.history.pushState.bind(window.history);
1352
+ const originalReplaceState = window.history.replaceState.bind(window.history);
1353
+ window.history.pushState = (...args) => {
1354
+ originalPushState(...args);
1355
+ notify();
1356
+ };
1357
+ window.history.replaceState = (...args) => {
1358
+ originalReplaceState(...args);
1359
+ notify();
1360
+ };
1361
+ window.addEventListener("popstate", notify);
1362
+ }
1363
+ function useLocationHref() {
1364
+ const [href, setHref] = react.useState(
1365
+ () => typeof window === "undefined" ? "" : window.location.href
1366
+ );
1367
+ react.useEffect(() => {
1368
+ ensureHistoryPatched();
1369
+ const onChange = () => setHref(window.location.href);
1370
+ locationChangeListeners.add(onChange);
1371
+ return () => {
1372
+ locationChangeListeners.delete(onChange);
1373
+ };
1374
+ }, []);
1375
+ return href;
1376
+ }
1377
+ function specOverlayFetchJson(path) {
1378
+ return fetch(`${SPECS_ORIGIN}${path}`, { credentials: "include" }).then((res) => {
1379
+ if (!res.ok) throw new Error(`spec overlay ${path}: ${res.status}`);
1380
+ return res.json();
1381
+ });
1382
+ }
1383
+ function SpecOverlayAction({ orgId } = {}) {
1384
+ const [access, setAccess] = react.useState(() => readSpecOverlayAccessCache());
1385
+ const [status, setStatus] = react.useState(null);
1386
+ const [open, setOpen] = react.useState(false);
1387
+ const href = useLocationHref();
1388
+ react.useEffect(() => {
1389
+ if (access) return;
1390
+ let cancelled = false;
1391
+ specOverlayFetchJson("/api/overlay/access").then((data) => {
1392
+ if (cancelled) return;
1393
+ const next = data && typeof data.allowed === "boolean" ? data : { allowed: false };
1394
+ setAccess(next);
1395
+ writeSpecOverlayAccessCache(next);
1396
+ }).catch(() => {
1397
+ if (cancelled) return;
1398
+ const next = { allowed: false };
1399
+ setAccess(next);
1400
+ writeSpecOverlayAccessCache(next);
1401
+ });
1402
+ return () => {
1403
+ cancelled = true;
1404
+ };
1405
+ }, [access]);
1406
+ react.useEffect(() => {
1407
+ if (!access?.allowed || typeof window === "undefined") return;
1408
+ const timer = window.setTimeout(() => {
1409
+ const url = `/api/overlay/status?url=${encodeURIComponent(window.location.href)}`;
1410
+ specOverlayFetchJson(url).then((data) => setStatus(data)).catch(() => setStatus(null));
1411
+ }, 300);
1412
+ return () => window.clearTimeout(timer);
1413
+ }, [access?.allowed, href, orgId]);
1414
+ if (!access?.allowed) return null;
1415
+ const badgeLabel = status && status.matched ? `${Math.round(status.mismatch_pct)}%` : status ? "\u2014" : void 0;
1416
+ const badgeTone = status && status.matched ? status.passed ? "default" : "danger" : "default";
1417
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1418
+ /* @__PURE__ */ jsxRuntime.jsxs(
1419
+ "button",
1420
+ {
1421
+ type: "button",
1422
+ "aria-label": "Spec",
1423
+ "aria-expanded": open,
1424
+ className: topBarIconButtonClass(badgeTone),
1425
+ onClick: () => setOpen(true),
1426
+ children: [
1427
+ /* @__PURE__ */ jsxRuntime.jsx(LayoutGlyph, { size: 20, className: "text-current" }),
1428
+ badgeLabel ? /* @__PURE__ */ jsxRuntime.jsx(
1429
+ "span",
1430
+ {
1431
+ className: [
1432
+ "absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-normal leading-none text-white",
1433
+ status && status.matched ? status.passed ? "bg-[color:var(--chrome-success,#2f855a)]" : "bg-[color:var(--chrome-danger)]" : "bg-[color:var(--muted)]"
1434
+ ].join(" "),
1435
+ children: badgeLabel
1436
+ }
1437
+ ) : null,
1438
+ !open ? /* @__PURE__ */ jsxRuntime.jsx(
1439
+ TopBarTooltip,
1440
+ {
1441
+ title: "Spec",
1442
+ description: "Spec design + visual regression for this page."
1443
+ }
1444
+ ) : null
1445
+ ]
1446
+ }
1447
+ ),
1448
+ open && typeof document !== "undefined" ? reactDom.createPortal(
1449
+ /* @__PURE__ */ jsxRuntime.jsx(
1450
+ "div",
1451
+ {
1452
+ className: "fixed inset-0 z-50 flex justify-end bg-black/40",
1453
+ role: "dialog",
1454
+ "aria-modal": "true",
1455
+ "aria-label": "Spec overlay",
1456
+ onMouseDown: (e) => {
1457
+ if (e.target === e.currentTarget) setOpen(false);
1458
+ },
1459
+ onKeyDown: (e) => {
1460
+ if (e.key === "Escape") setOpen(false);
1461
+ },
1462
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex h-full w-full max-w-[480px] flex-col border-l border-[color:var(--line)] bg-[color:var(--panel-elevated)] shadow-[var(--shadow-soft)]", children: [
1463
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-[color:var(--line)] px-4 py-3", children: [
1464
+ /* @__PURE__ */ jsxRuntime.jsxs("h2", { className: "truncate text-sm font-bold tracking-[-0.01em] text-[color:var(--foreground)]", children: [
1465
+ "Spec",
1466
+ status && status.matched ? `: ${status.spec_title}` : ""
1467
+ ] }),
1468
+ /* @__PURE__ */ jsxRuntime.jsx(
1469
+ "button",
1470
+ {
1471
+ type: "button",
1472
+ "aria-label": "Close",
1473
+ onClick: () => setOpen(false),
1474
+ className: "rounded-full p-1 text-[color:var(--muted)] hover:bg-[color:var(--panel)] hover:text-[color:var(--foreground)]",
1475
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseGlyph, { size: 16 })
1476
+ }
1477
+ )
1478
+ ] }),
1479
+ status && status.matched ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1480
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 border-b border-[color:var(--line)] px-4 py-2 text-xs font-semibold", children: [
1481
+ /* @__PURE__ */ jsxRuntime.jsx(
1482
+ "a",
1483
+ {
1484
+ href: status.spec_url,
1485
+ target: "_blank",
1486
+ rel: "noreferrer",
1487
+ className: "text-[color:var(--color-accent)] hover:underline",
1488
+ children: "Open spec \u2197"
1489
+ }
1490
+ ),
1491
+ /* @__PURE__ */ jsxRuntime.jsx(
1492
+ "a",
1493
+ {
1494
+ href: status.compare_url,
1495
+ target: "_blank",
1496
+ rel: "noreferrer",
1497
+ className: "text-[color:var(--color-accent)] hover:underline",
1498
+ children: "Compare tab \u2197"
1499
+ }
1500
+ )
1501
+ ] }),
1502
+ /* @__PURE__ */ jsxRuntime.jsx(
1503
+ "iframe",
1504
+ {
1505
+ src: status.overlay_url,
1506
+ title: "Spec overlay",
1507
+ className: "h-full w-full flex-1 border-0"
1508
+ }
1509
+ )
1510
+ ] }) : status && !status.matched ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 text-sm text-[color:var(--muted)]", children: [
1511
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "No compare target covers this page." }),
1512
+ /* @__PURE__ */ jsxRuntime.jsx(
1513
+ "a",
1514
+ {
1515
+ href: status.search_url,
1516
+ target: "_blank",
1517
+ rel: "noreferrer",
1518
+ className: "mt-2 inline-block font-semibold text-[color:var(--color-accent)] hover:underline",
1519
+ children: "Search specs \u2197"
1520
+ }
1521
+ )
1522
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4", children: /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-4 w-full" }) })
1523
+ ] })
1524
+ }
1525
+ ),
1526
+ document.body
1527
+ ) : null
1528
+ ] });
1529
+ }
1530
+ function CloseGlyph({ size = 16, className }) {
1531
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className, "aria-hidden": true, children: [
1532
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
1533
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
1534
+ ] });
1535
+ }
1314
1536
  var NAV_DISPLAY_OPTIONS = [
1315
1537
  { value: "menu", label: "Sub Menu" },
1316
1538
  { value: "tabs", label: "Tabs Only" },
@@ -1494,11 +1716,6 @@ var APP_EMAIL_CHILDREN = [
1494
1716
  { label: "Deliverability", icon: "activity", href: `${EMAIL_APP}/deliverability` }
1495
1717
  ];
1496
1718
  var EMAIL_V1_LEGACY = { label: "Email V1 (legacy)", icon: "clock", path: "/relationship/email/dashboard" };
1497
- var SMS_CHILDREN = [
1498
- { label: "Dashboard", icon: "grid", path: "/relationship/sms-mms-blast/dashboard" },
1499
- { label: "Compose & Send", icon: "send", path: "/relationship/sms-mms-blast/compose-and-send" },
1500
- { label: "Conversations", icon: "messageSquare", path: "/relationship/sms-mms-blast/conversations" }
1501
- ];
1502
1719
  var CTA_CHILDREN = [
1503
1720
  { label: "Dashboard", icon: "grid", path: "/relationship/cta-database/dashboard" },
1504
1721
  { label: "Create & Edit", icon: "edit", path: "/relationship/cta-creator/create-and-edit" },
@@ -1579,6 +1796,14 @@ function standardPolitogyNav(opts) {
1579
1796
  href: withOrg(abs(c.path)),
1580
1797
  children: c.children ? leaves(c.children) : void 0
1581
1798
  }));
1799
+ const appLeaves = (origin, children, modeParam) => {
1800
+ const base2 = origin.replace(/\/+$/, "");
1801
+ return children.map((c) => {
1802
+ const url = c.path ? `${base2}/${c.path}` : `${base2}/`;
1803
+ const withMode = modeParam ? `${url}${url.includes("?") ? "&" : "?"}mode=${modeParam}` : url;
1804
+ return { label: c.label, icon: icon(c.icon), href: withOrg(withMode) };
1805
+ });
1806
+ };
1582
1807
  const buildRelationship = () => {
1583
1808
  const contacts = opts.contactsItem ? {
1584
1809
  label: "Contacts",
@@ -1607,11 +1832,17 @@ function standardPolitogyNav(opts) {
1607
1832
  ...leaves([EMAIL_V1_LEGACY])
1608
1833
  ]
1609
1834
  },
1835
+ // SMS/MMS Blast IS a2p (Specs/A2P) — the standalone delivery engine
1836
+ // shared with Campaign Mode, not a core-hosted surface (the old
1837
+ // `/relationship/sms-mms-blast/*` core routes are dead; core no longer
1838
+ // hosts them). Every link carries `mode=relationship` so a2p's nav
1839
+ // resolves the Relationship tree instead of its own Campaign default
1840
+ // (resolveActiveMode, mode-preference.ts).
1610
1841
  {
1611
1842
  label: "SMS/MMS Blast",
1612
1843
  icon: icon("messageSquare"),
1613
- href: withOrg(abs("/relationship/sms-mms-blast/dashboard")),
1614
- children: leaves(SMS_CHILDREN)
1844
+ href: withOrg(`${A2P_ORIGIN}?mode=relationship`),
1845
+ children: appLeaves(A2P_ORIGIN, APP_A2P_CHILDREN, "relationship")
1615
1846
  },
1616
1847
  // These two carry no top-level href in VRM core (parent row only toggles).
1617
1848
  { label: "Calls to Action", icon: icon("layout"), children: leaves(CTA_CHILDREN) },
@@ -1652,14 +1883,6 @@ function standardPolitogyNav(opts) {
1652
1883
  ...CAMPAIGN_FEATURE_AVAILABILITY,
1653
1884
  ...opts.campaignAvailability
1654
1885
  };
1655
- const appLeaves = (origin, children) => {
1656
- const base2 = origin.replace(/\/+$/, "");
1657
- return children.map((c) => ({
1658
- label: c.label,
1659
- icon: icon(c.icon),
1660
- href: withOrg(c.path ? `${base2}/${c.path}` : `${base2}/`)
1661
- }));
1662
- };
1663
1886
  const rows = [
1664
1887
  { label: "Dashboard", icon: icon("home"), href: withOrg(abs("/campaign/dashboard")) },
1665
1888
  { label: "Voter Contact", icon: icon("target"), href: withOrg(abs("/campaign/campaigns")) },
@@ -3256,6 +3479,58 @@ function watchLogout(onLogout, options = {}) {
3256
3479
  }
3257
3480
  return cleanup;
3258
3481
  }
3482
+
3483
+ // src/mode-preference.ts
3484
+ var ACTIVE_MODE_COOKIE = "politogy_active_mode";
3485
+ var VALID_MODES = ["relationship", "campaign", "petition"];
3486
+ function isStandardNavMode(v) {
3487
+ return typeof v === "string" && VALID_MODES.includes(v);
3488
+ }
3489
+ function readActiveModeCookie() {
3490
+ if (typeof document === "undefined") return null;
3491
+ try {
3492
+ const m = document.cookie.match(
3493
+ new RegExp(`(?:^|;\\s*)${ACTIVE_MODE_COOKIE}=([^;]+)`)
3494
+ );
3495
+ const v = m ? decodeURIComponent(m[1]) : null;
3496
+ return isStandardNavMode(v) ? v : null;
3497
+ } catch {
3498
+ return null;
3499
+ }
3500
+ }
3501
+ function writeActiveModeCookie(mode) {
3502
+ if (typeof document === "undefined" || typeof window === "undefined") return;
3503
+ const host = window.location.hostname;
3504
+ const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
3505
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
3506
+ document.cookie = `${ACTIVE_MODE_COOKIE}=${encodeURIComponent(
3507
+ mode
3508
+ )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`;
3509
+ }
3510
+ function modeFromSearch(search) {
3511
+ if (!search) return null;
3512
+ try {
3513
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
3514
+ return params.get("mode");
3515
+ } catch {
3516
+ return null;
3517
+ }
3518
+ }
3519
+ function resolveActiveMode({
3520
+ search,
3521
+ modes,
3522
+ defaultMode
3523
+ }) {
3524
+ const fromQuery = modeFromSearch(search);
3525
+ if (fromQuery && isStandardNavMode(fromQuery) && modes.includes(fromQuery)) {
3526
+ return fromQuery;
3527
+ }
3528
+ const fromCookie = readActiveModeCookie();
3529
+ if (fromCookie && modes.includes(fromCookie)) {
3530
+ return fromCookie;
3531
+ }
3532
+ return defaultMode;
3533
+ }
3259
3534
  var BOOT_STAGE_TABLE = [
3260
3535
  { id: "s0_session", weight: 5, critical: true, waitsOn: "Politogy ID session resolution" },
3261
3536
  { id: "s1_org", weight: 10, critical: true, waitsOn: "Chrome API /api/chrome/me + orgs" },
@@ -3625,7 +3900,17 @@ var POLITOGY_APPS = {
3625
3900
  // Campaign Mode apps — each scoped to Campaign, so they render the Campaign
3626
3901
  // tree and a static Campaign pill. Not yet deployed; hidden from the Campaign
3627
3902
  // nav by CAMPAIGN_FEATURE_AVAILABILITY until their launch flips the flag.
3628
- a2p: { title: "A2P Messaging", origin: "https://a2p.politogyvrm.com", storagePrefix: "a2p", mode: "campaign" },
3903
+ // Dual-mode: a2p is the SMS/MMS delivery engine for BOTH Relationship Mode
3904
+ // and Campaign Mode (Specs/A2P). `mode: 'campaign'` stays the fallback for
3905
+ // anything reading `meta.mode` directly; the effective mode a user sees is
3906
+ // resolved per-visit (see `dual`, resolveActiveMode).
3907
+ a2p: {
3908
+ title: "A2P Messaging",
3909
+ origin: "https://a2p.politogyvrm.com",
3910
+ storagePrefix: "a2p",
3911
+ mode: "campaign",
3912
+ dual: { modes: ["relationship", "campaign"], defaultMode: "campaign" }
3913
+ },
3629
3914
  phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign" },
3630
3915
  canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign" },
3631
3916
  signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign" }
@@ -3762,9 +4047,19 @@ function FrameBody({
3762
4047
  });
3763
4048
  const userMenu = core?.userMenu ?? derivedUserMenu;
3764
4049
  const footer = useChromeFooter(buildMeta ?? {});
3765
- const appMode = meta.mode;
4050
+ const resolvedDualMode = meta.dual ? resolveActiveMode({
4051
+ search: search ?? (typeof window !== "undefined" ? window.location.search : void 0),
4052
+ modes: meta.dual.modes,
4053
+ defaultMode: meta.dual.defaultMode
4054
+ }) : void 0;
4055
+ const appMode = resolvedDualMode ?? meta.mode;
3766
4056
  const derivedMode = useChromeMode(appMode ? { pinnedModeId: appMode } : void 0);
3767
4057
  const mode = core?.mode ?? derivedMode;
4058
+ const coreLiveMode = app === CORE_APP ? pathname.startsWith("/campaign") ? "campaign" : pathname.startsWith("/relationship") ? "relationship" : void 0 : void 0;
4059
+ const cookieMode = coreLiveMode ?? (meta.dual ? void 0 : meta.mode);
4060
+ react.useEffect(() => {
4061
+ if (cookieMode) writeActiveModeCookie(cookieMode);
4062
+ }, [cookieMode]);
3768
4063
  const coreOrgs = chrome?.orgs ?? [];
3769
4064
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
3770
4065
  const navDisplay = chrome?.navDisplay ?? "both";
@@ -4137,18 +4432,71 @@ function AppDevNotes({
4137
4432
  }),
4138
4433
  [storagePrefix, identity.role]
4139
4434
  );
4140
- if (devNotes?.providedByApp) return /* @__PURE__ */ jsxRuntime.jsx(DevNotesSurface, { children });
4141
- return /* @__PURE__ */ jsxRuntime.jsx(devnotes.DevNotesProvider, { adapter, user, config, children: /* @__PURE__ */ jsxRuntime.jsx(DevNotesSurface, { children }) });
4435
+ const activatedRef = react.useRef(false);
4436
+ const activationWaitersRef = react.useRef([]);
4437
+ const activate = react.useCallback(() => {
4438
+ if (activatedRef.current) return;
4439
+ activatedRef.current = true;
4440
+ const waiters = activationWaitersRef.current;
4441
+ activationWaitersRef.current = [];
4442
+ waiters.forEach((resolve) => resolve());
4443
+ }, []);
4444
+ const waitForActivation = react.useCallback(() => {
4445
+ if (activatedRef.current) return Promise.resolve();
4446
+ return new Promise((resolve) => {
4447
+ activationWaitersRef.current.push(resolve);
4448
+ });
4449
+ }, []);
4450
+ const lazyAdapter = react.useMemo(() => {
4451
+ const GATED_METHODS = [
4452
+ "fetchTasks",
4453
+ "fetchTaskTypes",
4454
+ "fetchTaskLists",
4455
+ "fetchCollaborators",
4456
+ "fetchCapabilities",
4457
+ "getAppLinkStatus",
4458
+ "fetchBugReports",
4459
+ "fetchBugReportTypes"
4460
+ ];
4461
+ const wrapped = { ...adapter };
4462
+ for (const method of GATED_METHODS) {
4463
+ const original = adapter[method].bind(adapter);
4464
+ wrapped[method] = (...args) => waitForActivation().then(() => original(...args));
4465
+ }
4466
+ let idleScheduled = false;
4467
+ const originalUnreadCounts = adapter.fetchUnreadCounts.bind(adapter);
4468
+ wrapped.fetchUnreadCounts = () => {
4469
+ if (idleScheduled || typeof window === "undefined") return originalUnreadCounts();
4470
+ idleScheduled = true;
4471
+ return new Promise((resolve, reject) => {
4472
+ const run = () => originalUnreadCounts().then(resolve, reject);
4473
+ const ric = window.requestIdleCallback;
4474
+ if (ric) ric(run);
4475
+ else setTimeout(run, 0);
4476
+ });
4477
+ };
4478
+ return wrapped;
4479
+ }, [adapter, waitForActivation]);
4480
+ if (devNotes?.providedByApp) return /* @__PURE__ */ jsxRuntime.jsx(DevNotesSurface, { activate, children });
4481
+ return /* @__PURE__ */ jsxRuntime.jsx(devnotes.DevNotesProvider, { adapter: lazyAdapter, user, config, children: /* @__PURE__ */ jsxRuntime.jsx(DevNotesSurface, { activate, children }) });
4142
4482
  }
4143
4483
  var DevNotesTasksContext = react.createContext({
4144
4484
  ready: false,
4145
4485
  openTasks: () => {
4486
+ },
4487
+ activate: () => {
4146
4488
  }
4147
4489
  });
4148
- function DevNotesSurface({ children }) {
4490
+ function DevNotesSurface({
4491
+ activate,
4492
+ children
4493
+ }) {
4149
4494
  const [tasksOpen, setTasksOpen] = react.useState(false);
4150
- const openTasks = react.useCallback(() => setTasksOpen(true), []);
4151
- const tasksCtx = react.useMemo(() => ({ ready: true, openTasks }), [openTasks]);
4495
+ const openTasks = react.useCallback(() => {
4496
+ activate();
4497
+ setTasksOpen(true);
4498
+ }, [activate]);
4499
+ const tasksCtx = react.useMemo(() => ({ ready: true, openTasks, activate }), [openTasks, activate]);
4152
4500
  return /* @__PURE__ */ jsxRuntime.jsxs(DevNotesTasksContext.Provider, { value: tasksCtx, children: [
4153
4501
  children,
4154
4502
  tasksOpen ? /* @__PURE__ */ jsxRuntime.jsx(devnotes.DevNotesTaskListModal, { open: true, onClose: () => setTasksOpen(false) }) : null,
@@ -4204,14 +4552,15 @@ function ChromeTopBarActions() {
4204
4552
  children: /* @__PURE__ */ jsxRuntime.jsx(ChromeDevNotesMenu, {})
4205
4553
  }
4206
4554
  ),
4555
+ /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId }),
4207
4556
  /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId }),
4208
4557
  /* @__PURE__ */ jsxRuntime.jsx(NotificationsAction, { orgId: state.orgId })
4209
4558
  ] });
4210
4559
  }
4211
4560
  function ChromeDevNotesMenu() {
4212
- const { ready, openTasks } = react.useContext(DevNotesTasksContext);
4561
+ const { ready, openTasks, activate } = react.useContext(DevNotesTasksContext);
4213
4562
  if (!ready) return null;
4214
- return /* @__PURE__ */ jsxRuntime.jsx(devnotes.DevNotesMenu, { position: "top-right", onViewTasks: openTasks });
4563
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { style: { display: "contents" }, onPointerDownCapture: activate, onFocusCapture: activate, onKeyDownCapture: activate, children: /* @__PURE__ */ jsxRuntime.jsx(devnotes.DevNotesMenu, { position: "top-right", onViewTasks: openTasks }) });
4215
4564
  }
4216
4565
  function useIsSuperAdmin() {
4217
4566
  const state = useChromeState();