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:
parent
d883be1b9c
commit
6b8a222d6b
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
5
admin/src/vite-env.d.ts
vendored
5
admin/src/vite-env.d.ts
vendored
@ -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 {
|
||||
|
||||
@ -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,12 +326,45 @@ 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([
|
||||
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources, notifications] =
|
||||
await Promise.all([
|
||||
getReferral(userId),
|
||||
actionCampaignsService.getActiveForUser(userId),
|
||||
getFeaturedEvent(),
|
||||
@ -324,6 +372,7 @@ export const volunteerDashboardService = {
|
||||
getMyEvents(userId),
|
||||
getPoints(userId),
|
||||
getResources(),
|
||||
getNotifications(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -335,6 +384,7 @@ export const volunteerDashboardService = {
|
||||
myEvents,
|
||||
points,
|
||||
resources,
|
||||
notifications,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user