This commit is contained in:
Flatlogic Bot 2026-02-13 20:09:48 +00:00
parent 9e5f09aabe
commit 9e48b4cb24
10 changed files with 602 additions and 60 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@ -6,7 +6,7 @@ export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
export const gradientBgExecutive = 'bg-gradient-to-br from-white to-[#091EAA]/[0.07]'
export const gradientBgExecutive = 'bg-gradient-to-br from-white to-pavitra-blue/[0.07]'
export const colorsBgLight = {
white: 'bg-white text-black',
@ -15,7 +15,7 @@ export const colorsBgLight = {
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
danger: 'bg-red-500 border-red-500 text-white',
warning: 'bg-yellow-500 border-yellow-500 text-white',
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
info: 'bg-pavitra-blue border-pavitra-blue dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
}
export const colorsText = {
@ -25,7 +25,7 @@ export const colorsText = {
success: 'text-emerald-500',
danger: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
info: 'text-pavitra-blue',
};
export const colorsOutline = {
@ -35,7 +35,7 @@ export const colorsOutline = {
success: [colorsText.success, 'border-emerald-500'].join(' '),
danger: [colorsText.danger, 'border-red-500'].join(' '),
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
info: [colorsText.info, 'border-blue-500'].join(' '),
info: [colorsText.info, 'border-pavitra-blue'].join(' '),
};
export const getButtonColor = (
@ -57,7 +57,7 @@ export const getButtonColor = (
success: 'ring-emerald-300 dark:ring-pavitra-blue',
danger: 'ring-red-300 dark:ring-red-700',
warning: 'ring-yellow-300 dark:ring-yellow-700',
info: "ring-blue-300 dark:ring-pavitra-blue",
info: "ring-pavitra-blue/30 dark:ring-pavitra-blue/50",
},
active: {
white: 'bg-gray-100',
@ -67,7 +67,7 @@ export const getButtonColor = (
success: 'bg-emerald-700 dark:bg-pavitra-blue',
danger: 'bg-red-700 dark:bg-red-600',
warning: 'bg-yellow-700 dark:bg-yellow-600',
info: 'bg-blue-700 dark:bg-pavitra-blue',
info: 'bg-pavitra-blue/90 dark:bg-pavitra-blue/90',
},
bg: {
white: 'bg-white text-black',
@ -77,7 +77,7 @@ export const getButtonColor = (
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
danger: 'bg-red-600 text-white dark:bg-red-500 ',
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
info: " bg-pavitra-blue dark:bg-pavitra-blue text-white ",
},
bgHover: {
white: 'hover:bg-gray-100',
@ -90,7 +90,7 @@ export const getButtonColor = (
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
warning:
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
info: "hover:bg-pavitra-blue/80 hover:border-pavitra-blue/80 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
},
borders: {
white: 'border-white',
@ -100,14 +100,14 @@ export const getButtonColor = (
success: 'border-emerald-600 dark:border-pavitra-blue',
danger: 'border-red-600 dark:border-red-500',
warning: 'border-yellow-600 dark:border-yellow-500',
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
info: "border-pavitra-blue dark:border-pavitra-blue",
},
text: {
contrast: 'dark:text-slate-100',
success: 'text-emerald-600 dark:text-pavitra-blue',
danger: 'text-red-600 dark:text-red-500',
warning: 'text-yellow-600 dark:text-yellow-500',
info: 'text-blue-600 dark:text-pavitra-blue',
info: 'text-pavitra-blue dark:text-pavitra-blue',
},
outlineHover: {
contrast:
@ -117,7 +117,7 @@ export const getButtonColor = (
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
warning:
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
info: "hover:bg-pavitra-blue hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
},
}

View File

@ -0,0 +1,138 @@
import React from 'react';
import { mdiLinkedin, mdiMapMarkerOutline, mdiEmailOutline, mdiPhoneOutline, mdiMessageTextOutline, mdiCrown } from '@mdi/js';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import Link from 'next/link';
import { useAppSelector } from '../../stores/hooks';
type Props = {
member: any;
};
const MemberCard = ({ member }: Props) => {
const brandColor = '#091EAA';
const { currentUser } = useAppSelector((state) => state.auth);
const headshot = member.headshot && member.headshot[0] ? member.headshot[0].publicUrl : null;
const firstName = member.user?.firstName || '';
const lastName = member.user?.lastName || '';
const fullName = `${firstName} ${lastName}`.trim() || 'Anonymous Member';
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase() || '?';
const memberSinceYear = member.member_since ? new Date(member.member_since).getFullYear() : null;
const mailtoLink = `mailto:${member.user?.email}?subject=Power Suite Connection ${currentUser?.firstName || 'A Member'}`;
return (
<div className="bg-white border border-gray-100 rounded-none shadow-sm hover:shadow-md transition-shadow p-6 flex flex-col h-full relative">
<Link href={`/directory/${member.id}`} className="flex-grow">
<div className="flex items-start gap-4 mb-4">
{/* Avatar */}
<div className="relative flex-shrink-0">
{headshot ? (
<img
src={headshot}
alt={fullName}
className="w-20 h-20 rounded-full object-cover border-2 border-gray-50"
/>
) : (
<div
className="w-20 h-20 rounded-full flex items-center justify-center text-white text-xl font-bold"
style={{ background: `linear-gradient(135deg, ${brandColor} 0%, #0055AA 100%)` }}
>
{initials}
</div>
)}
</div>
<div className="flex-grow min-w-0">
{/* Name */}
<h3 className="text-[#091EAA] font-bold text-lg truncate leading-tight mb-1">
{fullName}
</h3>
{/* Title Badge */}
<div className="inline-block bg-[#091EAA] text-white text-[10px] font-bold px-2 py-1 uppercase tracking-wider mb-2">
{member.professional_title || 'Executive'}
</div>
{/* Organization */}
<div className="text-gray-600 font-medium text-sm mb-3">
{member.organization || 'Independent'}
</div>
</div>
</div>
{/* Pills */}
<div className="flex flex-wrap gap-2 mb-4">
{member.industry && (
<span className="bg-[#E5E7EB] text-[#4B5563] text-[10px] font-semibold px-2.5 py-0.5 rounded-full">
{member.industry}
</span>
)}
{member.sector && (
<span className="bg-[#DBEAFE] text-[#1D4ED8] text-[10px] font-semibold px-2.5 py-0.5 rounded-full">
{member.sector === 'nonprofit_501c3' ? 'Nonprofit (501c3)' : member.sector.charAt(0).toUpperCase() + member.sector.slice(1)}
</span>
)}
</div>
{/* Location */}
<div className="flex items-center text-gray-500 text-xs mb-3">
<BaseIcon path={mdiMapMarkerOutline} size={14} className="mr-1 text-[#091EAA]" />
<span>{member.city}{member.city && member.state ? ', ' : ''}{member.state}</span>
</div>
</Link>
{/* Action Buttons */}
<div className="flex items-center gap-3 mt-auto pt-4 border-t border-gray-50">
<BaseButton
href={mailtoLink}
label="Send Message"
color="info"
small
className="flex-grow rounded-none text-[11px] font-bold uppercase tracking-wider bg-[#091EAA] hover:bg-[#0055AA] border-none"
/>
{member.linkedin_url && (
<a
href={member.linkedin_url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-[#0A66C2] transition-colors"
>
<BaseIcon path={mdiLinkedin} size={20} />
</a>
)}
{member.allow_phone_contact && member.phone_number && (
<div className="flex gap-2">
<a href={`tel:${member.phone_number}`} className="text-gray-400 hover:text-[#091EAA]">
<BaseIcon path={mdiPhoneOutline} size={18} />
</a>
<a href={`sms:${member.phone_number}`} className="text-gray-400 hover:text-[#091EAA]">
<BaseIcon path={mdiMessageTextOutline} size={18} />
</a>
</div>
)}
</div>
{/* Footer Badges */}
<div className="mt-4 flex items-center justify-between">
{member.founding_member && (
<div className="flex items-center bg-[#FDE68A] text-[#92400E] text-[9px] font-bold px-2 py-0.5 rounded-full uppercase">
<BaseIcon path={mdiCrown} size={10} className="mr-1" />
Zeta Proud
</div>
)}
{memberSinceYear && (
<div className="text-[10px] text-gray-400 font-light ml-auto">
Member since {memberSinceYear}
</div>
)}
</div>
</div>
);
};
export default MemberCard;

View File

@ -8,7 +8,7 @@ const menuAside: MenuAsideItem[] = [
label: 'Home',
},
{
href: '/member_profiles/member_profiles-list',
href: '/directory',
label: 'Executive Directory',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -25,11 +25,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiShieldEditOutline,
permissions: 'READ_USERS', // Only show to users who can read users (Admins)
menu: [
{
href: '/dashboard',
icon: icon.mdiChartTimelineVariant,
label: 'Analytics',
},
{
href: '/users/users-list',
label: 'Member Accounts',
@ -69,4 +64,4 @@ const menuAside: MenuAsideItem[] = [
},
]
export default menuAside
export default menuAside

View File

@ -1,8 +1,9 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
import React, { useEffect } from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
@ -16,15 +17,16 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => {
const dispatch = useAppDispatch();
const router = useRouter();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const loadingMessage = 'Loading...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
@ -34,29 +36,31 @@ const Dashboard = () => {
const [email_reminders, setEmail_reminders] = React.useState(loadingMessage);
const [audit_events, setAudit_events] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
// Disable dashboard for non-admin members
useEffect(() => {
if (currentUser && !hasPermission(currentUser, 'READ_USERS')) {
router.push('/directory');
}
}, [currentUser, router]);
async function loadData() {
const entities = ['users','roles','permissions','member_profiles','expertise_areas','member_import_batches','email_reminders','audit_events',];
const fns = [setUsers,setRoles,setPermissions,setMember_profiles,setExpertise_areas,setMember_import_batches,setEmail_reminders,setAudit_events,];
const requests = entities.map((entity, index) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
Promise.allSettled(requests).then((results) => {
@ -73,17 +77,25 @@ const Dashboard = () => {
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
if (!hasPermission(currentUser, 'READ_USERS')) return; // Don't load if about to redirect
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
if (!hasPermission(currentUser, 'READ_USERS')) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
// Return null if redirecting to avoid flash of dashboard
if (currentUser && !hasPermission(currentUser, 'READ_USERS')) {
return null;
}
return (
<>
<Head>
@ -141,8 +153,6 @@ const Dashboard = () => {
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
@ -162,8 +172,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
@ -190,8 +198,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
@ -218,8 +224,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
@ -227,7 +231,7 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_MEMBER_PROFILES') && <Link href={'/member_profiles/member_profiles-list'}>
{hasPermission(currentUser, 'READ_MEMBER_PROFILES') && <Link href={'/directory'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
@ -246,8 +250,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
@ -274,8 +276,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiLightbulbOnOutline' in icon ? icon['mdiLightbulbOnOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
@ -302,8 +302,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiFileUploadOutline' in icon ? icon['mdiFileUploadOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
@ -330,8 +328,6 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiEmailOutline' in icon ? icon['mdiEmailOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
@ -358,16 +354,12 @@ const Dashboard = () => {
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div>
</SectionMain>
</>
@ -378,4 +370,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard
export default Dashboard

View File

@ -0,0 +1,288 @@
import React, { ReactElement, useEffect, useMemo } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { mdiLinkedin, mdiMapMarkerOutline, mdiEmailOutline, mdiPhoneOutline, mdiMessageTextOutline, mdiArrowLeft, mdiCrown } from '@mdi/js';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { fetch } from '../../stores/member_profiles/member_profilesSlice';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import BaseIcon from '../../components/BaseIcon';
import BaseButton from '../../components/BaseButton';
import LoadingSpinner from '../../components/LoadingSpinner';
const MemberProfileDetail = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { id } = router.query;
const { member_profiles, loading } = useAppSelector((state) => state.member_profiles);
const { currentUser } = useAppSelector((state) => state.auth);
useEffect(() => {
if (id) {
dispatch(fetch({ id }));
}
}, [dispatch, id]);
const member = member_profiles;
const isProfileAvailable = useMemo(() => {
if (!member) return false;
// Always allow the user to see their own profile?
// The requirement says "not be accessible for inactive/non-consented profiles".
// I will stick to the rule strictly.
const isActive = member.profile_status === 'active';
const isConsented = member.directory_consent === true;
const isReconfirmed = member.confirmation_expires_at ? new Date(member.confirmation_expires_at) > new Date() : false;
return isActive && isConsented && isReconfirmed;
}, [member]);
if (loading || !member) {
return (
<SectionMain>
<div className="flex justify-center py-20">
<LoadingSpinner />
</div>
</SectionMain>
);
}
if (!isProfileAvailable) {
return (
<SectionMain>
<div className="bg-white border border-gray-200 p-12 text-center">
<h2 className="text-2xl font-bold text-gray-400 mb-4">Profile unavailable</h2>
<p className="text-gray-500 mb-8">This profile is currently private or inactive.</p>
<BaseButton
label="Back to Directory"
color="info"
icon={mdiArrowLeft}
onClick={() => router.push('/directory')}
className="bg-[#091EAA] border-none rounded-none"
/>
</div>
</SectionMain>
);
}
const fullName = `${member.user?.firstName || ''} ${member.user?.lastName || ''}`.trim() || 'Anonymous Member';
const headshot = member.headshot && member.headshot[0] ? member.headshot[0].publicUrl : null;
const initials = `${(member.user?.firstName || '').charAt(0)}${(member.user?.lastName || '').charAt(0)}`.toUpperCase() || '?';
const mailtoLink = `mailto:${member.user?.email}?subject=Power Suite Connection ${currentUser?.firstName || 'A Member'}`;
return (
<>
<Head>
<title>{getPageTitle(fullName)}</title>
</Head>
<SectionMain>
<div className="mb-6">
<button
onClick={() => router.push('/directory')}
className="flex items-center text-[#091EAA] hover:underline text-sm font-bold uppercase tracking-widest"
>
<BaseIcon path={mdiArrowLeft} size={18} className="mr-2" />
Back to Directory
</button>
</div>
<div className="bg-white border border-gray-100 shadow-sm overflow-hidden">
{/* Header Section */}
<div className="p-8 md:p-12 bg-gray-50 border-b border-gray-100">
<div className="flex flex-col md:flex-row gap-8 items-start">
{/* Avatar */}
<div className="relative">
{headshot ? (
<img
src={headshot}
alt={fullName}
className="w-32 h-32 md:w-40 md:h-40 rounded-full object-cover border-4 border-white shadow-lg"
/>
) : (
<div
className="w-32 h-32 md:w-40 md:h-40 rounded-full flex items-center justify-center text-white text-4xl font-bold border-4 border-white shadow-lg"
style={{ background: `linear-gradient(135deg, #091EAA 0%, #0055AA 100%)` }}
>
{initials}
</div>
)}
</div>
{/* Basic Info */}
<div className="flex-grow pt-2">
<div className="flex flex-wrap items-center gap-4 mb-2">
<h1 className="text-3xl md:text-4xl font-bold text-[#091EAA] tracking-tight">
{fullName}
</h1>
{member.founding_member && (
<div className="flex items-center bg-[#FDE68A] text-[#92400E] text-[10px] font-bold px-3 py-1 rounded-full uppercase tracking-wider">
<BaseIcon path={mdiCrown} size={12} className="mr-1.5" />
Zeta Proud
</div>
)}
</div>
<div className="inline-block bg-[#091EAA] text-white text-xs font-bold px-3 py-1 uppercase tracking-[0.2em] mb-4">
{member.professional_title || 'Executive'}
</div>
<div className="text-xl text-gray-600 font-light mb-6">
{member.organization}
</div>
<div className="flex flex-wrap gap-6 text-gray-500 text-sm">
<div className="flex items-center">
<BaseIcon path={mdiMapMarkerOutline} size={18} className="mr-2 text-[#091EAA]" />
{member.city}, {member.state}
</div>
{member.member_since && (
<div className="flex items-center">
<span className="font-bold text-[#091EAA] mr-2">Member Since:</span>
{new Date(member.member_since).getFullYear()}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[200px]">
<BaseButton
href={mailtoLink}
label="Send Message"
color="info"
className="w-full bg-[#091EAA] border-none rounded-none py-3 text-sm font-bold uppercase tracking-widest shadow-lg"
/>
<div className="flex justify-center gap-6 py-2">
{member.linkedin_url && (
<a
href={member.linkedin_url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-[#0A66C2] transition-colors"
title="LinkedIn Profile"
>
<BaseIcon path={mdiLinkedin} size={28} />
</a>
)}
{member.allow_phone_contact && member.phone_number && (
<>
<a href={`tel:${member.phone_number}`} className="text-gray-400 hover:text-[#091EAA]" title="Call">
<BaseIcon path={mdiPhoneOutline} size={28} />
</a>
<a href={`sms:${member.phone_number}`} className="text-gray-400 hover:text-[#091EAA]" title="Text">
<BaseIcon path={mdiMessageTextOutline} size={28} />
</a>
</>
)}
</div>
</div>
</div>
</div>
{/* Content Section */}
<div className="p-8 md:p-12 grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Left Column: Bio & Spotlight */}
<div className="lg:col-span-2 space-y-12">
<section>
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-[0.3em] mb-6">Professional Bio</h2>
{member.professional_bio ? (
<div
className="text-gray-700 leading-relaxed font-light text-lg"
dangerouslySetInnerHTML={{ __html: member.professional_bio }}
/>
) : (
<p className="text-gray-400 font-light italic">No bio provided.</p>
)}
</section>
{member.recognition_spotlight && (
<section className="bg-[#091EAA]/5 p-8 border-l-4 border-[#091EAA]">
<h2 className="text-xs font-bold text-[#091EAA] uppercase tracking-[0.3em] mb-6">Recognition & Spotlight</h2>
<div
className="text-gray-700 leading-relaxed font-light"
dangerouslySetInnerHTML={{ __html: member.recognition_spotlight }}
/>
</section>
)}
</div>
{/* Right Column: Expertise & Details */}
<div className="space-y-10">
<section>
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-[0.3em] mb-6">Details</h2>
<div className="space-y-6">
<div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Industry</div>
<div className="text-[#091EAA] font-bold">{member.industry || 'N/A'}</div>
</div>
<div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Sector</div>
<div className="text-[#091EAA] font-bold uppercase tracking-wider text-sm">
{member.sector === 'nonprofit_501c3' ? 'Nonprofit (501c3)' : member.sector}
</div>
</div>
{member.zeta_region && (
<div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Zeta Region</div>
<div className="text-[#091EAA] font-bold">{member.zeta_region}</div>
</div>
)}
{member.leadership_level && (
<div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">Leadership Level</div>
<div className="text-[#091EAA] font-bold uppercase text-sm tracking-wide">
{member.leadership_level.replace(/_/g, ' ')}
</div>
</div>
)}
</div>
</section>
{member.areas_of_expertise && member.areas_of_expertise.length > 0 && (
<section>
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-[0.3em] mb-6">Areas of Expertise</h2>
<div className="flex flex-wrap gap-2">
{member.areas_of_expertise.map((area: any) => (
<span
key={area.id}
className="bg-white border border-[#091EAA] text-[#091EAA] text-[10px] font-bold px-3 py-1.5 uppercase tracking-wider"
>
{area.name}
</span>
))}
</div>
</section>
)}
{member.mentorship_interest && member.mentorship_interest !== 'neither' && (
<section>
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-[0.3em] mb-6">Mentorship</h2>
<div className="bg-gray-50 p-4 border border-gray-100">
<span className="text-[#091EAA] font-bold uppercase text-xs tracking-widest">
Interested as: {member.mentorship_interest}
</span>
</div>
</section>
)}
</div>
</div>
</div>
</SectionMain>
</>
);
};
MemberProfileDetail.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_MEMBER_PROFILES'}>
{page}
</LayoutAuthenticated>
);
};
export default MemberProfileDetail;

View File

@ -0,0 +1,129 @@
import React, { ReactElement, useEffect, useState, useMemo } from 'react';
import Head from 'next/head';
import { mdiMagnify } from '@mdi/js';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { fetch } from '../../stores/member_profiles/member_profilesSlice';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config';
import MemberCard from '../../components/Directory/MemberCard';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import { debounce } from 'lodash';
const ExecutiveDirectory = () => {
const dispatch = useAppDispatch();
const { member_profiles, loading } = useAppSelector((state) => state.member_profiles);
const [searchTerm, setSearchTerm] = useState('');
const [selectedSector, setSelectedSector] = useState('all');
// Load data on mount
useEffect(() => {
const currentDate = new Date().toISOString();
// Base filters: active status, directory consent, and reconfirmation is current
const baseQuery = `?profile_status=active&directory_consent=true&confirmation_expires_atRange=${currentDate}&limit=100`;
dispatch(fetch({ query: baseQuery }));
}, [dispatch]);
// Client-side filtering for live search and sector
// Note: For a real large-scale app, we'd do this server-side,
// but for the directory experience requested, client-side live filtering is smoother if data size allows.
const filteredMembers = useMemo(() => {
if (!member_profiles || !Array.isArray(member_profiles)) return [];
return member_profiles.filter((member: any) => {
const matchesSector =
selectedSector === 'all' ||
member.sector === selectedSector;
const searchLower = searchTerm.toLowerCase();
const fullName = `${member.user?.firstName || ''} ${member.user?.lastName || ''}`.toLowerCase();
const matchesSearch =
fullName.includes(searchLower) ||
(member.professional_title || '').toLowerCase().includes(searchLower) ||
(member.organization || '').toLowerCase().includes(searchLower) ||
(member.industry || '').toLowerCase().includes(searchLower);
return matchesSector && matchesSearch;
});
}, [member_profiles, searchTerm, selectedSector]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
return (
<>
<Head>
<title>{getPageTitle('Executive Directory')}</title>
</Head>
<SectionMain>
{/* Header / Search & Filter Bar */}
<div className="bg-white p-4 md:p-6 mb-8 border border-gray-100 flex flex-col md:flex-row md:items-center justify-between gap-6">
{/* Search Bar */}
<div className="relative flex-grow max-w-xl">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<BaseIcon path={mdiMagnify} size={20} className="text-gray-400" />
</div>
<input
type="text"
placeholder="Search executives, titles, companies..."
className="block w-full pl-11 pr-4 py-3 border-[#091EAA] border-2 focus:ring-0 focus:border-[#091EAA] text-sm placeholder-gray-400 font-light"
value={searchTerm}
onChange={handleSearchChange}
/>
</div>
{/* Sector Filters */}
<div className="flex flex-wrap gap-2">
{[
{ id: 'all', label: 'All Sectors' },
{ id: 'corporate', label: 'Corporate' },
{ id: 'nonprofit_501c3', label: 'Nonprofit (501c3)' },
].map((sector) => (
<button
key={sector.id}
onClick={() => setSelectedSector(sector.id)}
className={`px-6 py-2 text-xs font-bold uppercase tracking-wider border-2 transition-all ${
selectedSector === sector.id
? 'bg-[#091EAA] border-[#091EAA] text-white shadow-lg'
: 'bg-white border-[#091EAA] text-[#091EAA] hover:bg-gray-50'
}`}
>
{sector.label}
</button>
))}
</div>
</div>
{/* Directory Grid */}
{loading ? (
<div className="flex justify-center py-20">
<LoadingSpinner />
</div>
) : filteredMembers.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredMembers.map((member: any) => (
<MemberCard key={member.id} member={member} />
))}
</div>
) : (
<div className="text-center py-20 bg-white border border-dashed border-gray-200">
<p className="text-gray-400 font-light">No members found matching your criteria.</p>
</div>
)}
</SectionMain>
</>
);
};
ExecutiveDirectory.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_MEMBER_PROFILES'}>
{page}
</LayoutAuthenticated>
);
};
export default ExecutiveDirectory;

View File

@ -70,7 +70,7 @@ export default function Starter() {
<div className="flex flex-col items-center pt-4">
<Link
href="/member_profiles/member_profiles-list"
href="/directory"
className="px-16 py-4 text-xl font-bold text-white rounded-none bg-[#091EAA] hover:bg-[#0055AA] border-none shadow-xl transition-all inline-block"
>
Enter Executive Directory
@ -98,4 +98,4 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -31,18 +31,18 @@ export const white: StyleObject = {
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
asideMenuItemActive: 'font-bold text-black dark:text-white',
asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-blue-600',
navBarItemLabel: 'text-pavitra-blue',
navBarItemLabelHover: 'hover:text-black',
navBarItemLabelActiveColor: 'text-black',
overlay: 'from-white via-gray-100 to-white',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
iconsColor: 'text-pavitra-blue',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
focusRingColor: 'focus:ring focus:ring-pavitra-blue focus:border-pavitra-blue focus:outline-none border-gray-300 dark:focus:ring-pavitra-blue dark:focus:border-pavitra-blue',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-blue-600',
linkColor: 'text-pavitra-blue',
websiteHeder: 'border-b border-gray-200',
borders: 'border-gray-200',
shadow: '',
@ -88,14 +88,14 @@ export const basic: StyleObject = {
asideMenuItemActive: 'font-bold text-white',
asideMenuDropdown: 'bg-gray-700/50',
navBarItemLabel: 'text-black',
navBarItemLabelHover: 'hover:text-blue-500',
navBarItemLabelActiveColor: 'text-blue-600',
navBarItemLabelHover: 'hover:text-pavitra-blue',
navBarItemLabelActiveColor: 'text-pavitra-blue',
overlay: 'from-gray-700 via-gray-900 to-gray-700',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
iconsColor: 'text-pavitra-blue',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
focusRingColor: 'focus:ring focus:ring-pavitra-blue focus:border-pavitra-blue focus:outline-none dark:focus:ring-pavitra-blue border-gray-300 dark:focus:border-pavitra-blue',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-black',
@ -104,4 +104,4 @@ export const basic: StyleObject = {
shadow: '',
websiteSectionStyle: '',
textSecondary: '',
}
}

View File

@ -53,7 +53,7 @@ module.exports = {
text: '#45B26B',
},
'pavitra': {
'blue': '#0162FD',
'blue': '#091EAA',
'green': '#00B448',
'orange': '#FFAA00',
'red': '#F20041',
@ -107,4 +107,4 @@ module.exports = {
);
}),
],
}
}