@the-portland-company/shell 0.55.0 → 0.56.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/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.56.0 — 2026-09-30
2
+
3
+ ### Fixed — mode continuity for dual-mode apps (a2p)
4
+
5
+ a2p (Specs/A2P) is the SMS/MMS delivery engine for BOTH Relationship Mode and
6
+ Campaign Mode, but it was registered `mode: 'campaign'` only, so the
7
+ Relationship tree's "SMS/MMS Blast" row sent users into a2p and the whole left
8
+ nav flipped to the Campaign tree + "Campaign Mode" pill — a jarring identity
9
+ switch for a user who never left Relationship Mode.
10
+
11
+ `POLITOGY_APPS[app]` now supports an optional `dual: { modes, defaultMode }`.
12
+ a2p declares `dual: { modes: ['relationship', 'campaign'], defaultMode:
13
+ 'campaign' }`; its EFFECTIVE mode is resolved per-visit by the new
14
+ `resolveActiveMode` (`src/mode-preference.ts`): `?mode=` query param → the new
15
+ cross-subdomain `politogy_active_mode` cookie → the app's own default. Every
16
+ pinned app and core writes that cookie with its own mode, so arriving at a2p
17
+ after using Relationship Mode elsewhere resolves to Relationship even without
18
+ an explicit `?mode=` link. The Relationship tree's "SMS/MMS Blast" row now
19
+ links straight to a2p with `?mode=relationship` (the dead
20
+ `/relationship/sms-mms-blast/*` core routes are removed); the Campaign tree's
21
+ existing A2P row is unchanged.
22
+
1
23
  ## 0.55.0 — 2026-09-30
2
24
 
3
25
  ### Fixed — org pill body no longer takes the Mode accent (`./native`)
@@ -1494,11 +1494,6 @@ var APP_EMAIL_CHILDREN = [
1494
1494
  { label: "Deliverability", icon: "activity", href: `${EMAIL_APP}/deliverability` }
1495
1495
  ];
1496
1496
  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
1497
  var CTA_CHILDREN = [
1503
1498
  { label: "Dashboard", icon: "grid", path: "/relationship/cta-database/dashboard" },
1504
1499
  { label: "Create & Edit", icon: "edit", path: "/relationship/cta-creator/create-and-edit" },
@@ -1579,6 +1574,14 @@ function standardPolitogyNav(opts) {
1579
1574
  href: withOrg(abs(c.path)),
1580
1575
  children: c.children ? leaves(c.children) : void 0
1581
1576
  }));
1577
+ const appLeaves = (origin, children, modeParam) => {
1578
+ const base2 = origin.replace(/\/+$/, "");
1579
+ return children.map((c) => {
1580
+ const url = c.path ? `${base2}/${c.path}` : `${base2}/`;
1581
+ const withMode = modeParam ? `${url}${url.includes("?") ? "&" : "?"}mode=${modeParam}` : url;
1582
+ return { label: c.label, icon: icon(c.icon), href: withOrg(withMode) };
1583
+ });
1584
+ };
1582
1585
  const buildRelationship = () => {
1583
1586
  const contacts = opts.contactsItem ? {
1584
1587
  label: "Contacts",
@@ -1607,11 +1610,17 @@ function standardPolitogyNav(opts) {
1607
1610
  ...leaves([EMAIL_V1_LEGACY])
1608
1611
  ]
1609
1612
  },
1613
+ // SMS/MMS Blast IS a2p (Specs/A2P) — the standalone delivery engine
1614
+ // shared with Campaign Mode, not a core-hosted surface (the old
1615
+ // `/relationship/sms-mms-blast/*` core routes are dead; core no longer
1616
+ // hosts them). Every link carries `mode=relationship` so a2p's nav
1617
+ // resolves the Relationship tree instead of its own Campaign default
1618
+ // (resolveActiveMode, mode-preference.ts).
1610
1619
  {
1611
1620
  label: "SMS/MMS Blast",
1612
1621
  icon: icon("messageSquare"),
1613
- href: withOrg(abs("/relationship/sms-mms-blast/dashboard")),
1614
- children: leaves(SMS_CHILDREN)
1622
+ href: withOrg(`${A2P_ORIGIN}?mode=relationship`),
1623
+ children: appLeaves(A2P_ORIGIN, APP_A2P_CHILDREN, "relationship")
1615
1624
  },
1616
1625
  // These two carry no top-level href in VRM core (parent row only toggles).
1617
1626
  { label: "Calls to Action", icon: icon("layout"), children: leaves(CTA_CHILDREN) },
@@ -1652,14 +1661,6 @@ function standardPolitogyNav(opts) {
1652
1661
  ...CAMPAIGN_FEATURE_AVAILABILITY,
1653
1662
  ...opts.campaignAvailability
1654
1663
  };
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
1664
  const rows = [
1664
1665
  { label: "Dashboard", icon: icon("home"), href: withOrg(abs("/campaign/dashboard")) },
1665
1666
  { label: "Voter Contact", icon: icon("target"), href: withOrg(abs("/campaign/campaigns")) },
@@ -3256,6 +3257,58 @@ function watchLogout(onLogout, options = {}) {
3256
3257
  }
3257
3258
  return cleanup;
3258
3259
  }
3260
+
3261
+ // src/mode-preference.ts
3262
+ var ACTIVE_MODE_COOKIE = "politogy_active_mode";
3263
+ var VALID_MODES = ["relationship", "campaign", "petition"];
3264
+ function isStandardNavMode(v) {
3265
+ return typeof v === "string" && VALID_MODES.includes(v);
3266
+ }
3267
+ function readActiveModeCookie() {
3268
+ if (typeof document === "undefined") return null;
3269
+ try {
3270
+ const m = document.cookie.match(
3271
+ new RegExp(`(?:^|;\\s*)${ACTIVE_MODE_COOKIE}=([^;]+)`)
3272
+ );
3273
+ const v = m ? decodeURIComponent(m[1]) : null;
3274
+ return isStandardNavMode(v) ? v : null;
3275
+ } catch {
3276
+ return null;
3277
+ }
3278
+ }
3279
+ function writeActiveModeCookie(mode) {
3280
+ if (typeof document === "undefined" || typeof window === "undefined") return;
3281
+ const host = window.location.hostname;
3282
+ const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
3283
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
3284
+ document.cookie = `${ACTIVE_MODE_COOKIE}=${encodeURIComponent(
3285
+ mode
3286
+ )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`;
3287
+ }
3288
+ function modeFromSearch(search) {
3289
+ if (!search) return null;
3290
+ try {
3291
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
3292
+ return params.get("mode");
3293
+ } catch {
3294
+ return null;
3295
+ }
3296
+ }
3297
+ function resolveActiveMode({
3298
+ search,
3299
+ modes,
3300
+ defaultMode
3301
+ }) {
3302
+ const fromQuery = modeFromSearch(search);
3303
+ if (fromQuery && isStandardNavMode(fromQuery) && modes.includes(fromQuery)) {
3304
+ return fromQuery;
3305
+ }
3306
+ const fromCookie = readActiveModeCookie();
3307
+ if (fromCookie && modes.includes(fromCookie)) {
3308
+ return fromCookie;
3309
+ }
3310
+ return defaultMode;
3311
+ }
3259
3312
  var BOOT_STAGE_TABLE = [
3260
3313
  { id: "s0_session", weight: 5, critical: true, waitsOn: "Politogy ID session resolution" },
3261
3314
  { id: "s1_org", weight: 10, critical: true, waitsOn: "Chrome API /api/chrome/me + orgs" },
@@ -3625,7 +3678,17 @@ var POLITOGY_APPS = {
3625
3678
  // Campaign Mode apps — each scoped to Campaign, so they render the Campaign
3626
3679
  // tree and a static Campaign pill. Not yet deployed; hidden from the Campaign
3627
3680
  // 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" },
3681
+ // Dual-mode: a2p is the SMS/MMS delivery engine for BOTH Relationship Mode
3682
+ // and Campaign Mode (Specs/A2P). `mode: 'campaign'` stays the fallback for
3683
+ // anything reading `meta.mode` directly; the effective mode a user sees is
3684
+ // resolved per-visit (see `dual`, resolveActiveMode).
3685
+ a2p: {
3686
+ title: "A2P Messaging",
3687
+ origin: "https://a2p.politogyvrm.com",
3688
+ storagePrefix: "a2p",
3689
+ mode: "campaign",
3690
+ dual: { modes: ["relationship", "campaign"], defaultMode: "campaign" }
3691
+ },
3629
3692
  phonebank: { title: "Phone Banking", origin: "https://phonebank.politogyvrm.com", storagePrefix: "phonebank", mode: "campaign" },
3630
3693
  canvass: { title: "Field Canvassing", origin: "https://canvass.politogyvrm.com", storagePrefix: "canvass", mode: "campaign" },
3631
3694
  signs: { title: "Sign Mapping", origin: "https://signs.politogyvrm.com", storagePrefix: "signs", mode: "campaign" }
@@ -3762,9 +3825,19 @@ function FrameBody({
3762
3825
  });
3763
3826
  const userMenu = core?.userMenu ?? derivedUserMenu;
3764
3827
  const footer = useChromeFooter(buildMeta ?? {});
3765
- const appMode = meta.mode;
3828
+ const resolvedDualMode = meta.dual ? resolveActiveMode({
3829
+ search: search ?? (typeof window !== "undefined" ? window.location.search : void 0),
3830
+ modes: meta.dual.modes,
3831
+ defaultMode: meta.dual.defaultMode
3832
+ }) : void 0;
3833
+ const appMode = resolvedDualMode ?? meta.mode;
3766
3834
  const derivedMode = useChromeMode(appMode ? { pinnedModeId: appMode } : void 0);
3767
3835
  const mode = core?.mode ?? derivedMode;
3836
+ const coreLiveMode = app === CORE_APP ? pathname.startsWith("/campaign") ? "campaign" : pathname.startsWith("/relationship") ? "relationship" : void 0 : void 0;
3837
+ const cookieMode = coreLiveMode ?? (meta.dual ? void 0 : meta.mode);
3838
+ react.useEffect(() => {
3839
+ if (cookieMode) writeActiveModeCookie(cookieMode);
3840
+ }, [cookieMode]);
3768
3841
  const coreOrgs = chrome?.orgs ?? [];
3769
3842
  const activeCoreOrg = coreOrgs.find((o) => o.id === chrome?.orgId) ?? null;
3770
3843
  const navDisplay = chrome?.navDisplay ?? "both";