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 { useMemo } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { theme } from 'antd';
|
import { theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
@ -32,8 +32,21 @@ interface VolunteerFooterNavProps {
|
|||||||
export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) {
|
export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { settings } = useSettingsStore();
|
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 NAV_ITEMS = useMemo(() => {
|
||||||
const items = [...BASE_NAV_ITEMS];
|
const items = [...BASE_NAV_ITEMS];
|
||||||
@ -100,7 +113,7 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => navigate(key)}
|
onClick={() => navigateInLayout(key)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@ -53,6 +54,21 @@ export default function VolunteerLayout() {
|
|||||||
navigate('/login', { replace: true });
|
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)
|
// Build nav items list (mirrors VolunteerFooterNav logic)
|
||||||
const navItems = useMemo(() => {
|
const navItems = useMemo(() => {
|
||||||
const items: { key: string; icon: React.ReactNode; label: string }[] = [
|
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',
|
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!welcomeDismissed && (
|
{!isEmbedded && !welcomeDismissed && (
|
||||||
<Alert
|
<Alert
|
||||||
message="Welcome to the Volunteer Portal!"
|
message="Welcome to the Volunteer Portal!"
|
||||||
description="Here you can view your shifts, canvass your area, track your activity, and connect with your team."
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => { navigate(key); setMenuOpen(false); }}
|
onClick={() => { navigateInLayout(key); setMenuOpen(false); }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -222,8 +238,28 @@ export default function VolunteerLayout() {
|
|||||||
|
|
||||||
{/* Cross-navigation links */}
|
{/* Cross-navigation links */}
|
||||||
<div style={{ padding: '4px 0' }}>
|
<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
|
<div
|
||||||
onClick={() => { navigate('/home'); setMenuOpen(false); }}
|
onClick={() => { navigateInLayout('/home'); setMenuOpen(false); }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -239,7 +275,7 @@ export default function VolunteerLayout() {
|
|||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div
|
<div
|
||||||
onClick={() => { navigate('/app'); setMenuOpen(false); }}
|
onClick={() => { navigateInLayout('/app'); setMenuOpen(false); }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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_MKDOCS_URL?: string;
|
||||||
readonly VITE_DOMAIN?: string;
|
readonly VITE_DOMAIN?: string;
|
||||||
readonly VITE_MKDOCS_SITE_PORT?: 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 {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
|
|||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { actionCampaignsService, type ActiveCampaignForUser } from '../action-campaigns/action-campaigns.service';
|
import { actionCampaignsService, type ActiveCampaignForUser } from '../action-campaigns/action-campaigns.service';
|
||||||
import { referralService } from '../social/referral.service';
|
import { referralService } from '../social/referral.service';
|
||||||
|
import { notificationService } from '../social/notification.service';
|
||||||
|
|
||||||
export interface DashboardProfile {
|
export interface DashboardProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@ -67,6 +68,19 @@ export interface DashboardResource {
|
|||||||
viewPath: string | null;
|
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 {
|
export interface VolunteerDashboardPayload {
|
||||||
profile: DashboardProfile;
|
profile: DashboardProfile;
|
||||||
referral: DashboardReferral;
|
referral: DashboardReferral;
|
||||||
@ -76,6 +90,7 @@ export interface VolunteerDashboardPayload {
|
|||||||
myEvents: DashboardMyEvent[];
|
myEvents: DashboardMyEvent[];
|
||||||
points: DashboardPoints;
|
points: DashboardPoints;
|
||||||
resources: DashboardResource[];
|
resources: DashboardResource[];
|
||||||
|
notifications: DashboardNotification[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCE_TAG = 'volunteer-resource';
|
const RESOURCE_TAG = 'volunteer-resource';
|
||||||
@ -311,20 +326,54 @@ async function getResources(): Promise<DashboardResource[]> {
|
|||||||
return items.slice(0, 8).map(({ sortKey: _sortKey, ...rest }) => rest);
|
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 = {
|
export const volunteerDashboardService = {
|
||||||
async getDashboard(userId: string): Promise<VolunteerDashboardPayload | null> {
|
async getDashboard(userId: string): Promise<VolunteerDashboardPayload | null> {
|
||||||
const profile = await getProfile(userId);
|
const profile = await getProfile(userId);
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
|
|
||||||
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources] = await Promise.all([
|
const [referral, actionCampaign, featuredEvent, trainings, myEvents, points, resources, notifications] =
|
||||||
getReferral(userId),
|
await Promise.all([
|
||||||
actionCampaignsService.getActiveForUser(userId),
|
getReferral(userId),
|
||||||
getFeaturedEvent(),
|
actionCampaignsService.getActiveForUser(userId),
|
||||||
getTrainings(userId),
|
getFeaturedEvent(),
|
||||||
getMyEvents(userId),
|
getTrainings(userId),
|
||||||
getPoints(userId),
|
getMyEvents(userId),
|
||||||
getResources(),
|
getPoints(userId),
|
||||||
]);
|
getResources(),
|
||||||
|
getNotifications(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
@ -335,6 +384,7 @@ export const volunteerDashboardService = {
|
|||||||
myEvents,
|
myEvents,
|
||||||
points,
|
points,
|
||||||
resources,
|
resources,
|
||||||
|
notifications,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user