diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 212f5e4..dc848a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,53 +1,43 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages +name: Build and Deploy on: - # Runs on pushes targeting the default branch push: - branches: ['master'] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow one concurrent deployment -concurrency: - group: 'pages' - cancel-in-progress: true + branches: + - master jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + build-and-deploy: runs-on: ubuntu-latest + steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v4 - - name: Set up Node + + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '20' cache: 'npm' + - name: Install dependencies run: npm ci + - name: Build run: npm run build - env: - AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }} - AIRTABLE_BASE: ${{ secrets.AIRTABLE_BASE }} - - name: Setup Pages - uses: actions/configure-pages@v4 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 with: - # Upload dist folder - path: './dist' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 # Adjust this to your S3 bucket's region + + - name: Upload to S3 + run: | + aws s3 sync dist/ s3://crf-matching + + - name: Invalidate CloudFront + run: | + aws cloudfront create-invalidation \ + --distribution-id E6AN64MGEH6XD \ + --paths "/*" \ No newline at end of file diff --git a/README.md b/README.md index ee5b245..b1417f2 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ ### Local Setup - Create an [Airtable Personal Access Token](https://support.airtable.com/docs/creating-personal-access-tokens) +- Create an [ArcGIS Online API Key](https://developers.arcgis.com/documentation/security-and-authentication/api-key-authentication/tutorials/create-an-api-key/) - Create a `.env.` file in base folder that looks like this: ``` AIRTABLE_TOKEN=YOUR_TOKEN AIRTABLE_BASE=app54Ce7cZjqk6dLw +AGOL_API_KEY=YOUR_API_KEY ``` Run `npm run dev` for local server diff --git a/src/assets/CSCI_logo.png b/src/assets/CSCI_logo.png new file mode 100644 index 0000000..883aeb5 Binary files /dev/null and b/src/assets/CSCI_logo.png differ diff --git a/src/components/CommunityPane.jsx b/src/components/CommunityPane.jsx index 0096f41..8f3a2c1 100644 --- a/src/components/CommunityPane.jsx +++ b/src/components/CommunityPane.jsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { Typography, Box, Stack } from '@mui/material'; import HeaderBox from './HeaderBox'; import ScoreSection from './ScoreSection'; @@ -29,7 +28,7 @@ const getSectionData = (community, isSelectable, availableOptions, onSelectionCh availableSelections: isSelectable ? availableOptions?.hazards || [] : [], }, { - header: 'Size', + header: 'Community Population', cards: community.size, availableSelections: isSelectable ? availableOptions?.size || [] : [], }, diff --git a/src/components/ComparisonBoard.jsx b/src/components/ComparisonBoard.jsx index 1dd029c..6d67a03 100644 --- a/src/components/ComparisonBoard.jsx +++ b/src/components/ComparisonBoard.jsx @@ -32,7 +32,6 @@ export default function ComparisonBoard({ - - + {/* Header Area with View More Button */} + + {practitioner.org} + - + {truncatedDescription} + + + + + Adaptation Expertise + + + {displayedActivities.map((activity, index) => ( + - } - label="Compare this practitioner" - sx={{ - '& .MuiFormControlLabel-label': { - fontSize: '0.875rem', - color: theme.palette.primary.main, - }, - }} - /> - - - + fontSize: '0.9rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '32px', + textAlign: 'center', + }} + > + {activity} + + ))} + + + + + + + onComparisonSelect(practitioner.airtableRecId, e.target.checked)} + sx={{ + color: theme.palette.primary.main, + '&.Mui-checked': { + color: theme.palette.primary.main, + }, + }} + /> + } + label="Compare this practitioner" + sx={{ + '& .MuiFormControlLabel-label': { + fontSize: '0.875rem', + color: theme.palette.primary.main, + }, + }} + /> + + + + ); } diff --git a/src/components/PractitionerPane.jsx b/src/components/PractitionerPane.jsx index 8504be2..0eac6e5 100644 --- a/src/components/PractitionerPane.jsx +++ b/src/components/PractitionerPane.jsx @@ -1,7 +1,9 @@ import { useRef } from 'react'; -import { Typography, Box, styled } from '@mui/material'; +import { Typography, Box, styled, IconButton } from '@mui/material'; import PersonIcon from '@mui/icons-material/Person'; import SchoolIcon from '@mui/icons-material/School'; +import Button from '@mui/material/Button'; +import InfoIcon from '@mui/icons-material/Info'; import ProfilePopper from './ProfilePopper'; import HeaderBox from './HeaderBox'; @@ -23,41 +25,33 @@ function StrTrainedBadge({ isTrained }) { if (isTrained === 'Yes') { return ( - + - STR Trained + StR Trained ); @@ -77,26 +71,35 @@ function StrTrainedBadge({ isTrained }) { function PractitionerHeader({ strTrained, practitioner, poppedPractitioner, setPoppedPractitioner }) { const headerRef = useRef(null); + const timeoutRef = useRef(null); - const onMouseEnter = (e) => { + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } setPoppedPractitioner(practitioner); }; + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setPoppedPractitioner(null); + }, 100); + }; + return ( - - {/* practitioner label - hidden on xs */} - - {practitioner.org} - - - + + {practitioner.org} + + + + + + + {/* Bottom container for badge, popper, and button */} + + + + + + + + ); } export default function PractitionerPane({ community, practitioner, poppedPractitioner, setPoppedPractitioner }) { // Determine if we're on SelfServicePage by checking if community.name is "Self Service" - const isSelfService = community.name === 'My Community'; + const isSelfService = community.name === 'My Community' || community.name.includes(','); const sections = [ [community.state, practitioner.state], @@ -171,13 +236,11 @@ export default function PractitionerPane({ community, practitioner, poppedPracti return ( (
- {/* Add invisible spacer that matches "Add another" button if on SelfServicePage */} {isSelfService && ( - {/* Empty box with same dimensions as Add Another button */} - + /> )}
))} diff --git a/src/components/ProfilePopper.jsx b/src/components/ProfilePopper.jsx index 9bcdf5a..89e8b79 100644 --- a/src/components/ProfilePopper.jsx +++ b/src/components/ProfilePopper.jsx @@ -1,173 +1,137 @@ import Popper from '@mui/material/Popper'; import ContactRow from './ContactRow'; import CloseIcon from '@mui/icons-material/Close'; -import { IconButton, Typography, Box, Stack, Button, ClickAwayListener } from '@mui/material'; -import PersonIcon from './svg/PersonIcon'; +import PersonIcon from '@mui/icons-material/Person'; +import { IconButton, Typography, Box, Stack, Button } from '@mui/material'; import theme from '../theme'; -export default function ProfilePopper({ practitioner, poppedPractitioner, setPoppedPractitioner, headerRef }) { +export default function ProfilePopper({ + practitioner, + poppedPractitioner, + setPoppedPractitioner, + headerRef, + onMouseEnter, + onMouseLeave, +}) { const open = practitioner === poppedPractitioner; const id = open ? `profile-popper-${practitioner.id}` : undefined; - const handleClose = (e) => { - setPoppedPractitioner(null); - }; - return ( - - + - {/* triangle above box */} - - - - - + {practitioner.org} + - {/* content */} - - {/* close button */} - - - - - {/* title and description */} - {practitioner.org} + Practitioner Org Contact + + + + + - {/* inner box */} - - - Practitioner Org Contact - - - - - - - - {/* link to full profile */} - - - - - - + {/* link to full profile */} + {/**/} + {/* }*/} + {/* >*/} + {/* Full Practitioner Org Profile*/} + {/* */} + {/**/} +
+ ); } diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..ccec0c5 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,20 @@ +import { Snackbar } from '@mui/material'; +import theme from '../theme'; + +export default function Toast({ open, message, onClose }) { + return ( + + ); +} diff --git a/src/components/svg/GradCapIcon.jsx b/src/components/svg/GradCapIcon.jsx deleted file mode 100644 index 01376ac..0000000 --- a/src/components/svg/GradCapIcon.jsx +++ /dev/null @@ -1,28 +0,0 @@ - -import theme from '../../theme'; -import { SvgIcon } from '@mui/material'; - -export default function GradCapIcon() { - return ( - - - - - - ) -} \ No newline at end of file diff --git a/src/components/svg/OpenBook.jsx b/src/components/svg/OpenBook.jsx deleted file mode 100644 index 7d2639d..0000000 --- a/src/components/svg/OpenBook.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import theme from '../../theme'; -import { SvgIcon } from '@mui/material'; - -export default function OpenBookIcon() { - return ( - - - - - - ) -} diff --git a/src/components/svg/PersonIcon.jsx b/src/components/svg/PersonIcon.jsx deleted file mode 100644 index 1e2fc4b..0000000 --- a/src/components/svg/PersonIcon.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { SvgIcon } from "@mui/material" - -import theme from "../../theme" - -export default function PersonIcon ({ sx }) { - return - - - - - -} - diff --git a/src/main.jsx b/src/main.jsx index 72a0a4a..7b7c1c6 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,9 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; -import { createHashRouter, RouterProvider } from 'react-router-dom'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { CssBaseline } from '@mui/material'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from './theme'; import CommunityListPage from './pages/CommunityListPage.jsx'; import CommunityPage from './pages/CommunityPage.jsx'; @@ -9,7 +12,7 @@ import PractitionerPage from './pages/PractitionerPage.jsx'; import PractitionerListPage from './pages/PractitionerListPage.jsx'; import SelfServicePage from './pages/SelfServicePage.jsx'; -const router = createHashRouter([ +const router = createBrowserRouter([ { path: '/', element: , @@ -38,6 +41,9 @@ const router = createHashRouter([ ReactDOM.createRoot(document.getElementById('root')).render( - + + + + ); diff --git a/src/pages/CommunityListPage.jsx b/src/pages/CommunityListPage.jsx index fe26b9b..fb633b1 100644 --- a/src/pages/CommunityListPage.jsx +++ b/src/pages/CommunityListPage.jsx @@ -15,16 +15,18 @@ import { fetchAllCommunities } from '../util/api'; // components import FullPageSpinner from '../components/FullPageSpinner'; +import Logo from '../components/Logo'; function CommunitiesPageLoaded({ communities }) { return (
+ {/* CSCI Logo */}

CRF Community Matching Tool

@@ -43,7 +45,7 @@ function CommunitiesPageLoaded({ communities }) { component="th" scope="row" > - {community.name} + {community.name} ))} diff --git a/src/pages/CommunityPage.jsx b/src/pages/CommunityPage.jsx index e405666..1ec6d2c 100644 --- a/src/pages/CommunityPage.jsx +++ b/src/pages/CommunityPage.jsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { fetchCommunity, fetchPractitionersForCommunity } from '../util/api'; +import { Container } from '@mui/material'; import ComparisonBoard from '../components/ComparisonBoard'; +import Logo from '../components/Logo'; import FullPageSpinner from '../components/FullPageSpinner'; export default function CommunityPage() { @@ -20,12 +22,17 @@ export default function CommunityPage() { } return ( - + <> + + {/* CSCI Logo */} + + + ); } diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 4ccbe51..6e751bc 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import { Autocomplete, TextField, + CircularProgress, Typography, Container, Box, @@ -12,72 +14,154 @@ import { Chip, Menu, MenuItem, - ToggleButtonGroup, - ToggleButton, } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; import LocationOnIcon from '@mui/icons-material/LocationOn'; import AddIcon from '@mui/icons-material/Add'; import TuneIcon from '@mui/icons-material/Tune'; import WindowIcon from '@mui/icons-material/Window'; import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; +import ShareIcon from '@mui/icons-material/Share'; +import ClearAllIcon from '@mui/icons-material/ClearAll'; +import { PersonOffOutlined } from '@mui/icons-material'; +import { FormatListBulleted } from '@mui/icons-material'; +import CancelIcon from '@mui/icons-material/Cancel'; +import IconButton from '@mui/material/IconButton'; import { fetchFilteredPractitioners, fetchOptionsFromAirtable, fetchAllPractitioners } from '../util/api'; +import Toast from '../components/Toast'; +import ComparisonBoard from '../components/ComparisonBoard'; import PractitionerCard from '../components/PractitionerCard'; +import { searchLocations, getLocationDetails } from '../util/geocoding'; +import { filtersToSearchParams, searchParamsToFilters, generateShareableUrl } from '../util/urlStateManagement'; +import Logo from '../components/Logo'; const PRACTITIONERS_PER_PAGE = 6; -// State capitals data -const cityData = [ - { city: 'Montgomery', state: 'Alabama' }, - { city: 'Juneau', state: 'Alaska' }, - { city: 'Phoenix', state: 'Arizona' }, - { city: 'Little Rock', state: 'Arkansas' }, - { city: 'Sacramento', state: 'California' }, - { city: 'Denver', state: 'Colorado' }, - { city: 'Hartford', state: 'Connecticut' }, - { city: 'Dover', state: 'Delaware' }, - { city: 'Tallahassee', state: 'Florida' }, - { city: 'Atlanta', state: 'Georgia' }, - { city: 'Honolulu', state: 'Hawaii' }, - { city: 'Boise', state: 'Idaho' }, - { city: 'Springfield', state: 'Illinois' }, - { city: 'Indianapolis', state: 'Indiana' }, - { city: 'Des Moines', state: 'Iowa' }, - { city: 'Topeka', state: 'Kansas' }, - { city: 'Frankfort', state: 'Kentucky' }, - { city: 'Baton Rouge', state: 'Louisiana' }, - { city: 'Augusta', state: 'Maine' }, - { city: 'Annapolis', state: 'Maryland' }, - { city: 'Boston', state: 'Massachusetts' }, - { city: 'Lansing', state: 'Michigan' }, - { city: 'Saint Paul', state: 'Minnesota' }, - { city: 'Jackson', state: 'Mississippi' }, - { city: 'Jefferson City', state: 'Missouri' }, - { city: 'Helena', state: 'Montana' }, - { city: 'Lincoln', state: 'Nebraska' }, - { city: 'Carson City', state: 'Nevada' }, - { city: 'Concord', state: 'New Hampshire' }, - { city: 'Trenton', state: 'New Jersey' }, - { city: 'Santa Fe', state: 'New Mexico' }, - { city: 'Albany', state: 'New York' }, - { city: 'Raleigh', state: 'North Carolina' }, - { city: 'Bismarck', state: 'North Dakota' }, - { city: 'Columbus', state: 'Ohio' }, - { city: 'Oklahoma City', state: 'Oklahoma' }, - { city: 'Salem', state: 'Oregon' }, - { city: 'Harrisburg', state: 'Pennsylvania' }, - { city: 'Providence', state: 'Rhode Island' }, - { city: 'Columbia', state: 'South Carolina' }, - { city: 'Pierre', state: 'South Dakota' }, - { city: 'Nashville', state: 'Tennessee' }, - { city: 'Austin', state: 'Texas' }, - { city: 'Salt Lake City', state: 'Utah' }, - { city: 'Montpelier', state: 'Vermont' }, - { city: 'Richmond', state: 'Virginia' }, - { city: 'Olympia', state: 'Washington' }, - { city: 'Charleston', state: 'West Virginia' }, - { city: 'Madison', state: 'Wisconsin' }, - { city: 'Cheyenne', state: 'Wyoming' }, -]; +const LocationSearch = ({ value, onChange, disabled }) => { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [inputValue, setInputValue] = useState(''); + + // Update input value when value prop changes + useEffect(() => { + if (value?.fullText) { + setInputValue(value.fullText); + } + }, [value]); + + const handleInputChange = async (event, newInputValue) => { + setInputValue(newInputValue); + + if (newInputValue.length >= 3) { + setLoading(true); + const suggestions = await searchLocations(newInputValue); + // Transform suggestions to match the selected value format + const transformedSuggestions = suggestions.map((suggestion) => ({ + ...suggestion, + fullText: suggestion.text, + })); + setOptions(transformedSuggestions); + setLoading(false); + } else { + setOptions([]); + } + }; + + const handleChange = async (event, newValue) => { + if (newValue?.magicKey) { + setLoading(true); + const details = await getLocationDetails(newValue.magicKey); + if (details) { + onChange(event, details); + } + setLoading(false); + } else { + onChange(event, null); + } + }; + + const handleClear = (event) => { + event.stopPropagation(); // Prevent triggering other click handlers + onChange(null, null); + setInputValue(''); + }; + + return ( + { + if (!option) return ''; + return option.fullText || option.text || ''; + }} + isOptionEqualToValue={(option, value) => { + if (!option || !value) return false; + return option.fullText === value.fullText; + }} + filterOptions={(x) => x} + autoComplete + includeInputInList + filterSelectedOptions + loading={loading} + loadingText="Searching..." + noOptionsText={inputValue.length < 3 ? 'Type at least 3 characters' : 'No locations found'} + open={open && inputValue.length >= 3} + onOpen={() => setOpen(true)} + onClose={() => setOpen(false)} + disabled={disabled} + sx={{ flexGrow: 1 }} + popupIcon={null} + renderInput={(params) => ( + , + endAdornment: ( + <> + {loading ? ( + + ) : value ? ( + + + + ) : null} + + ), + }} + /> + )} + /> + ); +}; const FilterSection = ({ title, description, type, selected, availableOptions, onAdd, onRemove }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -104,6 +188,8 @@ const FilterSection = ({ title, description, type, selected, availableOptions, o return 'Add hazard'; case 'sectors': return 'Add sector'; + case 'size': + return 'Add population'; default: return 'Add'; } @@ -117,8 +203,16 @@ const FilterSection = ({ title, description, type, selected, availableOptions, o @@ -128,15 +222,17 @@ const FilterSection = ({ title, description, type, selected, availableOptions, o > {title} - + {/**/} + {/* Learn more*/} + {/**/} { +const ViewToggle = ({ view, onViewChange, selectedCount, onClearSelected }) => { return ( { width: '100%', mb: 3, gap: 2, + position: 'relative', // For absolute positioning of clear button }} > { { Compare + + {/* Clear Selected Button - Only show when there are selected practitioners */} + {selectedCount > 0 && ( + + )} ); }; export default function LandingPage() { + const theme = useTheme(); + const [toastOpen, setToastOpen] = useState(false); const [selectedLocation, setSelectedLocation] = useState(null); const [selectedState, setSelectedState] = useState(''); const [practitioners, setPractitioners] = useState([]); @@ -281,6 +404,8 @@ export default function LandingPage() { const [showFilters, setShowFilters] = useState(false); const [currentView, setCurrentView] = useState('cards'); const [displayCount, setDisplayCount] = useState(PRACTITIONERS_PER_PAGE); + const [selectedForComparison, setSelectedForComparison] = useState(new Set()); + const [searchParams, setSearchParams] = useSearchParams(); const [filters, setFilters] = useState({ activities: [], sectors: [], @@ -294,9 +419,46 @@ export default function LandingPage() { size: [], }); - const visiblePractitioners = practitioners.slice(0, displayCount); - const hasMorePractitioners = practitioners.length > displayCount; - const hasAnyFilters = Object.values(filters).some((arr) => arr.length > 0) || selectedState; + // Load state from URL on initial render + useEffect(() => { + const loadStateFromUrl = async () => { + if (searchParams.toString()) { + const { + filters: urlFilters, + location, + view, + selectedPractitioners, + } = await searchParamsToFilters(searchParams); + + // Update all state from URL + setFilters(urlFilters); + setSelectedLocation(location.selectedLocation); + setSelectedState(location.selectedState); + + // Set selected practitioners from URL + if (selectedPractitioners.length > 0) { + setSelectedForComparison(new Set(selectedPractitioners)); + } + + if (view) { + setCurrentView(view); + } + } + }; + + loadStateFromUrl(); + }, []); + + // Helper to check if all filters are empty + const areFiltersEmpty = () => { + return Object.values(filters).every((arr) => arr.length === 0); + }; + + // Update URL when filters, location, or selected practitioners change + useEffect(() => { + const params = filtersToSearchParams(filters, selectedLocation, currentView, Array.from(selectedForComparison)); + setSearchParams(params); + }, [filters, selectedLocation, currentView, selectedForComparison]); useEffect(() => { fetchOptionsFromAirtable(setAvailableOptions); @@ -310,25 +472,96 @@ export default function LandingPage() { }, []); useEffect(() => { - if (selectedState || Object.values(filters).some((arr) => arr.length > 0)) { - fetchFilteredPractitioners( - { - state: selectedState ? [selectedState] : [], - ...filters, - }, - setPractitioners - ); + if (Object.values(filters).some((arr) => arr.length > 0)) { + fetchFilteredPractitioners(filters, setPractitioners); } else { setPractitioners([]); } - }, [selectedState, filters]); + }, [filters]); + + // Get community name based on selection state + const getCommunityName = () => { + if (areFiltersEmpty()) { + return 'My Community'; + } + return selectedLocation ? `${selectedLocation.city}, ${selectedLocation.state}` : 'My Community'; + }; + + const community = { + name: getCommunityName(), + state: filters.state || (selectedState ? [selectedState] : []), // Use filters.state if available + activities: filters.activities, + sectors: filters.sectors, + hazards: filters.hazards, + size: filters.size, + totalCategories: + (filters.state?.length || (selectedState ? 1 : 0)) + + filters.activities.length + + filters.sectors.length + + filters.hazards.length + + filters.size.length, + }; + + const visiblePractitioners = practitioners.slice(0, displayCount); + const hasMorePractitioners = practitioners.length > displayCount; + const hasAnyFilters = Object.values(filters).some((arr) => arr.length > 0) || selectedState; + + const handleShare = async () => { + const shareableUrl = generateShareableUrl( + filters, + selectedLocation, + currentView, + Array.from(selectedForComparison) + ); + + try { + await navigator.clipboard.writeText(shareableUrl); + setToastOpen(true); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleToastClose = () => { + setToastOpen(false); + }; const handleLocationSelect = (event, newValue) => { setSelectedLocation(newValue); if (newValue) { setSelectedState(newValue.state); + setFilters((prev) => ({ + ...prev, + state: [newValue.state], + })); + setShowFilters(true); } else { setSelectedState(''); + setFilters((prev) => ({ + ...prev, + state: [], + })); + } + }; + + const handleSelectionChange = (category, newSelections) => { + if (category === 'state') { + // Clear location selection if state is removed + if (newSelections.length === 0) { + setSelectedLocation(null); + setSelectedState(''); + } + // Update filters + setFilters((prev) => ({ + ...prev, + [category]: newSelections, + })); + } else { + // Handle other categories normally + setFilters((prev) => ({ + ...prev, + [category]: newSelections, + })); } }; @@ -346,17 +579,80 @@ export default function LandingPage() { })); }; + const handleClearAllFilters = () => { + setSelectedLocation(null); + setShowFilters(false); + setFilters({ + activities: [], + sectors: [], + hazards: [], + size: [], + state: [], + }); + }; + const handleViewChange = (event, newView) => { if (newView !== null) { + // When switching views, maintain the current displayCount unless we're filtering for selected practitioners + if (newView === 'compare' && selectedForComparison.size > 0) { + // Only show selected practitioners in compare view if there are any selected + const selectedPractitioners = practitioners.filter((p) => selectedForComparison.has(p.airtableRecId)); + setDisplayCount(selectedPractitioners.length); + } setCurrentView(newView); } }; + const handleResetCommunity = () => { + // Reset location state + setSelectedLocation(null); + setSelectedState(''); + setSelectedForComparison(new Set()); + + // Reset all filters + setFilters({ + activities: [], + sectors: [], + hazards: [], + size: [], + state: [], + }); + + // Reset practitioners + setPractitioners([]); + + // Reset display count back to initial value + setDisplayCount(PRACTITIONERS_PER_PAGE); + + // Collapse filters section if it's expanded + setShowFilters(false); + + // Reset view back to cards if in compare mode + setCurrentView('cards'); + }; + + const handleComparisonSelect = (practitionerId, isSelected) => { + setSelectedForComparison((prev) => { + const newSelected = new Set(prev); + if (isSelected) { + newSelected.add(practitionerId); + } else { + newSelected.delete(practitionerId); + } + return newSelected; + }); + }; + + const handleClearSelectedPractitioners = () => { + setSelectedForComparison(new Set()); + }; + return ( + {/* CSCI Logo */} Looking to connect to an adaptation practitioner? - - `${option.city}, ${option.state}`} - sx={{ flexGrow: 1 }} - renderInput={(params) => ( - , - }} - /> - )} + disabled={false} /> {selectedLocation && ( @@ -428,10 +711,7 @@ export default function LandingPage() { variant="contained" size="small" startIcon={} - onClick={() => { - setSelectedLocation(null); - setSelectedState(''); - }} + onClick={handleResetCommunity} sx={{ bgcolor: 'grey.400', textTransform: 'none', @@ -450,19 +730,89 @@ export default function LandingPage() { setShowFilters(!showFilters)} > - - } + onClick={() => setShowFilters(!showFilters)} + sx={{ + textTransform: 'none', + bgcolor: 'grey.500', + '&:hover': { + bgcolor: 'grey.600', + }, + }} > Filter practitioners by their expertise - + + + {/* Only show browse all when no community is selected */} + {!selectedLocation && ( + + )} + + {/* Only show clear button if there are filters applied */} + {(filters.activities.length > 0 || + filters.sectors.length > 0 || + filters.hazards.length > 0 || + filters.size.length > 0) && ( + + )} + {/* Filter Sections */} @@ -470,7 +820,7 @@ export default function LandingPage() { handleAddFilter('sectors', value)} onRemove={(value) => handleRemoveFilter('sectors', value)} /> + handleAddFilter('size', value)} + onRemove={(value) => handleRemoveFilter('size', value)} + /> - {/* Practitioners Section */} {practitioners.length > 0 && hasAnyFilters && ( - - Adaptation practitioners that can help your community - + + Adaptation practitioners that can help your community + + + + + + {currentView === 'cards' ? ( + <> + + {visiblePractitioners.length} out of {practitioners.length} practitioners selected from the{' '} + {totalPractitioners} available in the{' '} + + The Registry of Adaptation Practitioners + + - - {visiblePractitioners.length} out of {practitioners.length} practitioners selected from the{' '} - {totalPractitioners} available in the practitioner registry - - - - {visiblePractitioners.map((practitioner, index) => ( - + {visiblePractitioners.map((practitioner, index) => ( + + + + ))} - ))} - - - {hasMorePractitioners && ( - - - - )} + {/* Load More Button */} + {hasMorePractitioners && ( + + + + )} + + ) : ( + // Compare view + + selectedForComparison.size === 0 ? true : selectedForComparison.has(p.airtableRecId) + )} + isSelectable={true} + availableOptions={availableOptions} + onSelectionChange={handleSelectionChange} + displayCount={displayCount} + setDisplayCount={setDisplayCount} + /> + )}{' '} )} diff --git a/src/pages/OldLandingPage.jsx b/src/pages/OldLandingPage.jsx index 4d7830f..11c1047 100644 --- a/src/pages/OldLandingPage.jsx +++ b/src/pages/OldLandingPage.jsx @@ -28,7 +28,7 @@ export default function OldLandingPage() { >