+ {isEmbedded && (
+
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',
+ }}
+ >
+
+ Back to JSN
+
+ )}
{ navigate('/home'); setMenuOpen(false); }}
+ onClick={() => { navigateInLayout('/home'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
@@ -239,7 +275,7 @@ export default function VolunteerLayout() {
{isAdmin && (
{ navigate('/app'); setMenuOpen(false); }}
+ onClick={() => { navigateInLayout('/app'); setMenuOpen(false); }}
style={{
display: 'flex',
alignItems: 'center',
diff --git a/admin/src/vite-env.d.ts b/admin/src/vite-env.d.ts
index 5049e93..955dfe5 100644
--- a/admin/src/vite-env.d.ts
+++ b/admin/src/vite-env.d.ts
@@ -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 {
diff --git a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
index 36ee19d..2727d8e 100644
--- a/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
+++ b/api/src/modules/volunteer-dashboard/volunteer-dashboard.service.ts
@@ -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 {
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).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 {
+ const result = await notificationService.listNotifications(userId, 1, 20, true);
+ return result.notifications.map(toDashboardNotification);
+}
+
export const volunteerDashboardService = {
async getDashboard(userId: string): Promise {
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,
};
},
};