feat(jsn-bridge): notifications payload + embedded-mode polish

Two additions on top of the existing JSN-bridge work — both surface
through to JSN's /account VolunteerHub without further coordination.

api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts:
- Add DashboardNotification interface (id, title, body?, kind?,
  createdAt, href?) and `notifications: DashboardNotification[]` on
  VolunteerDashboardPayload.
- getDashboard() now fans out an 8th parallel fetch to the existing
  notificationService.listNotifications(userId, 1, 20, true), mapping
  each row's metadata.href (if present) into the href field.
- JSN's NotificationsCard was shipped pre-emptively typed for this
  field (loose-schema-now); it lights up automatically once cmlite is
  redeployed with this commit.

admin/src/components/VolunteerLayout.tsx + VolunteerFooterNav.tsx:
- Suppress the "Welcome to the Volunteer Portal!" alert when
  ?layout=embedded — the supporter arriving via SSO already knows
  where they are; the alert is noise.
- Add a "← Back to JSN" link in the drawer cross-nav section, visible
  only when embedded. Reads VITE_JSN_HOME_URL (new env var, defaults
  to http://localhost:8085 for dev).
- Preserve ?layout=embedded across in-cmlite navigation. Both the
  drawer nav and the footer nav now route through a navigateInLayout
  helper that appends the query when the supporter arrived embedded.
  Without this fix, clicking the footer "Map" button (etc.) stripped
  the query and re-showed PublicNavBar + the welcome alert.

admin/src/vite-env.d.ts:
- New VITE_JSN_HOME_URL slot in ImportMetaEnv. Default dev value baked
  into VolunteerLayout's render; production overrides via .env.

Bunker Admin
This commit is contained in:
bunker-admin 2026-05-26 12:06:43 -06:00
parent d883be1b9c
commit 6b8a222d6b
4 changed files with 119 additions and 15 deletions

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { theme } from 'antd';
import {
HomeOutlined,
@ -32,8 +32,21 @@ interface VolunteerFooterNavProps {
export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { token } = theme.useToken();
const { settings } = useSettingsStore();
// Preserve ?layout=embedded across in-cmlite footer nav so chrome stays
// hidden after the supporter clicks through from JSN. Matches the helper
// in VolunteerLayout's drawer nav.
const isEmbedded = searchParams.get('layout') === 'embedded';
const navigateInLayout = (to: string) => {
if (isEmbedded) {
const sep = to.includes('?') ? '&' : '?';
navigate(`${to}${sep}layout=embedded`);
} else {
navigate(to);
}
};
const NAV_ITEMS = useMemo(() => {
const items = [...BASE_NAV_ITEMS];
@ -100,7 +113,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
return (
<div
key={key}
onClick={() => navigate(key)}
onClick={() => navigateInLayout(key)}
style={{
display: 'flex',
alignItems: 'center',

View File

@ -16,6 +16,7 @@ import {
TeamOutlined,
MessageOutlined,
BarChartOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
@ -53,6 +54,21 @@ export default function VolunteerLayout() {
navigate('/login', { replace: true });
};
// Navigate while preserving the ?layout=embedded query so chrome stays
// hidden as the supporter moves through cmlite. Without this, clicking any
// drawer/footer-nav link would strip the param and re-show PublicNavBar +
// the welcome alert on the next page.
const navigateInLayout = (to: string) => {
if (isEmbedded) {
const sep = to.includes('?') ? '&' : '?';
navigate(`${to}${sep}layout=embedded`);
} else {
navigate(to);
}
};
const jsnHomeUrl = import.meta.env.VITE_JSN_HOME_URL ?? 'http://localhost:8085';
// Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [
@ -115,7 +131,7 @@ export default function VolunteerLayout() {
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px',
}}
>
{!welcomeDismissed && (
{!isEmbedded && !welcomeDismissed && (
<Alert
message="Welcome to the Volunteer Portal!"
description="Here you can view your shifts, canvass your area, track your activity, and connect with your team."
@ -196,7 +212,7 @@ export default function VolunteerLayout() {
return (
<div
key={key}
onClick={() => { navigate(key); setMenuOpen(false); }}
onClick={() => { navigateInLayout(key); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
@ -222,8 +238,28 @@ export default function VolunteerLayout() {
{/* Cross-navigation links */}
<div style={{ padding: '4px 0' }}>
{isEmbedded && (
<a
href={jsnHomeUrl}
onClick={() => setMenuOpen(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 20px',
cursor: 'pointer',
color: 'rgba(255,255,255,0.85)',
fontSize: 14,
fontWeight: 500,
textDecoration: 'none',
}}
>
<ArrowLeftOutlined />
<span>Back to JSN</span>
</a>
)}
<div
onClick={() => { navigate('/home'); setMenuOpen(false); }}
onClick={() => { navigateInLayout('/home'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
@ -239,7 +275,7 @@ export default function VolunteerLayout() {
</div>
{isAdmin && (
<div
onClick={() => { navigate('/app'); setMenuOpen(false); }}
onClick={() => { navigateInLayout('/app'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',

View File

@ -6,6 +6,11 @@ interface ImportMetaEnv {
readonly VITE_MKDOCS_URL?: string;
readonly VITE_DOMAIN?: string;
readonly VITE_MKDOCS_SITE_PORT?: string;
// Home URL for the upstream "Just Say No" supporter site. Used by
// VolunteerLayout's "← Back to JSN" link in embedded mode. Defaults to
// http://localhost:8085 in dev; production deploys set this to the public
// JSN host (e.g. https://justsaynoab.ca).
readonly VITE_JSN_HOME_URL?: string;
}
interface ImportMeta {

View File

@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { actionCampaignsService, type ActiveCampaignForUser } from '../action-campaigns/action-campaigns.service';
import { referralService } from '../social/referral.service';
import { notificationService } from '../social/notification.service';
export interface DashboardProfile {
id: string;
@ -67,6 +68,19 @@ export interface DashboardResource {
viewPath: string | null;
}
// Unread-notification summary surfaced to bridged callers (JSN's VolunteerHub
// renders this as a "N unread" badge + top-3 titles + "View all →"). Field
// shape is intentionally minimal: callers don't need the full Notification
// row, just enough to render a list + open the source on click.
export interface DashboardNotification {
id: string;
title: string;
body?: string;
kind?: string;
createdAt: string;
href?: string;
}
export interface VolunteerDashboardPayload {
profile: DashboardProfile;
referral: DashboardReferral;
@ -76,6 +90,7 @@ export interface VolunteerDashboardPayload {
myEvents: DashboardMyEvent[];
points: DashboardPoints;
resources: DashboardResource[];
notifications: DashboardNotification[];
}
const RESOURCE_TAG = 'volunteer-resource';
@ -311,20 +326,54 @@ async function getResources(): Promise<DashboardResource[]> {
return items.slice(0, 8).map(({ sortKey: _sortKey, ...rest }) => rest);
}
// Map Prisma Notification row → DashboardNotification. id is coerced to
// string (Prisma uses int autoincrement; JSN renders it as a React key so
// string is friendlier across the bridge). `metadata.href` is the per-row
// deep-link cmlite owns; missing values are fine.
function toDashboardNotification(n: {
id: number;
type: string;
title: string;
message: string;
metadata: Prisma.JsonValue;
createdAt: Date;
}): DashboardNotification {
let href: string | undefined;
if (n.metadata && typeof n.metadata === 'object' && !Array.isArray(n.metadata)) {
const raw = (n.metadata as Record<string, unknown>).href;
if (typeof raw === 'string') href = raw;
}
return {
id: String(n.id),
title: n.title,
body: n.message,
kind: n.type,
createdAt: n.createdAt.toISOString(),
href,
};
}
async function getNotifications(userId: string): Promise<DashboardNotification[]> {
const result = await notificationService.listNotifications(userId, 1, 20, true);
return result.notifications.map(toDashboardNotification);
}
export const volunteerDashboardService = {
async getDashboard(userId: string): Promise<VolunteerDashboardPayload | null> {
const profile = await getProfile(userId);
if (!profile) return null;
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources] = await Promise.all([
getReferral(userId),
actionCampaignsService.getActiveForUser(userId),
getFeaturedEvent(),
getTrainings(userId),
getMyEvents(userId),
getPoints(userId),
getResources(),
]);
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources, notifications] =
await Promise.all([
getReferral(userId),
actionCampaignsService.getActiveForUser(userId),
getFeaturedEvent(),
getTrainings(userId),
getMyEvents(userId),
getPoints(userId),
getResources(),
getNotifications(userId),
]);
return {
profile,
@ -335,6 +384,7 @@ export const volunteerDashboardService = {
myEvents,
points,
resources,
notifications,
};
},
};