@the-portland-company/shell 0.33.0 → 0.34.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.
@@ -146,6 +146,10 @@ var d=document.documentElement;
146
146
  if(resolved==="dark"){d.classList.add("dark");}else{d.classList.remove("dark");}
147
147
  try{d.style.colorScheme=resolved;}catch(e){}
148
148
  }catch(e){}})();`;
149
+ var CORE_APP_ORIGIN = "https://app.politogyvrm.com";
150
+ var CORE_BILLING_PATH = "/organization-settings/subscription-billing/text-email";
151
+ var CORE_BILLING_URL = `${CORE_APP_ORIGIN}${CORE_BILLING_PATH}`;
152
+ var QUARANTINED_CONTACTS_URL = `${CORE_APP_ORIGIN}/relationship/contacts/quarantined`;
149
153
  function topBarIconButtonClass(tone = "default") {
150
154
  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)]";
151
155
  return [
@@ -328,11 +332,22 @@ function TopBarPopover({
328
332
  badge,
329
333
  tone,
330
334
  onOpen,
335
+ openToken,
331
336
  children,
332
337
  panelClassName
333
338
  }) {
334
339
  const [open, setOpen] = react.useState(false);
335
340
  const rootRef = react.useRef(null);
341
+ const lastOpenTokenRef = react.useRef(openToken);
342
+ react.useEffect(() => {
343
+ if (openToken === void 0) return;
344
+ if (openToken === lastOpenTokenRef.current) return;
345
+ lastOpenTokenRef.current = openToken;
346
+ setOpen((v) => {
347
+ if (!v) onOpen?.();
348
+ return true;
349
+ });
350
+ }, [openToken, onOpen]);
336
351
  react.useEffect(() => {
337
352
  if (!open) return;
338
353
  const onDocClick = (e) => {
@@ -416,31 +431,60 @@ function LayoutGlyph({ size = 20, className }) {
416
431
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "9", y1: "21", x2: "9", y2: "9" })
417
432
  ] });
418
433
  }
434
+ function PlusGlyph({ size = 14, className }) {
435
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", className, "aria-hidden": true, children: [
436
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "5", x2: "12", y2: "19" }),
437
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "5", y1: "12", x2: "19", y2: "12" })
438
+ ] });
439
+ }
440
+ function XGlyph({ size = 14, className }) {
441
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", className, "aria-hidden": true, children: [
442
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
443
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
444
+ ] });
445
+ }
446
+ function CheckGlyph({ size = 14, className }) {
447
+ return /* @__PURE__ */ jsxRuntime.jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round", strokeLinejoin: "round", className, "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "20 6 9 17 4 12" }) });
448
+ }
449
+ var LOW_FUEL_HINT_DISMISSED_KEY = "politogy_low_fuel_hint_dismissed";
419
450
  function FuelRow({
420
451
  label,
421
452
  channel,
422
- loading
453
+ loading,
454
+ addHref,
455
+ addLabel
423
456
  }) {
424
457
  const remaining = channel?.remainingUnits ?? 0;
425
458
  const total = channel ? Math.max(channel.rawBalanceUnits, channel.remainingUnits) : 0;
426
459
  const ratio = total > 0 ? Math.min(100, Math.max(0, remaining / total * 100)) : 0;
427
460
  const low = channel?.lowFuel ?? false;
428
461
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "w-full", children: [
429
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-1 flex items-center justify-between", children: [
462
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-1 flex items-center justify-between gap-2", children: [
430
463
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-[color:var(--foreground)]", children: label }),
431
- loading ? /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3.5 w-14" }) : /* @__PURE__ */ jsxRuntime.jsxs(
432
- "span",
433
- {
434
- className: [
435
- "text-xs font-semibold tabular-nums",
436
- low ? "text-[color:var(--color-race-red)]" : "text-[color:var(--muted)]"
437
- ].join(" "),
438
- children: [
439
- remaining.toLocaleString(),
440
- " left"
441
- ]
442
- }
443
- )
464
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
465
+ loading ? /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3.5 w-14" }) : /* @__PURE__ */ jsxRuntime.jsxs(
466
+ "span",
467
+ {
468
+ className: [
469
+ "text-xs font-semibold tabular-nums",
470
+ low ? "text-[color:var(--color-race-red)]" : "text-[color:var(--muted)]"
471
+ ].join(" "),
472
+ children: [
473
+ remaining.toLocaleString(),
474
+ " left"
475
+ ]
476
+ }
477
+ ),
478
+ addHref ? /* @__PURE__ */ jsxRuntime.jsx(
479
+ "a",
480
+ {
481
+ href: addHref,
482
+ "aria-label": addLabel,
483
+ className: "flex h-5 w-5 items-center justify-center rounded-full border border-[color:var(--line)] text-[color:var(--muted)] transition hover:border-[color:var(--foreground)] hover:text-[color:var(--foreground)]",
484
+ children: /* @__PURE__ */ jsxRuntime.jsx(PlusGlyph, {})
485
+ }
486
+ ) : null
487
+ ] })
444
488
  ] }),
445
489
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel)]", children: loading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-full w-full animate-pulse bg-[color:var(--line)]" }) : /* @__PURE__ */ jsxRuntime.jsx(
446
490
  "div",
@@ -454,6 +498,71 @@ function FuelRow({
454
498
  ) })
455
499
  ] });
456
500
  }
501
+ function LowFuelHint({ lowFuel }) {
502
+ const [dismissed, setDismissed] = react.useState(() => {
503
+ if (typeof window === "undefined") return false;
504
+ try {
505
+ return window.sessionStorage.getItem(LOW_FUEL_HINT_DISMISSED_KEY) === "1";
506
+ } catch {
507
+ return false;
508
+ }
509
+ });
510
+ react.useEffect(() => {
511
+ if (lowFuel) return;
512
+ try {
513
+ window.sessionStorage.removeItem(LOW_FUEL_HINT_DISMISSED_KEY);
514
+ } catch {
515
+ }
516
+ }, [lowFuel]);
517
+ if (!lowFuel || dismissed) return null;
518
+ const dismiss = () => {
519
+ try {
520
+ window.sessionStorage.setItem(LOW_FUEL_HINT_DISMISSED_KEY, "1");
521
+ } catch {
522
+ }
523
+ setDismissed(true);
524
+ };
525
+ return /* @__PURE__ */ jsxRuntime.jsxs(
526
+ "div",
527
+ {
528
+ role: "status",
529
+ className: "absolute right-0 top-[calc(100%+10px)] z-50 w-60 rounded-lg border border-[color:var(--color-race-red)] bg-[color:var(--color-race-red)] px-3 py-2 text-white shadow-[var(--shadow-soft)]",
530
+ onClick: (e) => e.stopPropagation(),
531
+ children: [
532
+ /* @__PURE__ */ jsxRuntime.jsx(
533
+ "span",
534
+ {
535
+ "aria-hidden": true,
536
+ className: "absolute -top-[5px] right-[11px] h-2.5 w-2.5 rotate-45 bg-[color:var(--color-race-red)]"
537
+ }
538
+ ),
539
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-2", children: [
540
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1", children: [
541
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold", children: "You\u2019re low on fuel" }),
542
+ /* @__PURE__ */ jsxRuntime.jsx(
543
+ "a",
544
+ {
545
+ href: CORE_BILLING_URL,
546
+ className: "mt-1 inline-block text-sm font-bold underline hover:opacity-90",
547
+ children: "Buy More Fuel"
548
+ }
549
+ )
550
+ ] }),
551
+ /* @__PURE__ */ jsxRuntime.jsx(
552
+ "button",
553
+ {
554
+ type: "button",
555
+ "aria-label": "Dismiss low fuel notice",
556
+ onClick: dismiss,
557
+ className: "flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white/90 transition hover:bg-white/20",
558
+ children: /* @__PURE__ */ jsxRuntime.jsx(XGlyph, {})
559
+ }
560
+ )
561
+ ] })
562
+ ]
563
+ }
564
+ );
565
+ }
457
566
  function FuelAction({ orgId }) {
458
567
  const { fetchJson } = useNavChrome("FuelAction");
459
568
  const [fuel, setFuel] = react.useState(null);
@@ -479,21 +588,154 @@ function FuelAction({ orgId }) {
479
588
  };
480
589
  }, [fetchJson, orgId]);
481
590
  const low = !!fuel && (fuel.signal.lowFuel || fuel.broadcast.lowFuel);
482
- return /* @__PURE__ */ jsxRuntime.jsx(
483
- TopBarPopover,
591
+ return (
592
+ // The nudge anchors to the trigger, so it sits OUTSIDE the popover panel in
593
+ // its own relative wrapper (same shape the VRM widget used).
594
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-flex", children: [
595
+ /* @__PURE__ */ jsxRuntime.jsx(
596
+ TopBarPopover,
597
+ {
598
+ label: "Communications Fuel",
599
+ description: "Remaining SMS and email sending balance for the active organization.",
600
+ slot: "comms-fuel",
601
+ fallbackIcon: BatteryGlyph,
602
+ tone: low ? "warning" : "default",
603
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
604
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Communications Fuel" }),
605
+ error || !loading && !fuel ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Fuel balance is unavailable right now." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
606
+ /* @__PURE__ */ jsxRuntime.jsx(
607
+ FuelRow,
608
+ {
609
+ label: "SMS Signal",
610
+ channel: fuel?.signal ?? null,
611
+ loading,
612
+ addHref: CORE_BILLING_URL,
613
+ addLabel: "Add SMS Signal fuel"
614
+ }
615
+ ),
616
+ /* @__PURE__ */ jsxRuntime.jsx(
617
+ FuelRow,
618
+ {
619
+ label: "Email Broadcast",
620
+ channel: fuel?.broadcast ?? null,
621
+ loading,
622
+ addHref: CORE_BILLING_URL,
623
+ addLabel: "Add Email Broadcast fuel"
624
+ }
625
+ )
626
+ ] })
627
+ ] })
628
+ }
629
+ ),
630
+ /* @__PURE__ */ jsxRuntime.jsx(LowFuelHint, { lowFuel: low })
631
+ ] })
632
+ );
633
+ }
634
+ function notifStateKey(orgId) {
635
+ return `politogy_chrome_notif_state:${orgId ?? "none"}`;
636
+ }
637
+ function readNotifState(orgId) {
638
+ const empty = { readAlertIds: [], lastSeenAt: null, toastedReplyIds: [] };
639
+ if (typeof window === "undefined") return empty;
640
+ try {
641
+ const raw = window.localStorage.getItem(notifStateKey(orgId));
642
+ if (!raw) return empty;
643
+ const parsed = JSON.parse(raw);
644
+ return {
645
+ readAlertIds: Array.isArray(parsed?.readAlertIds) ? parsed.readAlertIds.filter((id) => typeof id === "string") : [],
646
+ lastSeenAt: typeof parsed?.lastSeenAt === "string" ? parsed.lastSeenAt : null,
647
+ toastedReplyIds: Array.isArray(parsed?.toastedReplyIds) ? parsed.toastedReplyIds.filter((id) => typeof id === "string") : []
648
+ };
649
+ } catch {
650
+ return empty;
651
+ }
652
+ }
653
+ function writeNotifState(orgId, state) {
654
+ if (typeof window === "undefined") return;
655
+ try {
656
+ window.localStorage.setItem(notifStateKey(orgId), JSON.stringify(state));
657
+ } catch {
658
+ }
659
+ }
660
+ var TEN_DLC_REDIRECTED_KEY = "politogy_10dlc_redirected";
661
+ function formatPhone(raw) {
662
+ const digits = raw.replace(/\D/g, "");
663
+ const ten = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits;
664
+ if (ten.length === 10) return `(${ten.slice(0, 3)}) ${ten.slice(3, 6)}-${ten.slice(6)}`;
665
+ return raw;
666
+ }
667
+ function ImportRow({ imp }) {
668
+ const pct = imp.percent == null ? null : Math.max(0, Math.min(100, imp.percent));
669
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
670
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2", children: [
671
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-sm text-[color:var(--foreground)]", children: imp.label }),
672
+ pct != null ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "shrink-0 text-xs tabular-nums text-[color:var(--muted)]", children: [
673
+ Math.round(pct),
674
+ "%"
675
+ ] }) : null
676
+ ] }),
677
+ pct != null ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mt-1 h-1 w-full overflow-hidden rounded-full bg-[color:var(--panel)]", children: /* @__PURE__ */ jsxRuntime.jsx(
678
+ "div",
679
+ {
680
+ className: "h-full rounded-full bg-[color:var(--color-race-red)] transition-[width]",
681
+ style: { width: `${pct}%` }
682
+ }
683
+ ) }) : null
684
+ ] });
685
+ }
686
+ function NewReplyToast({
687
+ reply,
688
+ onActivate,
689
+ onClose,
690
+ ttlMs = 12e3
691
+ }) {
692
+ const [visible, setVisible] = react.useState(false);
693
+ const onCloseRef = react.useRef(onClose);
694
+ onCloseRef.current = onClose;
695
+ react.useEffect(() => {
696
+ setVisible(true);
697
+ const fadeAt = Math.max(0, ttlMs - 450);
698
+ const t1 = window.setTimeout(() => setVisible(false), fadeAt);
699
+ const t2 = window.setTimeout(() => onCloseRef.current(), ttlMs);
700
+ return () => {
701
+ window.clearTimeout(t1);
702
+ window.clearTimeout(t2);
703
+ };
704
+ }, [ttlMs]);
705
+ const activate = () => {
706
+ setVisible(false);
707
+ onActivate();
708
+ };
709
+ return /* @__PURE__ */ jsxRuntime.jsxs(
710
+ "div",
484
711
  {
485
- label: "Communications Fuel",
486
- description: "Remaining SMS and email sending balance for the active organization.",
487
- slot: "comms-fuel",
488
- fallbackIcon: BatteryGlyph,
489
- tone: low ? "warning" : "default",
490
- children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
491
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Communications Fuel" }),
492
- error || !loading && !fuel ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Fuel balance is unavailable right now." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
493
- /* @__PURE__ */ jsxRuntime.jsx(FuelRow, { label: "SMS Signal", channel: fuel?.signal ?? null, loading }),
494
- /* @__PURE__ */ jsxRuntime.jsx(FuelRow, { label: "Email Broadcast", channel: fuel?.broadcast ?? null, loading })
495
- ] })
496
- ] })
712
+ role: "button",
713
+ tabIndex: 0,
714
+ "aria-label": "Open new reply",
715
+ onClick: activate,
716
+ onKeyDown: (e) => {
717
+ if (e.key === "Enter" || e.key === " ") {
718
+ e.preventDefault();
719
+ activate();
720
+ }
721
+ },
722
+ className: "absolute right-[-6px] top-[calc(100%+12px)] z-50 min-w-[240px] max-w-[320px] cursor-pointer rounded-xl bg-[#161616] px-4 py-3 text-white shadow-[var(--shadow-soft)]",
723
+ style: {
724
+ opacity: visible ? 1 : 0,
725
+ transform: visible ? "translateY(0px)" : "translateY(-4px)",
726
+ transition: "opacity 250ms ease, transform 250ms ease"
727
+ },
728
+ children: [
729
+ /* @__PURE__ */ jsxRuntime.jsx(
730
+ "span",
731
+ {
732
+ "aria-hidden": true,
733
+ className: "absolute right-[18px] top-[-7px] h-0 w-0 border-x-[8px] border-x-transparent border-b-[8px] border-b-[#161616]"
734
+ }
735
+ ),
736
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-sm font-semibold", children: reply.name }),
737
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 truncate text-xs opacity-85", children: reply.preview })
738
+ ]
497
739
  }
498
740
  );
499
741
  }
@@ -502,6 +744,13 @@ function NotificationsAction({ orgId }) {
502
744
  const [payload, setPayload] = react.useState(null);
503
745
  const [loading, setLoading] = react.useState(true);
504
746
  const [error, setError] = react.useState(false);
747
+ const [notif, setNotif] = react.useState(() => readNotifState(orgId));
748
+ const [toastReply, setToastReply] = react.useState(null);
749
+ const [openToken, setOpenToken] = react.useState(0);
750
+ react.useEffect(() => {
751
+ setNotif(readNotifState(orgId));
752
+ setToastReply(null);
753
+ }, [orgId]);
505
754
  react.useEffect(() => {
506
755
  let cancelled = false;
507
756
  setLoading(true);
@@ -520,51 +769,226 @@ function NotificationsAction({ orgId }) {
520
769
  cancelled = true;
521
770
  };
522
771
  }, [fetchJson, orgId]);
523
- const count = (payload?.alerts.length ?? 0) + (payload?.replies.length ?? 0);
524
- return /* @__PURE__ */ jsxRuntime.jsx(
525
- TopBarPopover,
526
- {
527
- label: "Alerts & Notifications",
528
- description: "System alerts and recent activity for the active organization.",
529
- slot: "notifications",
530
- fallbackIcon: BellGlyph,
531
- badge: loading ? 0 : count,
532
- children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
533
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Notifications" }),
534
- loading ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 py-1", children: [
535
- /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-4 w-full" }),
536
- /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-4 w-3/4" })
537
- ] }) : error ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Notifications are unavailable right now." }) : count === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "py-1 text-sm text-[color:var(--muted)]", children: "You\u2019re all caught up." }) : /* @__PURE__ */ jsxRuntime.jsxs("ul", { className: "flex max-h-80 flex-col gap-1 overflow-y-auto", children: [
538
- payload?.alerts.map((a) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsxs(
539
- "a",
540
- {
541
- href: a.href ?? "#",
542
- className: "block rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]",
543
- children: [
544
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold text-[color:var(--foreground)]", children: a.title }),
545
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-[color:var(--muted)]", children: a.description })
546
- ]
547
- }
548
- ) }, a.id)),
549
- payload?.replies.map((r) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: [
550
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold text-[color:var(--foreground)]", children: r.name }),
551
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-xs text-[color:var(--muted)]", children: r.preview })
552
- ] }, r.id))
553
- ] })
554
- ] })
772
+ react.useEffect(() => {
773
+ if (!payload || typeof window === "undefined") return;
774
+ const alert = payload.alerts.find((a) => a.kind === "10dlc_registration" && a.href);
775
+ if (!alert?.href) return;
776
+ try {
777
+ if (window.sessionStorage.getItem(TEN_DLC_REDIRECTED_KEY) === "1") return;
778
+ } catch {
779
+ return;
555
780
  }
781
+ let target;
782
+ try {
783
+ target = new URL(alert.href, window.location.href);
784
+ } catch {
785
+ return;
786
+ }
787
+ if (target.origin !== window.location.origin) return;
788
+ try {
789
+ window.sessionStorage.setItem(TEN_DLC_REDIRECTED_KEY, "1");
790
+ } catch {
791
+ }
792
+ window.location.assign(alert.href);
793
+ }, [payload]);
794
+ const markRead = react.useCallback(
795
+ (id) => {
796
+ setNotif((prev) => {
797
+ if (prev.readAlertIds.includes(id)) return prev;
798
+ const next = { ...prev, readAlertIds: [...prev.readAlertIds, id] };
799
+ writeNotifState(orgId, next);
800
+ return next;
801
+ });
802
+ },
803
+ [orgId]
804
+ );
805
+ const markUnread = react.useCallback(
806
+ (id) => {
807
+ setNotif((prev) => {
808
+ if (!prev.readAlertIds.includes(id)) return prev;
809
+ const next = { ...prev, readAlertIds: prev.readAlertIds.filter((x) => x !== id) };
810
+ writeNotifState(orgId, next);
811
+ return next;
812
+ });
813
+ },
814
+ [orgId]
815
+ );
816
+ const onOpen = react.useCallback(() => {
817
+ setNotif((prev) => {
818
+ const next = { ...prev, lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() };
819
+ writeNotifState(orgId, next);
820
+ return next;
821
+ });
822
+ }, [orgId]);
823
+ const alerts = payload?.alerts ?? [];
824
+ const replies = payload?.replies ?? [];
825
+ const imports = payload?.imports ?? [];
826
+ const quarantined = payload?.quarantined ?? [];
827
+ react.useEffect(() => {
828
+ if (!payload || toastReply) return;
829
+ const candidate = replies.filter((r) => {
830
+ if (!r.createdAt) return false;
831
+ if (notif.lastSeenAt && r.createdAt <= notif.lastSeenAt) return false;
832
+ return !notif.toastedReplyIds.includes(r.id);
833
+ }).sort((a, b) => a.createdAt < b.createdAt ? 1 : -1)[0];
834
+ if (!candidate) return;
835
+ setToastReply(candidate);
836
+ setNotif((prev) => {
837
+ const next = {
838
+ ...prev,
839
+ toastedReplyIds: [candidate.id, ...prev.toastedReplyIds].slice(0, 50)
840
+ };
841
+ writeNotifState(orgId, next);
842
+ return next;
843
+ });
844
+ }, [payload, replies, toastReply, notif.lastSeenAt, notif.toastedReplyIds, orgId]);
845
+ const onToastActivate = react.useCallback(() => {
846
+ const href = toastReply?.href;
847
+ setToastReply(null);
848
+ if (href && typeof window !== "undefined") {
849
+ window.location.assign(href);
850
+ return;
851
+ }
852
+ setOpenToken((t) => t + 1);
853
+ }, [toastReply]);
854
+ const unreadAlertCount = alerts.filter((a) => !notif.readAlertIds.includes(a.id)).length;
855
+ const unseenReplyCount = replies.filter((r) => {
856
+ if (!notif.lastSeenAt) return true;
857
+ if (!r.createdAt) return false;
858
+ return r.createdAt > notif.lastSeenAt;
859
+ }).length;
860
+ const badgeCount = unreadAlertCount + unseenReplyCount;
861
+ const isEmpty = alerts.length === 0 && replies.length === 0 && imports.length === 0 && quarantined.length === 0;
862
+ return (
863
+ // Relative wrapper so the new-reply toast anchors to the bell trigger,
864
+ // outside the popover panel (same pattern as the fuel low-fuel nudge).
865
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative inline-flex", children: [
866
+ /* @__PURE__ */ jsxRuntime.jsx(
867
+ TopBarPopover,
868
+ {
869
+ label: "Alerts & Notifications",
870
+ description: "System alerts and recent activity for the active organization.",
871
+ slot: "notifications",
872
+ fallbackIcon: BellGlyph,
873
+ badge: loading ? 0 : badgeCount,
874
+ onOpen,
875
+ openToken,
876
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
877
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Notifications" }),
878
+ loading ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 py-1", children: [
879
+ /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-4 w-full" }),
880
+ /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-4 w-3/4" })
881
+ ] }) : error ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Notifications are unavailable right now." }) : isEmpty ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "py-1 text-sm text-[color:var(--muted)]", children: "You\u2019re all caught up." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex max-h-80 flex-col gap-2 overflow-y-auto", children: [
882
+ alerts.length > 0 || replies.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("ul", { className: "flex flex-col gap-1", children: [
883
+ alerts.map((a) => {
884
+ const isRead = notif.readAlertIds.includes(a.id);
885
+ return /* @__PURE__ */ jsxRuntime.jsx("li", { className: isRead ? "opacity-50" : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-1 rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: [
886
+ /* @__PURE__ */ jsxRuntime.jsxs(
887
+ "a",
888
+ {
889
+ href: a.href ?? "#",
890
+ onClick: () => markRead(a.id),
891
+ className: "block min-w-0 flex-1",
892
+ children: [
893
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold text-[color:var(--foreground)]", children: a.title }),
894
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-[color:var(--muted)]", children: a.description })
895
+ ]
896
+ }
897
+ ),
898
+ /* @__PURE__ */ jsxRuntime.jsx(
899
+ "button",
900
+ {
901
+ type: "button",
902
+ "aria-label": isRead ? "Mark as unread" : "Mark as read",
903
+ onClick: () => isRead ? markUnread(a.id) : markRead(a.id),
904
+ className: "mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[color:var(--muted)] transition hover:bg-[color:var(--panel-elevated)] hover:text-[color:var(--foreground)]",
905
+ children: /* @__PURE__ */ jsxRuntime.jsx(CheckGlyph, {})
906
+ }
907
+ )
908
+ ] }) }, a.id);
909
+ }),
910
+ replies.map((r) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: [
911
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm font-semibold text-[color:var(--foreground)]", children: r.name }),
912
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "truncate text-xs text-[color:var(--muted)]", children: r.preview })
913
+ ] }, r.id))
914
+ ] }) : null,
915
+ imports.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
916
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-1 px-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Imports" }),
917
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-col gap-1", children: imports.map((imp) => /* @__PURE__ */ jsxRuntime.jsx("li", { className: "rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: imp.href ? /* @__PURE__ */ jsxRuntime.jsx("a", { href: imp.href, className: "block", children: /* @__PURE__ */ jsxRuntime.jsx(ImportRow, { imp }) }) : /* @__PURE__ */ jsxRuntime.jsx(ImportRow, { imp }) }, imp.id)) })
918
+ ] }) : null,
919
+ quarantined.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
920
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-1 flex items-center justify-between px-2", children: [
921
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Quarantined Contacts" }),
922
+ /* @__PURE__ */ jsxRuntime.jsx(
923
+ "a",
924
+ {
925
+ href: QUARANTINED_CONTACTS_URL,
926
+ className: "text-[11px] font-semibold text-[color:var(--color-race-red)] hover:underline",
927
+ children: "View all"
928
+ }
929
+ )
930
+ ] }),
931
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex flex-col gap-1", children: quarantined.map((q) => /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: [
932
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between gap-2", children: [
933
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm font-semibold text-[color:var(--foreground)]", children: formatPhone(q.phone) }),
934
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-4 min-w-4 items-center justify-center rounded-full bg-[color:var(--panel)] px-1 text-[10px] tabular-nums text-[color:var(--muted)]", children: q.count })
935
+ ] }),
936
+ q.latestAt ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-[color:var(--muted)]", children: new Date(q.latestAt).toLocaleString() }) : null
937
+ ] }, q.phone)) })
938
+ ] }) : null
939
+ ] })
940
+ ] })
941
+ }
942
+ ),
943
+ toastReply ? /* @__PURE__ */ jsxRuntime.jsx(
944
+ NewReplyToast,
945
+ {
946
+ reply: toastReply,
947
+ onActivate: onToastActivate,
948
+ onClose: () => setToastReply(null)
949
+ }
950
+ ) : null
951
+ ] })
556
952
  );
557
953
  }
558
- function SupportAction() {
954
+ function composeSupportMessage(input) {
955
+ const ctx = [`Page: ${input.pageTitle || "Unknown"}`, `URL: ${input.url}`];
956
+ if (input.orgName) ctx.push(`Org: ${input.orgName}`);
957
+ const reply = [input.replyEmail?.trim(), input.replyPhone?.trim()].filter(Boolean).join(" / ");
958
+ if (reply) ctx.push(`Reply-to: ${reply}`);
959
+ return `${input.note.trim()}
960
+
961
+ ---
962
+ ${ctx.join("\n")}`;
963
+ }
964
+ function SupportAction({ orgId } = {}) {
559
965
  const { fetchJson } = useNavChrome("SupportAction");
966
+ const { me, loaded } = useChromeMe();
967
+ const { orgs } = useChromeOrgs();
560
968
  const [open, setOpen] = react.useState(false);
561
969
  const [message, setMessage] = react.useState("");
970
+ const [email, setEmail] = react.useState("");
971
+ const [phone, setPhone] = react.useState("");
972
+ const [showEmailError, setShowEmailError] = react.useState(false);
562
973
  const [state, setState] = react.useState("idle");
974
+ const meEmail = me?.email ?? null;
975
+ const mePhone = me?.phone ?? null;
976
+ const missingEmail = loaded && !meEmail;
977
+ const missingPhone = loaded && !mePhone;
978
+ const activeOrg = (orgId ? orgs?.find((o) => o.id === orgId) : null) ?? (orgs && orgs.length === 1 ? orgs[0] : null);
979
+ const orgName = activeOrg?.name ?? null;
563
980
  const close = react.useCallback(() => {
564
981
  setOpen(false);
565
982
  setState("idle");
566
983
  setMessage("");
984
+ setShowEmailError(false);
567
985
  }, []);
986
+ react.useEffect(() => {
987
+ if (!open) return;
988
+ setEmail(meEmail ?? "");
989
+ setPhone(mePhone ?? "");
990
+ setShowEmailError(false);
991
+ }, [open, meEmail, mePhone]);
568
992
  react.useEffect(() => {
569
993
  if (!open) return;
570
994
  const onKey = (e) => {
@@ -575,12 +999,28 @@ function SupportAction() {
575
999
  }, [open, close]);
576
1000
  const submit = async () => {
577
1001
  if (!message.trim() || state === "sending") return;
1002
+ const resolvedEmail = (missingEmail ? email : meEmail ?? "").trim();
1003
+ const resolvedPhone = (missingPhone ? phone : mePhone ?? "").trim();
1004
+ if (missingEmail && !resolvedEmail) {
1005
+ setShowEmailError(true);
1006
+ return;
1007
+ }
1008
+ setShowEmailError(false);
1009
+ const collectedContact = missingEmail || missingPhone;
1010
+ const fullMessage = composeSupportMessage({
1011
+ note: message,
1012
+ pageTitle: typeof document !== "undefined" ? document.title : "",
1013
+ url: typeof window !== "undefined" ? window.location.href : "",
1014
+ orgName,
1015
+ replyEmail: collectedContact ? resolvedEmail : void 0,
1016
+ replyPhone: collectedContact ? resolvedPhone : void 0
1017
+ });
578
1018
  setState("sending");
579
1019
  try {
580
1020
  await fetchJson("support", {
581
1021
  method: "POST",
582
1022
  headers: { "Content-Type": "application/json" },
583
- body: JSON.stringify({ message: message.trim() })
1023
+ body: JSON.stringify({ message: fullMessage })
584
1024
  });
585
1025
  setState("sent");
586
1026
  } catch {
@@ -633,6 +1073,33 @@ function SupportAction() {
633
1073
  ) })
634
1074
  ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
635
1075
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-sm text-[color:var(--muted)]", children: "Describe the problem or question. Your account details ride along automatically." }),
1076
+ missingEmail ? /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "mt-4 block", children: [
1077
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mb-1 block text-xs font-semibold text-[color:var(--foreground)]", children: "Your email" }),
1078
+ /* @__PURE__ */ jsxRuntime.jsx(
1079
+ "input",
1080
+ {
1081
+ type: "email",
1082
+ value: email,
1083
+ onChange: (e) => setEmail(e.target.value),
1084
+ placeholder: "you@example.com",
1085
+ className: "w-full rounded-lg border border-[color:var(--line)] bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--foreground)] placeholder:text-[color:var(--muted)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-race-red)]"
1086
+ }
1087
+ ),
1088
+ showEmailError ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mt-1 block text-xs text-[color:var(--color-race-red)]", children: "Enter an email so support can reply." }) : null
1089
+ ] }) : null,
1090
+ missingPhone ? /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "mt-3 block", children: [
1091
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mb-1 block text-xs font-semibold text-[color:var(--foreground)]", children: "Your phone \u2014 optional" }),
1092
+ /* @__PURE__ */ jsxRuntime.jsx(
1093
+ "input",
1094
+ {
1095
+ type: "tel",
1096
+ value: phone,
1097
+ onChange: (e) => setPhone(e.target.value),
1098
+ placeholder: "(503) 555-1212",
1099
+ className: "w-full rounded-lg border border-[color:var(--line)] bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--foreground)] placeholder:text-[color:var(--muted)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-race-red)]"
1100
+ }
1101
+ )
1102
+ ] }) : null,
636
1103
  /* @__PURE__ */ jsxRuntime.jsx(
637
1104
  "textarea",
638
1105
  {
@@ -2369,7 +2836,7 @@ function ChromeTopBarActions() {
2369
2836
  children: /* @__PURE__ */ jsxRuntime.jsx(ChromeDevNotesMenu, {})
2370
2837
  }
2371
2838
  ),
2372
- /* @__PURE__ */ jsxRuntime.jsx(SupportAction, {}),
2839
+ /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId }),
2373
2840
  /* @__PURE__ */ jsxRuntime.jsx(NotificationsAction, { orgId: state.orgId })
2374
2841
  ] });
2375
2842
  }