Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e48b4cb24 | ||
|
|
9e5f09aabe | ||
|
|
5788b000f5 | ||
|
|
0c08f4b813 |
BIN
assets/pasted-20260213-200505-610d0266.png
Normal file
BIN
assets/pasted-20260213-200505-610d0266.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
9460
backend/package-lock.json
generated
Normal file
9460
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,5 @@
|
|||||||
const UsersDBApi = require('../db/api/users');
|
const UsersDBApi = require('../db/api/users');
|
||||||
|
const db = require('../db/models');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
|
|||||||
@ -66,6 +66,9 @@ module.exports = class Member_profilesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
|
if (!id) {
|
||||||
|
throw new ValidationError('member_profiles.idRequired');
|
||||||
|
}
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let member_profiles = await Member_profilesDBApi.findBy(
|
let member_profiles = await Member_profilesDBApi.findBy(
|
||||||
@ -134,5 +137,3 @@ module.exports = class Member_profilesService {
|
|||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3113
backend/yarn.lock
3113
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-
|
|||||||
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
||||||
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
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 gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
||||||
|
export const gradientBgExecutive = 'bg-gradient-to-br from-white to-pavitra-blue/[0.07]'
|
||||||
|
|
||||||
export const colorsBgLight = {
|
export const colorsBgLight = {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
@ -14,7 +15,7 @@ export const colorsBgLight = {
|
|||||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
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',
|
danger: 'bg-red-500 border-red-500 text-white',
|
||||||
warning: 'bg-yellow-500 border-yellow-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 = {
|
export const colorsText = {
|
||||||
@ -24,7 +25,7 @@ export const colorsText = {
|
|||||||
success: 'text-emerald-500',
|
success: 'text-emerald-500',
|
||||||
danger: 'text-red-500',
|
danger: 'text-red-500',
|
||||||
warning: 'text-yellow-500',
|
warning: 'text-yellow-500',
|
||||||
info: 'text-blue-500',
|
info: 'text-pavitra-blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const colorsOutline = {
|
export const colorsOutline = {
|
||||||
@ -34,7 +35,7 @@ export const colorsOutline = {
|
|||||||
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
||||||
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
||||||
warning: [colorsText.warning, 'border-yellow-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 = (
|
export const getButtonColor = (
|
||||||
@ -56,7 +57,7 @@ export const getButtonColor = (
|
|||||||
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
||||||
danger: 'ring-red-300 dark:ring-red-700',
|
danger: 'ring-red-300 dark:ring-red-700',
|
||||||
warning: 'ring-yellow-300 dark:ring-yellow-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: {
|
active: {
|
||||||
white: 'bg-gray-100',
|
white: 'bg-gray-100',
|
||||||
@ -66,7 +67,7 @@ export const getButtonColor = (
|
|||||||
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
||||||
danger: 'bg-red-700 dark:bg-red-600',
|
danger: 'bg-red-700 dark:bg-red-600',
|
||||||
warning: 'bg-yellow-700 dark:bg-yellow-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: {
|
bg: {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
@ -76,7 +77,7 @@ export const getButtonColor = (
|
|||||||
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
||||||
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
||||||
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
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: {
|
bgHover: {
|
||||||
white: 'hover:bg-gray-100',
|
white: 'hover:bg-gray-100',
|
||||||
@ -89,7 +90,7 @@ export const getButtonColor = (
|
|||||||
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
'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: {
|
borders: {
|
||||||
white: 'border-white',
|
white: 'border-white',
|
||||||
@ -99,14 +100,14 @@ export const getButtonColor = (
|
|||||||
success: 'border-emerald-600 dark:border-pavitra-blue',
|
success: 'border-emerald-600 dark:border-pavitra-blue',
|
||||||
danger: 'border-red-600 dark:border-red-500',
|
danger: 'border-red-600 dark:border-red-500',
|
||||||
warning: 'border-yellow-600 dark:border-yellow-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: {
|
text: {
|
||||||
contrast: 'dark:text-slate-100',
|
contrast: 'dark:text-slate-100',
|
||||||
success: 'text-emerald-600 dark:text-pavitra-blue',
|
success: 'text-emerald-600 dark:text-pavitra-blue',
|
||||||
danger: 'text-red-600 dark:text-red-500',
|
danger: 'text-red-600 dark:text-red-500',
|
||||||
warning: 'text-yellow-600 dark:text-yellow-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: {
|
outlineHover: {
|
||||||
contrast:
|
contrast:
|
||||||
@ -116,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',
|
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
'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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
138
frontend/src/components/Directory/MemberCard.tsx
Normal file
138
frontend/src/components/Directory/MemberCard.tsx
Normal 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;
|
||||||
@ -6,6 +6,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function Logo({ className = '' }: Props) {
|
export default function Logo({ className = '' }: Props) {
|
||||||
return (
|
return (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
src={"https://flatlogic.com/logo.svg"}
|
src={"https://flatlogic.com/logo.svg"}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef, useState} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { BgKey } from '../interfaces'
|
import { BgKey } from '../interfaces'
|
||||||
import {gradientBgPurplePink, gradientBgDark, gradientBgPinkRed, gradientBgViolet} from '../colors'
|
import {gradientBgPurplePink, gradientBgDark, gradientBgPinkRed, gradientBgViolet, gradientBgExecutive} from '../colors'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -21,6 +21,8 @@ export default function SectionFullScreen({ bg, children }: Props) {
|
|||||||
componentClass += gradientBgPurplePink
|
componentClass += gradientBgPurplePink
|
||||||
} else if (bg === 'pinkRed') {
|
} else if (bg === 'pinkRed') {
|
||||||
componentClass += gradientBgPinkRed
|
componentClass += gradientBgPinkRed
|
||||||
|
} else if (bg === 'executive') {
|
||||||
|
componentClass += gradientBgExecutive
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={componentClass}>{children}</div>
|
return <div className={componentClass}>{children}</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style'
|
|||||||
|
|
||||||
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
|
export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20'
|
||||||
|
|
||||||
export const appTitle = 'created by Flatlogic generator!'
|
export const appTitle = 'The Power Suite Executive Directory'
|
||||||
|
|
||||||
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}`
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ i18n
|
|||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
defaultNS: 'common',
|
||||||
|
ns: ['common'],
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'navigator'],
|
order: ['localStorage', 'navigator'],
|
||||||
lookupLocalStorage: 'app_lang_',
|
lookupLocalStorage: 'app_lang_',
|
||||||
@ -19,3 +21,5 @@ i18n
|
|||||||
},
|
},
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@ -42,7 +42,7 @@ export type ColorButtonKey =
|
|||||||
| 'info'
|
| 'info'
|
||||||
| 'void'
|
| 'void'
|
||||||
|
|
||||||
export type BgKey = 'purplePink' | 'pinkRed' | 'violet'
|
export type BgKey = 'purplePink' | 'pinkRed' | 'violet' | 'executive'
|
||||||
|
|
||||||
export type TrendType = 'up' | 'down' | 'success' | 'danger' | 'warning' | 'info'
|
export type TrendType = 'up' | 'down' | 'success' | 'danger' | 'warning' | 'info'
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
@ -64,6 +63,19 @@ export default function LayoutAuthenticated({
|
|||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) router.push('/error');
|
||||||
}, [currentUser, permission]);
|
}, [currentUser, permission]);
|
||||||
|
|
||||||
|
// Mandatory Opt-In Gating
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
const memberProfile = currentUser?.member_profiles_user?.[0] || currentUser?.member_profiles_user;
|
||||||
|
const isOptInPage = router.pathname === '/profile/opt-in';
|
||||||
|
|
||||||
|
// If user is a member but hasn't consented, redirect to opt-in
|
||||||
|
if (memberProfile && !memberProfile.directory_consent && !isOptInPage) {
|
||||||
|
router.push('/profile/opt-in');
|
||||||
|
}
|
||||||
|
}, [currentUser, router.pathname]);
|
||||||
|
|
||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
|
||||||
@ -122,7 +134,7 @@ export default function LayoutAuthenticated({
|
|||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
<FooterBar>Power Suite Executive Directory © 2026</FooterBar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,88 +3,64 @@ import { MenuAsideItem } from './interfaces'
|
|||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiHomeOutline,
|
||||||
label: 'Dashboard',
|
label: 'Home',
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/directory',
|
||||||
label: 'Users',
|
label: 'Executive Directory',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
icon: icon.mdiCardAccountDetailsOutline ?? icon.mdiTable,
|
||||||
permissions: 'READ_USERS'
|
permissions: 'READ_MEMBER_PROFILES'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/profile',
|
||||||
|
label: 'My Profile',
|
||||||
|
icon: icon.mdiAccountCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Administration',
|
||||||
|
icon: icon.mdiShieldEditOutline,
|
||||||
|
permissions: 'READ_USERS', // Only show to users who can read users (Admins)
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'Member Accounts',
|
||||||
|
icon: icon.mdiAccountGroup,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/member_import_batches/member_import_batches-list',
|
||||||
|
label: 'CSV Imports',
|
||||||
|
icon: icon.mdiFileUploadOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/expertise_areas/expertise_areas-list',
|
||||||
|
label: 'Expertise Areas',
|
||||||
|
icon: icon.mdiLightbulbOnOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/email_reminders/email_reminders-list',
|
||||||
|
label: 'System Emails',
|
||||||
|
icon: icon.mdiEmailOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/audit_events/audit_events-list',
|
||||||
|
label: 'Audit Log',
|
||||||
|
icon: icon.mdiClipboardTextOutline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/roles/roles-list',
|
href: '/roles/roles-list',
|
||||||
label: 'Roles',
|
label: 'Roles',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiShieldAccountVariantOutline,
|
||||||
// @ts-ignore
|
|
||||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_ROLES'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/permissions/permissions-list',
|
href: '/permissions/permissions-list',
|
||||||
label: 'Permissions',
|
label: 'Permissions',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiShieldAccountOutline,
|
||||||
// @ts-ignore
|
|
||||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_PERMISSIONS'
|
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
href: '/member_profiles/member_profiles-list',
|
|
||||||
label: 'Member profiles',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_MEMBER_PROFILES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/expertise_areas/expertise_areas-list',
|
|
||||||
label: 'Expertise areas',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiLightbulbOnOutline' in icon ? icon['mdiLightbulbOnOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_EXPERTISE_AREAS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/member_import_batches/member_import_batches-list',
|
|
||||||
label: 'Member import batches',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFileUploadOutline' in icon ? icon['mdiFileUploadOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_MEMBER_IMPORT_BATCHES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/email_reminders/email_reminders-list',
|
|
||||||
label: 'Email reminders',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiEmailOutline' in icon ? icon['mdiEmailOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_EMAIL_REMINDERS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/audit_events/audit_events-list',
|
|
||||||
label: 'Audit events',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_AUDIT_EVENTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/profile',
|
|
||||||
label: 'Profile',
|
|
||||||
icon: icon.mdiAccountCircle,
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
{
|
|
||||||
href: '/api-docs',
|
|
||||||
target: '_blank',
|
|
||||||
label: 'Swagger API',
|
|
||||||
icon: icon.mdiFileCode,
|
|
||||||
permissions: 'READ_API_DOCS'
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||||
import SectionMain from '../components/SectionMain'
|
import SectionMain from '../components/SectionMain'
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
|
||||||
@ -16,15 +17,16 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
|||||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
|
|
||||||
const loadingMessage = 'Loading...';
|
const loadingMessage = 'Loading...';
|
||||||
|
|
||||||
|
|
||||||
const [users, setUsers] = React.useState(loadingMessage);
|
const [users, setUsers] = React.useState(loadingMessage);
|
||||||
const [roles, setRoles] = React.useState(loadingMessage);
|
const [roles, setRoles] = React.useState(loadingMessage);
|
||||||
const [permissions, setPermissions] = React.useState(loadingMessage);
|
const [permissions, setPermissions] = React.useState(loadingMessage);
|
||||||
@ -34,29 +36,31 @@ const Dashboard = () => {
|
|||||||
const [email_reminders, setEmail_reminders] = React.useState(loadingMessage);
|
const [email_reminders, setEmail_reminders] = React.useState(loadingMessage);
|
||||||
const [audit_events, setAudit_events] = React.useState(loadingMessage);
|
const [audit_events, setAudit_events] = React.useState(loadingMessage);
|
||||||
|
|
||||||
|
|
||||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||||
role: { value: '', label: '' },
|
role: { value: '', label: '' },
|
||||||
});
|
});
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
|
||||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
|
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() {
|
async function loadData() {
|
||||||
const entities = ['users','roles','permissions','member_profiles','expertise_areas','member_import_batches','email_reminders','audit_events',];
|
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 fns = [setUsers,setRoles,setPermissions,setMember_profiles,setExpertise_areas,setMember_import_batches,setEmail_reminders,setAudit_events,];
|
||||||
|
|
||||||
const requests = entities.map((entity, index) => {
|
const requests = entities.map((entity, index) => {
|
||||||
|
|
||||||
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
||||||
return axios.get(`/${entity.toLowerCase()}/count`);
|
return axios.get(`/${entity.toLowerCase()}/count`);
|
||||||
} else {
|
} else {
|
||||||
fns[index](null);
|
fns[index](null);
|
||||||
return Promise.resolve({data: {count: null}});
|
return Promise.resolve({data: {count: null}});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.allSettled(requests).then((results) => {
|
Promise.allSettled(requests).then((results) => {
|
||||||
@ -73,17 +77,25 @@ const Dashboard = () => {
|
|||||||
async function getWidgets(roleId) {
|
async function getWidgets(roleId) {
|
||||||
await dispatch(fetchWidgets(roleId));
|
await dispatch(fetchWidgets(roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
if (!hasPermission(currentUser, 'READ_USERS')) return; // Don't load if about to redirect
|
||||||
loadData().then();
|
loadData().then();
|
||||||
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentUser || !widgetsRole?.role?.value) return;
|
if (!currentUser || !widgetsRole?.role?.value) return;
|
||||||
|
if (!hasPermission(currentUser, 'READ_USERS')) return;
|
||||||
getWidgets(widgetsRole?.role?.value || '').then();
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
}, [widgetsRole?.role?.value]);
|
}, [widgetsRole?.role?.value]);
|
||||||
|
|
||||||
|
// Return null if redirecting to avoid flash of dashboard
|
||||||
|
if (currentUser && !hasPermission(currentUser, 'READ_USERS')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -141,8 +153,6 @@ const Dashboard = () => {
|
|||||||
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||||
|
|
||||||
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-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'}>
|
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
|
||||||
<div
|
<div
|
||||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
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"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
path={icon.mdiAccountGroup || icon.mdiTable}
|
path={icon.mdiAccountGroup || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -190,8 +198,6 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
|
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -218,8 +224,6 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
size={48}
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
path={icon.mdiShieldAccountOutline || icon.mdiTable}
|
path={icon.mdiShieldAccountOutline || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -227,7 +231,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Link>}
|
</Link>}
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_MEMBER_PROFILES') && <Link href={'/member_profiles/member_profiles-list'}>
|
{hasPermission(currentUser, 'READ_MEMBER_PROFILES') && <Link href={'/directory'}>
|
||||||
<div
|
<div
|
||||||
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
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"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
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}
|
path={'mdiBadgeAccountOutline' in icon ? icon['mdiBadgeAccountOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -274,8 +276,6 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
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}
|
path={'mdiLightbulbOnOutline' in icon ? icon['mdiLightbulbOnOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -302,8 +302,6 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
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}
|
path={'mdiFileUploadOutline' in icon ? icon['mdiFileUploadOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -330,8 +328,6 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
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}
|
path={'mdiEmailOutline' in icon ? icon['mdiEmailOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -358,16 +354,12 @@ const Dashboard = () => {
|
|||||||
w="w-16"
|
w="w-16"
|
||||||
h="h-16"
|
h="h-16"
|
||||||
size={48}
|
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}
|
path={'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>}
|
</Link>}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
288
frontend/src/pages/directory/[id].tsx
Normal file
288
frontend/src/pages/directory/[id].tsx
Normal 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;
|
||||||
129
frontend/src/pages/directory/index.tsx
Normal file
129
frontend/src/pages/directory/index.tsx
Normal 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;
|
||||||
@ -38,11 +38,11 @@ export default function Forgot() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Forgot Password')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='executive'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-lg border-gray-100'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
@ -61,11 +61,14 @@ export default function Forgot() {
|
|||||||
type='submit'
|
type='submit'
|
||||||
label={loading ? 'Loading...' : 'Submit' }
|
label={loading ? 'Loading...' : 'Submit' }
|
||||||
color='info'
|
color='info'
|
||||||
|
className='w-full md:w-auto'
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href={'/login'}
|
href={'/login'}
|
||||||
label={'Login'}
|
label={'Login'}
|
||||||
color='info'
|
color='info'
|
||||||
|
outline
|
||||||
|
className='w-full md:w-auto'
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -1,161 +1,97 @@
|
|||||||
|
import React, { ReactElement, useEffect } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import { useRouter } from 'next/router';
|
||||||
import CardBox from '../components/CardBox';
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
import { logoutUser, findMe } from '../stores/authSlice';
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
const router = useRouter();
|
||||||
src: undefined,
|
const dispatch = useAppDispatch();
|
||||||
photographer: undefined,
|
const { currentUser, token } = useAppSelector((state) => state.auth);
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('background');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'Power Suite Executive Directory'
|
// Hydrate auth state if token exists but currentUser doesn't
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
if (token && !currentUser) {
|
||||||
const image = await getPexelsImage();
|
dispatch(findMe());
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
}
|
||||||
fetchData();
|
}, [token, currentUser, dispatch]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
const handleLogout = () => {
|
||||||
<div
|
dispatch(logoutUser());
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
router.push('/login');
|
||||||
style={{
|
};
|
||||||
backgroundImage: `${
|
|
||||||
image
|
// If not authenticated, show a minimal landing with Login CTA
|
||||||
? `url(${image?.src?.original})`
|
if (!token) {
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
return (
|
||||||
}`,
|
<div className="min-h-screen flex flex-col items-center justify-center bg-white px-8">
|
||||||
backgroundSize: 'cover',
|
<Head>
|
||||||
backgroundPosition: 'left center',
|
<title>{getPageTitle('The Power Suite')}</title>
|
||||||
backgroundRepeat: 'no-repeat',
|
</Head>
|
||||||
}}
|
<div className="max-w-[800px] w-full text-center space-y-10">
|
||||||
>
|
<h1 className="text-4xl md:text-5xl font-bold text-[#091EAA] tracking-tight">
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
Zetas in Corporate America: The Power Suite
|
||||||
<a
|
</h1>
|
||||||
className='text-[8px]'
|
<p className="text-lg text-[#6B7280] font-light">
|
||||||
href={image?.photographer_url}
|
A private executive directory connecting Power Suite members across industries, regions, and leadership levels.
|
||||||
target='_blank'
|
</p>
|
||||||
rel='noreferrer'
|
<div className="pt-8">
|
||||||
>
|
<BaseButton
|
||||||
Photo by {image?.photographer} on Pexels
|
href="/login"
|
||||||
</a>
|
label="Login to Access"
|
||||||
|
color="info"
|
||||||
|
className="px-12 py-3 text-lg rounded-none bg-[#091EAA] hover:bg-[#0055AA] border-none shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
// Option C Landing Page for Authenticated Users
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<div className="min-h-screen flex flex-col items-center justify-center bg-white px-8">
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Home')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
<div className="max-w-[800px] w-full text-center space-y-12">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold text-[#091EAA] tracking-tight">
|
||||||
|
Zetas in Corporate America: The Power Suite
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl text-[#6B7280] font-light max-w-2xl mx-auto">
|
||||||
|
A private executive directory connecting Power Suite members across industries, regions, and leadership levels.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="flex flex-col items-center pt-4">
|
||||||
<div
|
<Link
|
||||||
className={`flex ${
|
href="/directory"
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
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"
|
||||||
} min-h-screen w-full`}
|
|
||||||
>
|
>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
Enter Executive Directory
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your Power Suite Executive Directory app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionFullScreen>
|
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="mt-16 flex flex-wrap justify-center gap-x-8 gap-y-4 text-gray-400 font-light uppercase tracking-[0.2em] text-[10px]">
|
||||||
|
<Link href="/profile" className="hover:text-[#091EAA] transition-colors">
|
||||||
|
Update My Profile
|
||||||
|
</Link>
|
||||||
|
<Link href="/profile/reconfirm" className="hover:text-[#091EAA] transition-colors">
|
||||||
|
Reconfirm My Profile
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="hover:text-red-600 transition-colors uppercase tracking-[0.2em] text-[10px] font-light"
|
||||||
|
>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +99,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
@ -20,7 +18,6 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
|||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -28,14 +25,7 @@ export default function Login() {
|
|||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const notify = (type, msg) => toast(msg, { type });
|
||||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('background');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||||
(state) => state.auth,
|
(state) => state.auth,
|
||||||
@ -46,16 +36,6 @@ export default function Login() {
|
|||||||
|
|
||||||
const title = 'Power Suite Executive Directory'
|
const title = 'Power Suite Executive Directory'
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect( () => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage()
|
|
||||||
const video = await getPexelsVideo()
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
// Fetch user data
|
// Fetch user data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
@ -100,70 +80,17 @@ export default function Login() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}>
|
|
||||||
<div className="flex justify-center w-full bg-blue-300/20">
|
|
||||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
|
||||||
by {image?.photographer} on Pexels</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<div className="min-h-screen">
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video.user.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={contentPosition === 'background' ? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
} : {}}>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='executive'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<div className='flex flex-row min-h-screen w-full'>
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full py-12'>
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
|
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3 shadow-sm border-gray-100'>
|
||||||
|
|
||||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
||||||
|
|
||||||
@ -195,7 +122,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
<CardBox className='w-full md:w-3/5 lg:w-2/3 shadow-lg border-gray-100'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
@ -231,7 +158,7 @@ export default function Login() {
|
|||||||
<Field type='checkbox' name='remember' />
|
<Field type='checkbox' name='remember' />
|
||||||
</FormCheckRadio>
|
</FormCheckRadio>
|
||||||
|
|
||||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
<Link className={`${textColor} text-blue-600 font-medium`} href={'/forgot'}>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -248,9 +175,9 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
<br />
|
<br />
|
||||||
<p className={'text-center'}>
|
<p className={'text-center text-gray-600'}>
|
||||||
Don’t have an account yet?{' '}
|
Don’t have an account yet?{' '}
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
<Link className={`${textColor} font-medium`} href={'/register'}>
|
||||||
New Account
|
New Account
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
@ -260,9 +187,9 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
</SectionFullScreen>
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className='bg-white border-t border-gray-100 text-gray-500 flex flex-col text-center justify-center md:flex-row'>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<Link className='py-6 ml-4 text-sm hover:text-gray-700' href='/privacy-policy/'>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
mdiUpload,
|
mdiUpload,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import Image from 'next/image';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { ToastContainer, toast } from 'react-toastify';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
@ -84,7 +85,13 @@ const EditUsers = () => {
|
|||||||
<CardBox>
|
<CardBox>
|
||||||
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
||||||
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
||||||
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
|
<Image
|
||||||
|
className="w-80 h-80 max-w-full max-h-full object-cover object-center"
|
||||||
|
src={`${currentUser?.avatar[0]?.publicUrl}`}
|
||||||
|
alt="Avatar"
|
||||||
|
width={320}
|
||||||
|
height={320}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
<Formik
|
<Formik
|
||||||
|
|||||||
131
frontend/src/pages/profile/opt-in.tsx
Normal file
131
frontend/src/pages/profile/opt-in.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import React, { ReactElement, useState, useEffect } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import CardBox from '../../components/CardBox';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import { findMe } from '../../stores/authSlice';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export default function ProfileOptIn() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Get the member profile ID. It might be in member_profiles_user array or object depending on associations
|
||||||
|
const memberProfile = currentUser?.member_profiles_user?.[0] || currentUser?.member_profiles_user;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (memberProfile?.directory_consent) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}, [memberProfile, router]);
|
||||||
|
|
||||||
|
const handleConsent = async () => {
|
||||||
|
if (!consent) {
|
||||||
|
toast.error('You must provide consent to proceed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (memberProfile?.id) {
|
||||||
|
// Update existing member profile with consent
|
||||||
|
await axios.put(`/member_profiles/${memberProfile.id}`, {
|
||||||
|
id: memberProfile.id,
|
||||||
|
data: {
|
||||||
|
directory_consent: true,
|
||||||
|
consented_at: new Date(),
|
||||||
|
profile_status: 'draft' // Initial status after consent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new member profile with consent
|
||||||
|
await axios.post(`/member_profiles`, {
|
||||||
|
data: {
|
||||||
|
user: currentUser.id,
|
||||||
|
directory_consent: true,
|
||||||
|
consented_at: new Date(),
|
||||||
|
profile_status: 'draft'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(findMe());
|
||||||
|
toast.success('Consent recorded. Welcome to the Directory!');
|
||||||
|
|
||||||
|
// Redirect to the directory list
|
||||||
|
router.push('/member_profiles/member_profiles-list');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Failed to save consent. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Directory Consent')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<div className="max-w-3xl mx-auto py-12">
|
||||||
|
<CardBox>
|
||||||
|
<div className="space-y-8 p-6">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold text-[#091EAA]">Private Directory Consent</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Welcome to Zetas in Corporate America: The Power Suite. To access and be visible in our private executive directory, we require your explicit consent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 p-6 border-l-4 border-[#091EAA] space-y-4 text-sm text-blue-900">
|
||||||
|
<p className="font-semibold uppercase tracking-wider">Mandatory Disclosure</p>
|
||||||
|
<p>
|
||||||
|
I understand this directory is visible only to approved Power Suite members and I consent to having my professional information displayed.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your profile will remain in a "Draft" status and will not be searchable by other members until you complete all required fields, including your headshot and LinkedIn URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3 pt-4">
|
||||||
|
<input
|
||||||
|
id="consent-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-5 w-5 text-[#091EAA] border-gray-300 rounded focus:ring-[#091EAA]"
|
||||||
|
checked={consent}
|
||||||
|
onChange={(e) => setConsent(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="consent-checkbox" className="text-gray-700 font-medium cursor-pointer">
|
||||||
|
I agree to the terms above and wish to opt-in to the Executive Directory.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-8 flex justify-center">
|
||||||
|
<BaseButton
|
||||||
|
label={loading ? 'Processing...' : 'Proceed to Directory'}
|
||||||
|
color="info"
|
||||||
|
className="px-12 py-3 bg-[#091EAA] hover:bg-[#0055AA] border-none rounded-none text-lg shadow-lg"
|
||||||
|
onClick={handleConsent}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileOptIn.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
126
frontend/src/pages/profile/reconfirm.tsx
Normal file
126
frontend/src/pages/profile/reconfirm.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { ReactElement, useState, useEffect } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import CardBox from '../../components/CardBox';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
import { findMe } from '../../stores/authSlice';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export default function ProfileReconfirm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Get the member profile ID
|
||||||
|
const memberProfile = currentUser?.member_profiles_user?.[0] || currentUser?.member_profiles_user;
|
||||||
|
|
||||||
|
const handleReconfirm = async () => {
|
||||||
|
if (!memberProfile?.id) {
|
||||||
|
toast.error('No member profile found. Please complete your profile first.');
|
||||||
|
router.push('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Update existing member profile with reconfirmation date
|
||||||
|
await axios.put(`/member_profiles/${memberProfile.id}`, {
|
||||||
|
id: memberProfile.id,
|
||||||
|
data: {
|
||||||
|
last_confirmed_at: new Date(),
|
||||||
|
confirmation_expires_at: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
|
||||||
|
profile_status: 'active'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatch(findMe());
|
||||||
|
toast.success('Profile reconfirmed successfully. Thank you!');
|
||||||
|
|
||||||
|
// Redirect to the home page or directory
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Failed to reconfirm profile. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentUser) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Annual Profile Reconfirmation')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<div className="max-w-3xl mx-auto py-12">
|
||||||
|
<CardBox>
|
||||||
|
<div className="space-y-8 p-6">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold text-[#091EAA]">Annual Profile Reconfirmation</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
To ensure our Executive Directory remains accurate and valuable for all members, we require an annual reconfirmation of your professional information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 p-6 border-l-4 border-[#091EAA] space-y-4">
|
||||||
|
<h2 className="font-semibold text-blue-900">Current Profile Summary</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase text-xs font-bold">Professional Title</p>
|
||||||
|
<p className="font-medium">{memberProfile?.professional_title || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase text-xs font-bold">Organization</p>
|
||||||
|
<p className="font-medium">{memberProfile?.organization || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase text-xs font-bold">Industry</p>
|
||||||
|
<p className="font-medium">{memberProfile?.industry || 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 uppercase text-xs font-bold">Location</p>
|
||||||
|
<p className="font-medium">{memberProfile ? `${memberProfile.city || ''}, ${memberProfile.state || ''}` : 'Not specified'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-blue-100">
|
||||||
|
<p className="text-xs text-blue-700 italic">
|
||||||
|
If this information is incorrect, please click "Update My Profile" below before reconfirming.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row justify-center items-center gap-4 pt-4">
|
||||||
|
<BaseButton
|
||||||
|
label={loading ? 'Processing...' : 'Confirm Information is Accurate'}
|
||||||
|
color="info"
|
||||||
|
className="px-8 py-3 bg-[#091EAA] hover:bg-[#0055AA] border-none rounded-none text-lg shadow-lg"
|
||||||
|
onClick={handleReconfirm}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
label="Update My Profile First"
|
||||||
|
color="white"
|
||||||
|
className="px-8 py-3 border-[#091EAA] text-[#091EAA] rounded-none text-lg"
|
||||||
|
onClick={() => router.push('/profile')}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileReconfirm.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
@ -39,11 +39,11 @@ export default function Register() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Register')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg='executive'>
|
||||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-lg border-gray-100'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
email: '',
|
email: '',
|
||||||
@ -71,11 +71,14 @@ export default function Register() {
|
|||||||
type='submit'
|
type='submit'
|
||||||
label={loading ? 'Loading...' : 'Register' }
|
label={loading ? 'Loading...' : 'Register' }
|
||||||
color='info'
|
color='info'
|
||||||
|
className='w-full md:w-auto'
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
href={'/login'}
|
href={'/login'}
|
||||||
label={'Login'}
|
label={'Login'}
|
||||||
color='info'
|
color='info'
|
||||||
|
outline
|
||||||
|
className='w-full md:w-auto'
|
||||||
/>
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@ -10,12 +10,19 @@ interface MainState {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitialToken = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return localStorage.getItem('token') || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
const initialState: MainState = {
|
const initialState: MainState = {
|
||||||
/* User */
|
/* User */
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
token: '',
|
token: getInitialToken(),
|
||||||
notify: {
|
notify: {
|
||||||
showNotification: false,
|
showNotification: false,
|
||||||
textNotification: '',
|
textNotification: '',
|
||||||
|
|||||||
@ -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',
|
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',
|
asideMenuItemActive: 'font-bold text-black dark:text-white',
|
||||||
asideMenuDropdown: 'bg-gray-100/75',
|
asideMenuDropdown: 'bg-gray-100/75',
|
||||||
navBarItemLabel: 'text-blue-600',
|
navBarItemLabel: 'text-pavitra-blue',
|
||||||
navBarItemLabelHover: 'hover:text-black',
|
navBarItemLabelHover: 'hover:text-black',
|
||||||
navBarItemLabelActiveColor: 'text-black',
|
navBarItemLabelActiveColor: 'text-black',
|
||||||
overlay: 'from-white via-gray-100 to-white',
|
overlay: 'from-white via-gray-100 to-white',
|
||||||
activeLinkColor: 'bg-gray-100/70',
|
activeLinkColor: 'bg-gray-100/70',
|
||||||
bgLayoutColor: 'bg-gray-50',
|
bgLayoutColor: 'bg-gray-50',
|
||||||
iconsColor: 'text-blue-500',
|
iconsColor: 'text-pavitra-blue',
|
||||||
cardsColor: 'bg-white',
|
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',
|
corners: 'rounded',
|
||||||
cardsStyle: 'bg-white border border-pavitra-400',
|
cardsStyle: 'bg-white border border-pavitra-400',
|
||||||
linkColor: 'text-blue-600',
|
linkColor: 'text-pavitra-blue',
|
||||||
websiteHeder: 'border-b border-gray-200',
|
websiteHeder: 'border-b border-gray-200',
|
||||||
borders: 'border-gray-200',
|
borders: 'border-gray-200',
|
||||||
shadow: '',
|
shadow: '',
|
||||||
@ -88,14 +88,14 @@ export const basic: StyleObject = {
|
|||||||
asideMenuItemActive: 'font-bold text-white',
|
asideMenuItemActive: 'font-bold text-white',
|
||||||
asideMenuDropdown: 'bg-gray-700/50',
|
asideMenuDropdown: 'bg-gray-700/50',
|
||||||
navBarItemLabel: 'text-black',
|
navBarItemLabel: 'text-black',
|
||||||
navBarItemLabelHover: 'hover:text-blue-500',
|
navBarItemLabelHover: 'hover:text-pavitra-blue',
|
||||||
navBarItemLabelActiveColor: 'text-blue-600',
|
navBarItemLabelActiveColor: 'text-pavitra-blue',
|
||||||
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
||||||
activeLinkColor: 'bg-gray-100/70',
|
activeLinkColor: 'bg-gray-100/70',
|
||||||
bgLayoutColor: 'bg-gray-50',
|
bgLayoutColor: 'bg-gray-50',
|
||||||
iconsColor: 'text-blue-500',
|
iconsColor: 'text-pavitra-blue',
|
||||||
cardsColor: 'bg-white',
|
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',
|
corners: 'rounded',
|
||||||
cardsStyle: 'bg-white border border-pavitra-400',
|
cardsStyle: 'bg-white border border-pavitra-400',
|
||||||
linkColor: 'text-black',
|
linkColor: 'text-black',
|
||||||
|
|||||||
@ -53,7 +53,7 @@ module.exports = {
|
|||||||
text: '#45B26B',
|
text: '#45B26B',
|
||||||
},
|
},
|
||||||
'pavitra': {
|
'pavitra': {
|
||||||
'blue': '#0162FD',
|
'blue': '#091EAA',
|
||||||
'green': '#00B448',
|
'green': '#00B448',
|
||||||
'orange': '#FFAA00',
|
'orange': '#FFAA00',
|
||||||
'red': '#F20041',
|
'red': '#F20041',
|
||||||
|
|||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user