From 614d09b3387e2114d99f39836219c750b4a27f34 Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Tue, 29 Jul 2025 16:25:42 +0100 Subject: [PATCH 01/13] filters in URL queries --- .../OpportunityCards/Filters/constants.tsx | 8 ++ .../OpportunityCards/OpportunityCards.tsx | 63 ++++++++++- .../OpportunityCardsHeader.tsx | 5 +- src/components/core/common/Search.tsx | 15 ++- src/utils/index.ts | 101 ++++++++++++++++++ 5 files changed, 183 insertions(+), 9 deletions(-) diff --git a/src/components/OpportunityCards/Filters/constants.tsx b/src/components/OpportunityCards/Filters/constants.tsx index 1648a87..97ccf05 100644 --- a/src/components/OpportunityCards/Filters/constants.tsx +++ b/src/components/OpportunityCards/Filters/constants.tsx @@ -44,3 +44,11 @@ export const defaultFilter: CardsFilter = { }, }, }; + +export const FILTER_KEYS = [ + "searchInput", + "activityType", + "district", + "daySlot", + "accompanying", +] as const; diff --git a/src/components/OpportunityCards/OpportunityCards.tsx b/src/components/OpportunityCards/OpportunityCards.tsx index 1fe93ff..42487af 100644 --- a/src/components/OpportunityCards/OpportunityCards.tsx +++ b/src/components/OpportunityCards/OpportunityCards.tsx @@ -1,13 +1,20 @@ +import { useLocation, useSearchParams } from "react-router-dom"; import styled from "styled-components"; import { Lang, OpportunityType } from "need4deed-sdk"; import { useTranslation } from "react-i18next"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Cards from "./Cards"; import { urlApiOpportunity } from "../../config/constants"; import OpportunityCardsHeader from "./OpportunityCardsHeader"; import MapView from "./MapView"; import Filters from "./Filters/Filters"; -import { defaultFilter } from "./Filters/constants"; +import { defaultFilter, FILTER_KEYS } from "./Filters/constants"; +import { + deserializeFilters, + getFilterKeysExcludingSearch, + serializeFilters, +} from "../../utils"; +import { CardsFilter } from "./types"; const OpportunitiesContainer = styled.div` display: flex; @@ -21,23 +28,68 @@ const OpportunitiesContainer = styled.div` `; export function OpportunityCards() { + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const query = new URLSearchParams(location.search); const { i18n, t } = useTranslation(); const [numOfOpportunities, setNumOfOpportunities] = useState(0); const [cardsFilter, setCardsFilter] = useState(defaultFilter); const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [isFiltersOpen, setIsFiltersOpen] = useState(false); + const [ToggledFilters, setToggledFilters] = useState(false); + + const handleFilterUpdate = ( + newFilter: CardsFilter | ((prev: CardsFilter) => CardsFilter), + ) => { + setToggledFilters(true); + const updatedFilter = + typeof newFilter === "function" ? newFilter(cardsFilter) : newFilter; + + setCardsFilter(updatedFilter); + + const queryParams = serializeFilters(updatedFilter); + + setSearchParams(queryParams); + }; + + const initializeFilter = ( + incomingFilter: CardsFilter | ((prev: CardsFilter) => CardsFilter), + ) => { + const baseFilter = + typeof incomingFilter === "function" + ? incomingFilter(cardsFilter) + : incomingFilter; + + const hasFilterParams = FILTER_KEYS.some((key) => query.has(key)); + + const finalFilter = hasFilterParams + ? deserializeFilters(query, baseFilter) + : baseFilter; + + setCardsFilter(finalFilter); + }; const onSearchInputChange = (searchInput: string) => { - setCardsFilter({ ...cardsFilter, searchInput }); + handleFilterUpdate((prev) => ({ ...prev, searchInput })); }; + useEffect(() => { + if (ToggledFilters) return; + + const hasRelevantFilters = getFilterKeysExcludingSearch().some((key) => + searchParams.has(key), + ); + + setIsFiltersOpen(hasRelevantFilters); + }, [searchParams, ToggledFilters]); + const tabs = [t("opportunityPage.tabs.tab1"), t("opportunityPage.tabs.tab2")]; return ( @@ -45,6 +97,7 @@ export function OpportunityCards() { // Todo: temporarily just show numOfOpportunities as 0. when map view is available refactor below line. numOfOpportunities={selectedTabIndex === 0 ? numOfOpportunities : 0} onSearchInputChange={onSearchInputChange} + cardsFilter={cardsFilter} tabs={tabs} selectedTabIndex={selectedTabIndex} setSelectedTabIndex={setSelectedTabIndex} @@ -68,7 +121,7 @@ export function OpportunityCards() { popup setNumOfOpportunities={setNumOfOpportunities} cardsFilter={cardsFilter} - setCardsFilter={setCardsFilter} + setCardsFilter={initializeFilter} isFiltersOpen={isFiltersOpen} /> ) : ( diff --git a/src/components/OpportunityCards/OpportunityCardsHeader.tsx b/src/components/OpportunityCards/OpportunityCardsHeader.tsx index 8a43dc9..777adef 100644 --- a/src/components/OpportunityCards/OpportunityCardsHeader.tsx +++ b/src/components/OpportunityCards/OpportunityCardsHeader.tsx @@ -7,6 +7,7 @@ import useScreenType from "../../hooks/useScreenType"; import { ScreenTypes } from "../../config/types"; import ResultsFound from "./ResultsFound"; import { hyphenationStyles } from "../styled/mixins"; +import { CardsFilter } from "./types"; const HeaderContainer = styled.div` display: flex; @@ -64,6 +65,7 @@ interface Props { selectedTabIndex: number; setSelectedTabIndex: (index: number) => void; setIsFiltersOpen: (isOpen: boolean) => void; + cardsFilter?: CardsFilter; } export default function OpportunityCardsHeader({ @@ -73,11 +75,11 @@ export default function OpportunityCardsHeader({ setSelectedTabIndex, tabs, setIsFiltersOpen, + cardsFilter, }: Props) { const { t } = useTranslation(); const screenSize = useScreenType(); const isMobile = screenSize === ScreenTypes.MOBILE; - return ( {t("opportunityPage.header")} @@ -108,6 +110,7 @@ export default function OpportunityCardsHeader({ placeHolder={`${t("opportunityPage.searchPlaceHolder")} ...`} onInputChange={onSearchInputChange} width="var(--opportunities-header-searchbar-width)" + cardsFilter={cardsFilter} /> {isMobile ? ( diff --git a/src/components/core/common/Search.tsx b/src/components/core/common/Search.tsx index 48ad9f0..e47e7e9 100644 --- a/src/components/core/common/Search.tsx +++ b/src/components/core/common/Search.tsx @@ -1,6 +1,7 @@ import styled from "styled-components"; import { MagnifyingGlassIcon } from "@phosphor-icons/react"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; +import { CardsFilter } from "../../OpportunityCards/types"; interface SearchContainerProps { width?: string; @@ -21,7 +22,7 @@ const SearchContainer = styled.div` const StyledInput = styled.input` font-size: var(--search-input-font-size); border: none; - + flex: 1; &:focus { outline: none; } @@ -31,14 +32,22 @@ interface Props { placeHolder?: string; onInputChange: (input: string) => void; width?: string; + cardsFilter?: CardsFilter; } export function Search({ placeHolder = "Search", onInputChange, width, + cardsFilter, }: Props) { - const [inputValue, setInputValue] = useState(""); + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (cardsFilter) { + setInputValue(cardsFilter.searchInput); + } + }, [cardsFilter]); const handleInputChange = (e: ChangeEvent) => { const newValue = e.target.value; diff --git a/src/utils/index.ts b/src/utils/index.ts index f47d68c..e4b2edb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,6 +14,9 @@ import { Subpages, YesNo, } from "../config/types"; +import { FILTER_KEYS } from "../components/OpportunityCards/Filters/constants"; +import { CardsFilter } from "../components/OpportunityCards/types"; +import { TimeSlot, Weekday } from "../components/forms/types"; export function pivotArrayToObj(arr: Array>) { const [first] = arr; @@ -47,6 +50,11 @@ export function getBaseUrl(url: string) { return baseUrl ? `/${baseUrl}` : ""; } +export const hasKey = ( + obj: T, + key: PropertyKey, +): key is keyof T => Object.prototype.hasOwnProperty.call(obj, key); + export function setLangDirection( containerRef: MutableRefObject, lng: Lang, @@ -470,3 +478,96 @@ export function setStoredLang(lang: Lang) { console.warn(`Invalid language code: ${lang}`); } } + +export function serializeFilters(filters: CardsFilter): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.searchInput) { + params.set("searchInput", filters.searchInput); + } + + if (filters.accompanying) { + params.set("accompanying", "true"); + } + + if (filters.activityType) { + Object.entries(filters.activityType).forEach(([key, value]) => { + if (value === true) { + params.append("activityType", key); + } + }); + } + + if (filters.district) { + Object.entries(filters.district).forEach(([key, value]) => { + if (value === true) { + params.append("district", key); + } + }); + } + + if (filters.days) { + Object.entries(filters.days).forEach(([day, timeSlots]) => { + const dayKey = day as Weekday; // safely cast string to enum + + Object.entries(timeSlots as TimeSlot).forEach(([slot, value]) => { + if (value) { + params.append("daySlot", `${dayKey}-${slot}`); + } + }); + }); + } + + return params; +} + +export function deserializeFilters( + query: URLSearchParams, + defaultFilter: CardsFilter, +): CardsFilter { + const filters: CardsFilter = structuredClone(defaultFilter); + + const search = query.get("searchInput"); + if (search !== null) { + filters.searchInput = search; + } + + const accompanying = query.get("accompanying"); + if (accompanying === "true") { + filters.accompanying = true; + } + + const activityTypes = query.getAll("activityType"); + activityTypes.forEach((type) => { + if (hasKey(filters.activityType, type)) { + filters.activityType[type] = true; + } + }); + + const districts = query.getAll("district"); + districts.forEach((dist) => { + if (hasKey(filters.district, dist)) { + filters.district[dist] = true; + } + }); + + const daySlots = query.getAll("daySlot"); + daySlots.forEach((slot) => { + const [day, time] = slot.split("-"); + if ( + filters.days[day as keyof typeof filters.days] && + filters.days[day as keyof typeof filters.days][ + time as keyof typeof filters.days.monday + ] !== undefined + ) { + filters.days[day as keyof typeof filters.days][ + time as keyof typeof filters.days.monday + ] = true; + } + }); + + return filters; +} + +export const getFilterKeysExcludingSearch = () => + FILTER_KEYS.filter((key) => key !== "searchInput"); From 7dcddaff605683a1626d54aa8894272fe5b4bb8c Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Tue, 29 Jul 2025 21:15:19 +0100 Subject: [PATCH 02/13] reverted search UI --- src/components/core/common/Search.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/core/common/Search.tsx b/src/components/core/common/Search.tsx index e47e7e9..13fac0c 100644 --- a/src/components/core/common/Search.tsx +++ b/src/components/core/common/Search.tsx @@ -22,7 +22,6 @@ const SearchContainer = styled.div` const StyledInput = styled.input` font-size: var(--search-input-font-size); border: none; - flex: 1; &:focus { outline: none; } From 635d1827fc6cc15d2e6a363469e4367d1b565efa Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Wed, 30 Jul 2025 19:21:41 +0100 Subject: [PATCH 03/13] optimise filter code --- .../OpportunityCards/Filters/constants.tsx | 20 ++-- .../OpportunityCards/OpportunityCards.tsx | 16 +-- src/components/OpportunityCards/helpers.ts | 100 ++++++++++++++++- src/utils/index.ts | 101 ------------------ 4 files changed, 122 insertions(+), 115 deletions(-) diff --git a/src/components/OpportunityCards/Filters/constants.tsx b/src/components/OpportunityCards/Filters/constants.tsx index 97ccf05..2fece80 100644 --- a/src/components/OpportunityCards/Filters/constants.tsx +++ b/src/components/OpportunityCards/Filters/constants.tsx @@ -45,10 +45,16 @@ export const defaultFilter: CardsFilter = { }, }; -export const FILTER_KEYS = [ - "searchInput", - "activityType", - "district", - "daySlot", - "accompanying", -] as const; +export const FILTER_KEY = { + SEARCH_INPUT: "searchInput", + ACTIVITY_TYPE: "activityType", + DISTRICT: "district", + DAY_SLOT: "daySlot", + ACCOMPANYING: "accompanying", +} as const; + +export const FILTER_KEY_LIST = Object.values(FILTER_KEY); + +export type FilterKey = (typeof FILTER_KEY)[keyof typeof FILTER_KEY]; + +export const DASH = "-"; diff --git a/src/components/OpportunityCards/OpportunityCards.tsx b/src/components/OpportunityCards/OpportunityCards.tsx index 42487af..9796e0c 100644 --- a/src/components/OpportunityCards/OpportunityCards.tsx +++ b/src/components/OpportunityCards/OpportunityCards.tsx @@ -8,10 +8,14 @@ import { urlApiOpportunity } from "../../config/constants"; import OpportunityCardsHeader from "./OpportunityCardsHeader"; import MapView from "./MapView"; import Filters from "./Filters/Filters"; -import { defaultFilter, FILTER_KEYS } from "./Filters/constants"; +import { + defaultFilter, + FILTER_KEY, + FILTER_KEY_LIST, +} from "./Filters/constants"; import { deserializeFilters, - getFilterKeysExcludingSearch, + getFilterKeysExcluding, serializeFilters, } from "../../utils"; import { CardsFilter } from "./types"; @@ -60,7 +64,7 @@ export function OpportunityCards() { ? incomingFilter(cardsFilter) : incomingFilter; - const hasFilterParams = FILTER_KEYS.some((key) => query.has(key)); + const hasFilterParams = FILTER_KEY_LIST.some((key) => query.has(key)); const finalFilter = hasFilterParams ? deserializeFilters(query, baseFilter) @@ -76,9 +80,9 @@ export function OpportunityCards() { useEffect(() => { if (ToggledFilters) return; - const hasRelevantFilters = getFilterKeysExcludingSearch().some((key) => - searchParams.has(key), - ); + const hasRelevantFilters = getFilterKeysExcluding([ + FILTER_KEY.SEARCH_INPUT, + ]).some((key) => searchParams.has(key)); setIsFiltersOpen(hasRelevantFilters); }, [searchParams, ToggledFilters]); diff --git a/src/components/OpportunityCards/helpers.ts b/src/components/OpportunityCards/helpers.ts index bb19d24..c929345 100644 --- a/src/components/OpportunityCards/helpers.ts +++ b/src/components/OpportunityCards/helpers.ts @@ -3,8 +3,9 @@ import { OpportunityType } from "need4deed-sdk"; import { TFunction } from "i18next"; import { Opportunity } from "../VolunteeringOpportunities/types"; import { CardsFilter, Day, DayKeys, Days, DaysKeys } from "./types"; -import { TimeSlot } from "../forms/types"; +import { TimeSlot, Weekday } from "../forms/types"; import { CategoryTitle } from "../VolunteeringOpportunities/utils"; +import { DASH, FILTER_KEY_LIST, FilterKey } from "./Filters/constants"; const dayEnumMap: Record = { 1: "monday", @@ -244,3 +245,100 @@ export const extractCardsFilter = ( }; export const isObjectEmpty = (obj: object) => Object.keys(obj).length === 0; + +export const hasKey = ( + obj: T | null | undefined, + key: PropertyKey, +): key is keyof T => !!obj && Object.prototype.hasOwnProperty.call(obj, key); + +export function serializeFilters(filters: CardsFilter): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.searchInput) { + params.set("searchInput", filters.searchInput); + } + + if (filters.accompanying) { + params.set("accompanying", "true"); + } + + if (filters.activityType) { + Object.entries(filters.activityType).forEach(([key, value]) => { + if (value === true) { + params.append("activityType", key); + } + }); + } + + if (filters.district) { + Object.entries(filters.district).forEach(([key, value]) => { + if (value === true) { + params.append("district", key); + } + }); + } + + if (filters.days) { + Object.entries(filters.days).forEach(([day, timeSlots]) => { + const dayKey = day as Weekday; // safely cast string to enum + + Object.entries(timeSlots as TimeSlot).forEach(([slot, value]) => { + if (value) { + params.append("daySlot", `${dayKey}${DASH}${slot}`); + } + }); + }); + } + + return params; +} + +export function deserializeFilters( + query: URLSearchParams, + defaultFilter: CardsFilter, +): CardsFilter { + const filters: CardsFilter = structuredClone(defaultFilter); + + const search = query.get("searchInput"); + if (search !== null) { + filters.searchInput = search; + } + + const accompanying = query.get("accompanying"); + if (accompanying === "true") { + filters.accompanying = true; + } + + const activityTypes = query.getAll("activityType"); + activityTypes.forEach((type) => { + if (hasKey(filters.activityType, type)) { + filters.activityType[type] = true; + } + }); + + const districts = query.getAll("district"); + districts.forEach((dist) => { + if (hasKey(filters.district, dist)) { + filters.district[dist] = true; + } + }); + + const daySlots = query.getAll("daySlot"); + daySlots.forEach((slot) => { + const [day, time] = slot.split("-"); + const dayKey = day as keyof Days; + const timeKey = time as keyof Day; + + if (filters.days[dayKey] && filters.days[dayKey][timeKey] !== undefined) { + filters.days[dayKey][timeKey] = true; + } + }); + + return filters; +} + +export const getFilterKeysExcluding = ( + exclude: FilterKey[] = [], +): FilterKey[] => { + return FILTER_KEY_LIST.filter((key) => !exclude.includes(key)); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index e4b2edb..f47d68c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,9 +14,6 @@ import { Subpages, YesNo, } from "../config/types"; -import { FILTER_KEYS } from "../components/OpportunityCards/Filters/constants"; -import { CardsFilter } from "../components/OpportunityCards/types"; -import { TimeSlot, Weekday } from "../components/forms/types"; export function pivotArrayToObj(arr: Array>) { const [first] = arr; @@ -50,11 +47,6 @@ export function getBaseUrl(url: string) { return baseUrl ? `/${baseUrl}` : ""; } -export const hasKey = ( - obj: T, - key: PropertyKey, -): key is keyof T => Object.prototype.hasOwnProperty.call(obj, key); - export function setLangDirection( containerRef: MutableRefObject, lng: Lang, @@ -478,96 +470,3 @@ export function setStoredLang(lang: Lang) { console.warn(`Invalid language code: ${lang}`); } } - -export function serializeFilters(filters: CardsFilter): URLSearchParams { - const params = new URLSearchParams(); - - if (filters.searchInput) { - params.set("searchInput", filters.searchInput); - } - - if (filters.accompanying) { - params.set("accompanying", "true"); - } - - if (filters.activityType) { - Object.entries(filters.activityType).forEach(([key, value]) => { - if (value === true) { - params.append("activityType", key); - } - }); - } - - if (filters.district) { - Object.entries(filters.district).forEach(([key, value]) => { - if (value === true) { - params.append("district", key); - } - }); - } - - if (filters.days) { - Object.entries(filters.days).forEach(([day, timeSlots]) => { - const dayKey = day as Weekday; // safely cast string to enum - - Object.entries(timeSlots as TimeSlot).forEach(([slot, value]) => { - if (value) { - params.append("daySlot", `${dayKey}-${slot}`); - } - }); - }); - } - - return params; -} - -export function deserializeFilters( - query: URLSearchParams, - defaultFilter: CardsFilter, -): CardsFilter { - const filters: CardsFilter = structuredClone(defaultFilter); - - const search = query.get("searchInput"); - if (search !== null) { - filters.searchInput = search; - } - - const accompanying = query.get("accompanying"); - if (accompanying === "true") { - filters.accompanying = true; - } - - const activityTypes = query.getAll("activityType"); - activityTypes.forEach((type) => { - if (hasKey(filters.activityType, type)) { - filters.activityType[type] = true; - } - }); - - const districts = query.getAll("district"); - districts.forEach((dist) => { - if (hasKey(filters.district, dist)) { - filters.district[dist] = true; - } - }); - - const daySlots = query.getAll("daySlot"); - daySlots.forEach((slot) => { - const [day, time] = slot.split("-"); - if ( - filters.days[day as keyof typeof filters.days] && - filters.days[day as keyof typeof filters.days][ - time as keyof typeof filters.days.monday - ] !== undefined - ) { - filters.days[day as keyof typeof filters.days][ - time as keyof typeof filters.days.monday - ] = true; - } - }); - - return filters; -} - -export const getFilterKeysExcludingSearch = () => - FILTER_KEYS.filter((key) => key !== "searchInput"); From c138eb80db765b1aea8fe17c2992f4e970b6907b Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Fri, 1 Aug 2025 10:31:06 +0100 Subject: [PATCH 04/13] added url language for filter --- src/components/OpportunityCards/OpportunityCards.tsx | 4 ++-- src/config/i18next.ts | 12 ++++++++++-- src/utils/index.ts | 9 +++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/OpportunityCards/OpportunityCards.tsx b/src/components/OpportunityCards/OpportunityCards.tsx index 9796e0c..02f37e6 100644 --- a/src/components/OpportunityCards/OpportunityCards.tsx +++ b/src/components/OpportunityCards/OpportunityCards.tsx @@ -13,12 +13,12 @@ import { FILTER_KEY, FILTER_KEY_LIST, } from "./Filters/constants"; +import { CardsFilter } from "./types"; import { deserializeFilters, getFilterKeysExcluding, serializeFilters, -} from "../../utils"; -import { CardsFilter } from "./types"; +} from "./helpers"; const OpportunitiesContainer = styled.div` display: flex; diff --git a/src/config/i18next.ts b/src/config/i18next.ts index 4fdf688..d47e342 100644 --- a/src/config/i18next.ts +++ b/src/config/i18next.ts @@ -4,11 +4,19 @@ import { initReactI18next } from "react-i18next"; import legal from "../../public/locales/de/legal.json"; import deTranslation from "../../public/locales/de/translations.json"; import enTranslation from "../../public/locales/en/translations.json"; -import { getStoredLang } from "../utils"; +import { getLangFromUrl, getStoredLang, setStoredLang } from "../utils"; import { Env } from "./types"; +// Step 1: Pick the language +const urlLang = getLangFromUrl(); +const storedLang = getStoredLang(); +const initialLang = urlLang || storedLang || Lang.EN; + +setStoredLang(initialLang); + +// Step 2: Initialize i18n i18next.use(initReactI18next).init({ - lng: getStoredLang() || Lang.EN, + lng: initialLang, fallbackLng: Lang.EN, debug: process.env.NODE_ENV === Env.DEVELOP, resources: { diff --git a/src/utils/index.ts b/src/utils/index.ts index f47d68c..468467e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -470,3 +470,12 @@ export function setStoredLang(lang: Lang) { console.warn(`Invalid language code: ${lang}`); } } + +export function getLangFromUrl(): Lang | null { + const segments = window.location.pathname.split("/").filter(Boolean); // removes empty "" + const langCandidate = segments.find((s) => + Object.values(Lang).includes(s as Lang), + ); + + return langCandidate ? (langCandidate as Lang) : null; +} From 647b327ee71e58ff1bcae6d09cc6bc95d01eb913 Mon Sep 17 00:00:00 2001 From: aricionur Date: Fri, 1 Aug 2025 15:57:50 +0100 Subject: [PATCH 05/13] set and get lang from URL --- .../OpportunityCards/Filters/constants.tsx | 9 +-- .../OpportunityCards/OpportunityCards.tsx | 34 +++--------- src/components/OpportunityCards/helpers.ts | 55 ++++++++++++------- src/components/OpportunityCards/types.ts | 1 + src/config/i18next.ts | 2 - src/utils/index.ts | 8 +-- 6 files changed, 52 insertions(+), 57 deletions(-) diff --git a/src/components/OpportunityCards/Filters/constants.tsx b/src/components/OpportunityCards/Filters/constants.tsx index 2fece80..cfb41c7 100644 --- a/src/components/OpportunityCards/Filters/constants.tsx +++ b/src/components/OpportunityCards/Filters/constants.tsx @@ -1,6 +1,5 @@ -import { CardsFilter } from "../types"; +import { CardFilterKeys, CardsFilter } from "../types"; -/* eslint-disable import/prefer-default-export */ export const defaultFilter: CardsFilter = { searchInput: "", accompanying: false, @@ -49,12 +48,14 @@ export const FILTER_KEY = { SEARCH_INPUT: "searchInput", ACTIVITY_TYPE: "activityType", DISTRICT: "district", - DAY_SLOT: "daySlot", + DAYS: "days", ACCOMPANYING: "accompanying", -} as const; +} as const satisfies Record; export const FILTER_KEY_LIST = Object.values(FILTER_KEY); export type FilterKey = (typeof FILTER_KEY)[keyof typeof FILTER_KEY]; export const DASH = "-"; + +export const langQueryParamKey = "lang"; diff --git a/src/components/OpportunityCards/OpportunityCards.tsx b/src/components/OpportunityCards/OpportunityCards.tsx index 02f37e6..ce1900f 100644 --- a/src/components/OpportunityCards/OpportunityCards.tsx +++ b/src/components/OpportunityCards/OpportunityCards.tsx @@ -2,23 +2,15 @@ import { useLocation, useSearchParams } from "react-router-dom"; import styled from "styled-components"; import { Lang, OpportunityType } from "need4deed-sdk"; import { useTranslation } from "react-i18next"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Cards from "./Cards"; import { urlApiOpportunity } from "../../config/constants"; import OpportunityCardsHeader from "./OpportunityCardsHeader"; import MapView from "./MapView"; import Filters from "./Filters/Filters"; -import { - defaultFilter, - FILTER_KEY, - FILTER_KEY_LIST, -} from "./Filters/constants"; +import { defaultFilter, FILTER_KEY_LIST } from "./Filters/constants"; import { CardsFilter } from "./types"; -import { - deserializeFilters, - getFilterKeysExcluding, - serializeFilters, -} from "./helpers"; +import { deserializeFilters, openFilters, serializeFilters } from "./helpers"; const OpportunitiesContainer = styled.div` display: flex; @@ -36,24 +28,21 @@ export function OpportunityCards() { const [searchParams, setSearchParams] = useSearchParams(); const query = new URLSearchParams(location.search); const { i18n, t } = useTranslation(); + const language = i18n.language as Lang; const [numOfOpportunities, setNumOfOpportunities] = useState(0); const [cardsFilter, setCardsFilter] = useState(defaultFilter); const [selectedTabIndex, setSelectedTabIndex] = useState(0); const [isFiltersOpen, setIsFiltersOpen] = useState(false); - const [ToggledFilters, setToggledFilters] = useState(false); const handleFilterUpdate = ( newFilter: CardsFilter | ((prev: CardsFilter) => CardsFilter), ) => { - setToggledFilters(true); const updatedFilter = typeof newFilter === "function" ? newFilter(cardsFilter) : newFilter; setCardsFilter(updatedFilter); - const queryParams = serializeFilters(updatedFilter); - - setSearchParams(queryParams); + setSearchParams(serializeFilters(updatedFilter, language)); }; const initializeFilter = ( @@ -71,22 +60,13 @@ export function OpportunityCards() { : baseFilter; setCardsFilter(finalFilter); + setIsFiltersOpen(openFilters(searchParams)); }; const onSearchInputChange = (searchInput: string) => { handleFilterUpdate((prev) => ({ ...prev, searchInput })); }; - useEffect(() => { - if (ToggledFilters) return; - - const hasRelevantFilters = getFilterKeysExcluding([ - FILTER_KEY.SEARCH_INPUT, - ]).some((key) => searchParams.has(key)); - - setIsFiltersOpen(hasRelevantFilters); - }, [searchParams, ToggledFilters]); - const tabs = [t("opportunityPage.tabs.tab1"), t("opportunityPage.tabs.tab2")]; return ( @@ -120,7 +100,7 @@ export function OpportunityCards() { ], }, primaryKeys: ["title", "name"], - language: i18n.language as Lang, + language, }} popup setNumOfOpportunities={setNumOfOpportunities} diff --git a/src/components/OpportunityCards/helpers.ts b/src/components/OpportunityCards/helpers.ts index c929345..bc04e4c 100644 --- a/src/components/OpportunityCards/helpers.ts +++ b/src/components/OpportunityCards/helpers.ts @@ -1,11 +1,17 @@ /* eslint-disable no-restricted-syntax */ -import { OpportunityType } from "need4deed-sdk"; +import { Lang, OpportunityType } from "need4deed-sdk"; import { TFunction } from "i18next"; import { Opportunity } from "../VolunteeringOpportunities/types"; import { CardsFilter, Day, DayKeys, Days, DaysKeys } from "./types"; import { TimeSlot, Weekday } from "../forms/types"; import { CategoryTitle } from "../VolunteeringOpportunities/utils"; -import { DASH, FILTER_KEY_LIST, FilterKey } from "./Filters/constants"; +import { + DASH, + FILTER_KEY, + FILTER_KEY_LIST, + FilterKey, + langQueryParamKey, +} from "./Filters/constants"; const dayEnumMap: Record = { 1: "monday", @@ -251,21 +257,21 @@ export const hasKey = ( key: PropertyKey, ): key is keyof T => !!obj && Object.prototype.hasOwnProperty.call(obj, key); -export function serializeFilters(filters: CardsFilter): URLSearchParams { +export function serializeFilters(filters: CardsFilter, language: Lang) { const params = new URLSearchParams(); if (filters.searchInput) { - params.set("searchInput", filters.searchInput); + params.set(FILTER_KEY.SEARCH_INPUT, filters.searchInput); } if (filters.accompanying) { - params.set("accompanying", "true"); + params.set(FILTER_KEY.ACCOMPANYING, "true"); } if (filters.activityType) { Object.entries(filters.activityType).forEach(([key, value]) => { if (value === true) { - params.append("activityType", key); + params.append(FILTER_KEY.ACTIVITY_TYPE, key); } }); } @@ -273,61 +279,64 @@ export function serializeFilters(filters: CardsFilter): URLSearchParams { if (filters.district) { Object.entries(filters.district).forEach(([key, value]) => { if (value === true) { - params.append("district", key); + params.append(FILTER_KEY.DISTRICT, key); } }); } if (filters.days) { Object.entries(filters.days).forEach(([day, timeSlots]) => { - const dayKey = day as Weekday; // safely cast string to enum + const dayKey = day as Weekday; Object.entries(timeSlots as TimeSlot).forEach(([slot, value]) => { if (value) { - params.append("daySlot", `${dayKey}${DASH}${slot}`); + params.append(FILTER_KEY.DAYS, `${dayKey}${DASH}${slot}`); } }); }); } + if (params.size) params.set(langQueryParamKey, language); + else params.delete(langQueryParamKey, language); + return params; } export function deserializeFilters( query: URLSearchParams, - defaultFilter: CardsFilter, + filter: CardsFilter, ): CardsFilter { - const filters: CardsFilter = structuredClone(defaultFilter); + const filters: CardsFilter = structuredClone(filter); - const search = query.get("searchInput"); + const search = query.get(FILTER_KEY.SEARCH_INPUT); if (search !== null) { filters.searchInput = search; } - const accompanying = query.get("accompanying"); + const accompanying = query.get(FILTER_KEY.ACCOMPANYING); if (accompanying === "true") { filters.accompanying = true; } - const activityTypes = query.getAll("activityType"); + const activityTypes = query.getAll(FILTER_KEY.ACTIVITY_TYPE); activityTypes.forEach((type) => { if (hasKey(filters.activityType, type)) { filters.activityType[type] = true; } }); - const districts = query.getAll("district"); + const districts = query.getAll(FILTER_KEY.DISTRICT); districts.forEach((dist) => { if (hasKey(filters.district, dist)) { filters.district[dist] = true; } }); - const daySlots = query.getAll("daySlot"); + const daySlots = query.getAll(FILTER_KEY.DAYS); daySlots.forEach((slot) => { - const [day, time] = slot.split("-"); - const dayKey = day as keyof Days; - const timeKey = time as keyof Day; + const [day, time] = slot.split(DASH); + const dayKey = day as DaysKeys; + const timeKey = time as DayKeys; if (filters.days[dayKey] && filters.days[dayKey][timeKey] !== undefined) { filters.days[dayKey][timeKey] = true; @@ -342,3 +351,11 @@ export const getFilterKeysExcluding = ( ): FilterKey[] => { return FILTER_KEY_LIST.filter((key) => !exclude.includes(key)); }; + +export const openFilters = (searchParams: URLSearchParams) => { + const hasRelevantFilters = getFilterKeysExcluding([ + FILTER_KEY.SEARCH_INPUT, + ]).some((key) => searchParams.has(key)); + + return hasRelevantFilters; +}; diff --git a/src/components/OpportunityCards/types.ts b/src/components/OpportunityCards/types.ts index 9bc40e3..344daa3 100644 --- a/src/components/OpportunityCards/types.ts +++ b/src/components/OpportunityCards/types.ts @@ -26,5 +26,6 @@ export interface Day { export type DaysKeys = keyof Days; export type DayKeys = keyof Day; +export type CardFilterKeys = keyof CardsFilter; export type SetFilter = Dispatch>; diff --git a/src/config/i18next.ts b/src/config/i18next.ts index d47e342..d2ecb8a 100644 --- a/src/config/i18next.ts +++ b/src/config/i18next.ts @@ -7,14 +7,12 @@ import enTranslation from "../../public/locales/en/translations.json"; import { getLangFromUrl, getStoredLang, setStoredLang } from "../utils"; import { Env } from "./types"; -// Step 1: Pick the language const urlLang = getLangFromUrl(); const storedLang = getStoredLang(); const initialLang = urlLang || storedLang || Lang.EN; setStoredLang(initialLang); -// Step 2: Initialize i18n i18next.use(initReactI18next).init({ lng: initialLang, fallbackLng: Lang.EN, diff --git a/src/utils/index.ts b/src/utils/index.ts index 468467e..4265596 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -472,10 +472,8 @@ export function setStoredLang(lang: Lang) { } export function getLangFromUrl(): Lang | null { - const segments = window.location.pathname.split("/").filter(Boolean); // removes empty "" - const langCandidate = segments.find((s) => - Object.values(Lang).includes(s as Lang), - ); + const params = new URLSearchParams(window.location.search); + const lang = params.get("lang") as Lang; - return langCandidate ? (langCandidate as Lang) : null; + return Object.values(Lang).includes(lang) ? lang : null; } From 5a163f512fbc542c720b06b73813025357bd4a61 Mon Sep 17 00:00:00 2001 From: aricionur Date: Sat, 2 Aug 2025 09:54:25 +0100 Subject: [PATCH 06/13] fix bug, keep filters section open when change the language --- src/components/OpportunityCards/OpportunityCards.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OpportunityCards/OpportunityCards.tsx b/src/components/OpportunityCards/OpportunityCards.tsx index ce1900f..1926df1 100644 --- a/src/components/OpportunityCards/OpportunityCards.tsx +++ b/src/components/OpportunityCards/OpportunityCards.tsx @@ -60,7 +60,7 @@ export function OpportunityCards() { : baseFilter; setCardsFilter(finalFilter); - setIsFiltersOpen(openFilters(searchParams)); + setIsFiltersOpen(isFiltersOpen || openFilters(searchParams)); }; const onSearchInputChange = (searchInput: string) => { From ce7770f765c33c78b368dd926f6886dbaa04615a Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Sat, 2 Aug 2025 16:15:13 +0100 Subject: [PATCH 07/13] added podcast icon in footer --- src/components/FooterPartners/footer/ContactSocials.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/FooterPartners/footer/ContactSocials.tsx b/src/components/FooterPartners/footer/ContactSocials.tsx index e008ffe..401dec8 100644 --- a/src/components/FooterPartners/footer/ContactSocials.tsx +++ b/src/components/FooterPartners/footer/ContactSocials.tsx @@ -2,6 +2,7 @@ import { FacebookLogo, InstagramLogo, LinkedinLogo, + ApplePodcastsLogo, } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; @@ -83,6 +84,13 @@ function Socials() { color="var(--color-orchid-light)" /> + + + ); } From 6944472209f53f90b86c8a9501102b0cc828bbf3 Mon Sep 17 00:00:00 2001 From: aricionur Date: Sun, 3 Aug 2025 10:36:24 +0100 Subject: [PATCH 08/13] change query param name and rearrange fn name --- src/components/OpportunityCards/Filters/constants.tsx | 2 +- src/config/i18next.ts | 4 ++-- src/utils/index.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/OpportunityCards/Filters/constants.tsx b/src/components/OpportunityCards/Filters/constants.tsx index cfb41c7..bebb7fc 100644 --- a/src/components/OpportunityCards/Filters/constants.tsx +++ b/src/components/OpportunityCards/Filters/constants.tsx @@ -58,4 +58,4 @@ export type FilterKey = (typeof FILTER_KEY)[keyof typeof FILTER_KEY]; export const DASH = "-"; -export const langQueryParamKey = "lang"; +export const langQueryParamKey = "language"; diff --git a/src/config/i18next.ts b/src/config/i18next.ts index d2ecb8a..81cb5bb 100644 --- a/src/config/i18next.ts +++ b/src/config/i18next.ts @@ -4,10 +4,10 @@ import { initReactI18next } from "react-i18next"; import legal from "../../public/locales/de/legal.json"; import deTranslation from "../../public/locales/de/translations.json"; import enTranslation from "../../public/locales/en/translations.json"; -import { getLangFromUrl, getStoredLang, setStoredLang } from "../utils"; +import { getQueryParamLang, getStoredLang, setStoredLang } from "../utils"; import { Env } from "./types"; -const urlLang = getLangFromUrl(); +const urlLang = getQueryParamLang(); const storedLang = getStoredLang(); const initialLang = urlLang || storedLang || Lang.EN; diff --git a/src/utils/index.ts b/src/utils/index.ts index 4265596..ce4a09b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -471,7 +471,7 @@ export function setStoredLang(lang: Lang) { } } -export function getLangFromUrl(): Lang | null { +export function getQueryParamLang(): Lang | null { const params = new URLSearchParams(window.location.search); const lang = params.get("lang") as Lang; From 15e746b03da96f32694d0b89f0baebf15977508a Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Wed, 6 Aug 2025 12:10:47 +0100 Subject: [PATCH 09/13] update OpportunityCardsHeader.tsx and Search.tsx --- .../OpportunityCardsHeader.tsx | 2 +- src/components/core/common/Search.tsx | 21 +++++-------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/components/OpportunityCards/OpportunityCardsHeader.tsx b/src/components/OpportunityCards/OpportunityCardsHeader.tsx index 777adef..bea559f 100644 --- a/src/components/OpportunityCards/OpportunityCardsHeader.tsx +++ b/src/components/OpportunityCards/OpportunityCardsHeader.tsx @@ -110,7 +110,7 @@ export default function OpportunityCardsHeader({ placeHolder={`${t("opportunityPage.searchPlaceHolder")} ...`} onInputChange={onSearchInputChange} width="var(--opportunities-header-searchbar-width)" - cardsFilter={cardsFilter} + value={cardsFilter?.searchInput ?? ""} /> {isMobile ? ( diff --git a/src/components/core/common/Search.tsx b/src/components/core/common/Search.tsx index 13fac0c..f0c702e 100644 --- a/src/components/core/common/Search.tsx +++ b/src/components/core/common/Search.tsx @@ -1,7 +1,6 @@ import styled from "styled-components"; import { MagnifyingGlassIcon } from "@phosphor-icons/react"; -import { ChangeEvent, useEffect, useState } from "react"; -import { CardsFilter } from "../../OpportunityCards/types"; +import { ChangeEvent } from "react"; interface SearchContainerProps { width?: string; @@ -31,34 +30,24 @@ interface Props { placeHolder?: string; onInputChange: (input: string) => void; width?: string; - cardsFilter?: CardsFilter; + value?: string; } export function Search({ placeHolder = "Search", onInputChange, width, - cardsFilter, + value, }: Props) { - const [inputValue, setInputValue] = useState(""); - - useEffect(() => { - if (cardsFilter) { - setInputValue(cardsFilter.searchInput); - } - }, [cardsFilter]); - const handleInputChange = (e: ChangeEvent) => { - const newValue = e.target.value; - setInputValue(newValue); - onInputChange(newValue); + onInputChange(e.target.value); }; return ( From b56fc3aee7f14d4e4cef215c4e75968fce48fd9b Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Wed, 20 Aug 2025 22:08:07 +0100 Subject: [PATCH 10/13] update the event page options --- src/components/Event/Event.tsx | 122 +++++++++++++++------------------ src/components/Event/index.css | 27 +++++++- src/pages/EventPage.tsx | 7 +- src/pages/Subpage.tsx | 9 +-- 4 files changed, 83 insertions(+), 82 deletions(-) diff --git a/src/components/Event/Event.tsx b/src/components/Event/Event.tsx index d0a2aee..d2d5b4f 100644 --- a/src/components/Event/Event.tsx +++ b/src/components/Event/Event.tsx @@ -1,84 +1,70 @@ -import { EventN4D, Lang } from "need4deed-sdk"; +import { EventN4D } from "need4deed-sdk"; import { useTranslation } from "react-i18next"; -import { getImageUrl, getTimeFrameString } from "../../utils"; +import { formatDateRange, getImageUrl } from "../../utils"; interface Props { - eventData: { event?: EventN4D }; + eventData: EventN4D[]; // array of EventType } - const fallbackPicUrl = "event.webp"; export default function Event({ eventData }: Props) { - const { event } = eventData || { event: undefined }; - const { - i18n: { language }, - t, - } = useTranslation(); + const { t } = useTranslation(); - if (!event) { + if (eventData.length === 0) { return

{t("event.missing")}

; } - return ( -
-

{event.title}

-
{event.subTitle}
-
- {event.hostName} -
- {event.time && ( -
- {event.time} -
- )} + const pastEvent = eventData.filter((event) => !event.active); + // const upcomingEvent = eventData.filter((event) => event.active); // Uncomment if you want to display upcoming events as well - {event.date && ( -
- {getTimeFrameString(language as Lang, event.date, event.dateEnd)} -
- )} + return ( + <> + {pastEvent.map((event: EventN4D) => { + const eventStatus = event.active ? t("event.active") : t("event.past"); + return ( +
+
+
+
+
+

{eventStatus}

+
+

{event.title}

+
{event.subTitle}
- -
{event.address.replace(/\\n/g, "\n")}
-
{event.locationComment}
-

- - {t("event.locationLink")} - -

-
- {t("event.rsvp")}:{" "} - - {t("event.registration")} - -
-
-
-
-
- {event.description.replace(/\\n/g, "\n")} -
-
{event.additionalTitle}
-
    - {event.additionalInfo && - event.additionalInfo.map((info) => ( -
  • -
    {info}
    -
  • - ))} -
-
-
-
+ {event.date && ( +
+
+ {formatDateRange( + new Date(event.date), + event.dateEnd && new Date(event.dateEnd), + )} +
+
+ )} +
+ {event.description.replace(/\\n/g, "\n")} +
+
{event.additionalTitle}
+
    + {event.additionalInfo && + event.additionalInfo.map((info) => ( +
  • +
    {info}
    +
  • + ))} +
+
+
+
+ ); + })} + ); } diff --git a/src/components/Event/index.css b/src/components/Event/index.css index fc4d529..eef2bab 100644 --- a/src/components/Event/index.css +++ b/src/components/Event/index.css @@ -45,6 +45,11 @@ margin-block: 1rem; } +.event-date h6 { + font-size: 20px; + margin: 2rem auto; +} + .event-rsvp a { display: block; width: fit-content; @@ -62,7 +67,7 @@ .pic-and-text { display: grid; grid-template-columns: 1fr 2fr; - gap: 1rem; + gap: 2rem; margin-block: 1rem; width: 100%; } @@ -92,6 +97,26 @@ height: 100%; } +.event-status { + align-items: center; + padding: 0.375rem 1rem; + background: var(--color-white); + width: fit-content; + margin-bottom: 1rem; +} + +.event-status p { + color: var(--color-midnight-bright); + margin: 0; + font-weight: 600; +} + +.event-text h2, +h6, +p { + color: var(--color-midnight) +} + @media (max-width: 576px) { .pic-and-text { grid-template-columns: unset; diff --git a/src/pages/EventPage.tsx b/src/pages/EventPage.tsx index 9a7a426..5c77482 100644 --- a/src/pages/EventPage.tsx +++ b/src/pages/EventPage.tsx @@ -1,5 +1,4 @@ import { Lang } from "need4deed-sdk"; -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import Event from "../components/Event/Event"; @@ -10,13 +9,9 @@ export default function EventPage() { const { i18n } = useTranslation(); const [events] = useEvents(i18n.language as Lang); - const eventActive = useMemo( - () => events?.find((event) => event.active), - [events], - ); return ( - + ); } diff --git a/src/pages/Subpage.tsx b/src/pages/Subpage.tsx index 53bf461..90d9045 100644 --- a/src/pages/Subpage.tsx +++ b/src/pages/Subpage.tsx @@ -1,5 +1,5 @@ import { Lang } from "need4deed-sdk"; -import { useContext, useEffect, useMemo } from "react"; +import { useContext, useEffect } from "react"; import ReactGA from "react-ga4"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; @@ -34,11 +34,6 @@ function Subpage({ type }: Props) { const containerRef = useContext(AppContainerContext); const [events] = useEvents(i18n.language as Lang); - const eventActive = useMemo( - () => events?.find((event) => event.active), - [events], - ); - useEffect(() => { if (isEnumValue(Lang, lng)) { i18n.changeLanguage(lng); @@ -70,7 +65,7 @@ function Subpage({ type }: Props) { case Subpages.ANNOUNCEMENT: return ; case Subpages.EVENT: - return ; + return ; case Subpages.EVENTS: return ; case Subpages.COOKIES: From 73963a8f0a680b2b614a62f555d1f213d7145b18 Mon Sep 17 00:00:00 2001 From: jagdishlove Date: Mon, 25 Aug 2025 10:20:16 +0100 Subject: [PATCH 11/13] fix event component --- src/components/Event/Event.tsx | 82 +++----- .../PastEventCard/PastEventCard.module.css | 136 ++++++++++++ .../Event/PastEventCard/PastEventCard.tsx | 50 +++++ .../UpcomingEventCard.module.css | 197 ++++++++++++++++++ .../UpcomingEventCard/UpcomingEventCard.tsx | 79 +++++++ src/components/Event/index.css | 137 +----------- 6 files changed, 497 insertions(+), 184 deletions(-) create mode 100644 src/components/Event/PastEventCard/PastEventCard.module.css create mode 100644 src/components/Event/PastEventCard/PastEventCard.tsx create mode 100644 src/components/Event/UpcomingEventCard/UpcomingEventCard.module.css create mode 100644 src/components/Event/UpcomingEventCard/UpcomingEventCard.tsx diff --git a/src/components/Event/Event.tsx b/src/components/Event/Event.tsx index d2d5b4f..b5ef004 100644 --- a/src/components/Event/Event.tsx +++ b/src/components/Event/Event.tsx @@ -1,70 +1,48 @@ import { EventN4D } from "need4deed-sdk"; import { useTranslation } from "react-i18next"; - -import { formatDateRange, getImageUrl } from "../../utils"; +import PastEventCard from "./PastEventCard/PastEventCard"; +import UpcomingEventCard from "./UpcomingEventCard/UpcomingEventCard"; interface Props { - eventData: EventN4D[]; // array of EventType + eventData: EventN4D[]; } const fallbackPicUrl = "event.webp"; export default function Event({ eventData }: Props) { const { t } = useTranslation(); - if (eventData.length === 0) { + if (!eventData || eventData.length === 0) { return

{t("event.missing")}

; } - const pastEvent = eventData.filter((event) => !event.active); - // const upcomingEvent = eventData.filter((event) => event.active); // Uncomment if you want to display upcoming events as well + const upcomingEvents = eventData.filter((event) => event.active); + const pastEvents = eventData.filter((event) => !event.active); return ( - <> - {pastEvent.map((event: EventN4D) => { - const eventStatus = event.active ? t("event.active") : t("event.past"); - return ( -
-
-
-
-
-

{eventStatus}

-
-

{event.title}

-
{event.subTitle}
+
+ {upcomingEvents.length > 0 && ( +
+ {upcomingEvents.map((event) => ( + + ))} +
+ )} - {event.date && ( -
-
- {formatDateRange( - new Date(event.date), - event.dateEnd && new Date(event.dateEnd), - )} -
-
- )} -
- {event.description.replace(/\\n/g, "\n")} -
-
{event.additionalTitle}
-
    - {event.additionalInfo && - event.additionalInfo.map((info) => ( -
  • -
    {info}
    -
  • - ))} -
-
-
-
- ); - })} - + {pastEvents.length > 0 && ( +
+ {pastEvents.map((event) => ( + + ))} +
+ )} +
); } diff --git a/src/components/Event/PastEventCard/PastEventCard.module.css b/src/components/Event/PastEventCard/PastEventCard.module.css new file mode 100644 index 0000000..d532cc3 --- /dev/null +++ b/src/components/Event/PastEventCard/PastEventCard.module.css @@ -0,0 +1,136 @@ +.pastEventCard { + margin-bottom: 4rem; +} + + +.eventContent { + display: flex; + gap: 2rem; + margin-block: 1rem; + width: 100%; + flex-wrap: wrap; +} + +.eventContent h1 { + margin-block: 1rem 0; +} + + + +.eventContent strong { + font-weight: 700; +} + +.eventImage { + width: 328px; + height: 328px; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + flex-shrink: 0; +} + + +.eventStatus { + align-items: center; + font-size: 14px; + padding: 0.375rem 1rem; + background: var(--color-white); + width: fit-content; + margin-bottom: 1rem; +} + +.eventStatus p { + color: var(--color-midnight-bright); + margin: 0; + font-weight: 600; +} + +.eventTitle { + font-size: 32px; + font-weight: 700; + color: var(--color-midnight); +} + +.eventSubtitle { + font-size: 14px; + font-weight: 550; + color: var(--color-midnight); +} + +.eventDate h6 { + color: var(--color-midnight); + font-size: 20px; + font-weight: 600; + margin: 2rem auto; +} + + +.eventDescription { + font-size: 20px; + font-weight: 400; + white-space: pre-line; + margin-top: 1rem; + color: var(--color-midnight); +} + +.additionalTitle { + margin-top: 1rem; + font-weight: bold; + color: var(--color-midnight); +} + + +.additionalInfoList { + margin-top: 1rem; + padding-left: 20px; +} + +.additionalInfoItem { + margin-bottom: 0.5rem; + color: var(--color-midnight); +} + + + +@media (max-width: 1024px) { + .eventDetails { + width: 350px + } +} + +/* Responsive styles */ +@media (max-width: 576px) { + .eventContent { + grid-template-columns: unset; + gap: 1rem; + } + + + + .eventImage { + height: 200px; + width: 200px; + } + + .eventImage { + width: 328px; + height: 328px; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + flex-shrink: 0; + } + + .eventDescription { + font-size: 16px; + font-weight: 400; + white-space: pre-line; + margin-top: 1rem; + color: var(--color-midnight); + } + + +} \ No newline at end of file diff --git a/src/components/Event/PastEventCard/PastEventCard.tsx b/src/components/Event/PastEventCard/PastEventCard.tsx new file mode 100644 index 0000000..9040d9a --- /dev/null +++ b/src/components/Event/PastEventCard/PastEventCard.tsx @@ -0,0 +1,50 @@ +import { EventN4D } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; +import { formatDateRange, getImageUrl } from "../../../utils"; +import styles from "./PastEventCard.module.css"; + +interface PastEventCardProps { + event: EventN4D; + fallbackPicUrl: string; +} + +function PastEventCard({ event, fallbackPicUrl }: PastEventCardProps) { + const { t } = useTranslation(); + const eventStatus = event.active + ? t("homepage.events.headLine") + : t("event.past"); + + return ( +
+
+
+
+
+

{eventStatus}

+
+

{event.title}

+ + {event.date && ( +
+
+ {formatDateRange( + new Date(event.date), + event.dateEnd && new Date(event.dateEnd), + )} +
+
+ )} +
{event.shortDescription}
+
+
+
+ ); +} + +export default PastEventCard; diff --git a/src/components/Event/UpcomingEventCard/UpcomingEventCard.module.css b/src/components/Event/UpcomingEventCard/UpcomingEventCard.module.css new file mode 100644 index 0000000..1522681 --- /dev/null +++ b/src/components/Event/UpcomingEventCard/UpcomingEventCard.module.css @@ -0,0 +1,197 @@ +.eventContainer { + background: var(--layout-static-page-background-default); + color: var(--color-midnight); + + +} + +/* Ensure all child elements inherit the default text color */ +.eventContainer h1, +.eventContainer h2, +.eventContainer h3, +.eventContainer h4, +.eventContainer h5, +.eventContainer h6, +.eventContainer p, +.eventContainer li { + color: inherit; +} + +.eventTitle { + font-weight: 700; + font-size: 32px; + margin-bottom: 1rem; +} + + +.eventSubtitle { + font-weight: 600; + letter-spacing: 0.5%; + font-size: 20px; + text-transform: uppercase; + margin-bottom: 1rem; +} + +.eventDate h6 { + font-weight: 600; + font-size: 20px; + margin-bottom: 1rem; +} + +.eventAddress { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.eventAddress h6, +.eventAddress span { + font-weight: 500; + font-size: 20px; +} + +.cityZip { + background-color: var(--color-white); + padding: 6px 8px; + border-radius: 4px; +} + +.locationComment { + font-weight: 400; + font-size: 20px; + margin-bottom: 1rem; +} + + +.eventImage { + width: 100%; + max-width: 880px; + height: auto; + aspect-ratio: 16 / 9; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + flex-shrink: 0; + margin: 2rem 0; +} + +.eventDescription h6 { + white-space: pre-wrap; + font-weight: 600; + font-size: 20px; + margin-bottom: 1rem; +} + +.additionalInfoList { + font-size: 20px; + font-weight: 400; + margin-top: 1rem; +} + +.eventStatus { + align-items: center; + padding: 0.375rem 1rem; + background: var(--color-white); + width: fit-content; + margin-bottom: 1rem; + border-radius: 4px; +} + +.eventStatus p { + color: var(--n4d-secondary-darker); + margin: 0; + font-weight: 600; + font-size: 14px; +} + +.eventActions { + margin: 3rem 0; + display: flex; +} + +/* Responsive styles for tablets and mobile devices */ +@media (max-width: 1024px) { + .eventContainer { + padding: 1.5rem; + } + + .eventTitle { + font-size: 28px; + } + + .eventSubtitle { + font-size: 18px; + } + + .eventDate h6 { + font-size: 18px; + } + + .eventAddress h6, + .eventAddress span { + font-size: 18px; + } + + .eventDescription h6 { + font-size: 18px; + } + + .additionalInfoList { + font-size: 18px; + } + + .eventImage { + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + } +} + +@media (max-width: 576px) { + .eventContainer { + padding: 1rem; + } + + .eventTitle { + font-size: 24px; + } + + .eventSubtitle { + font-size: 16px; + } + + .eventDate h6 { + font-size: 16px; + } + + .eventAddress { + flex-direction: column; + align-items: flex-start; + } + + .eventAddress h6, + .eventAddress span { + font-size: 16px; + } + + .eventDescription h6 { + font-size: 16px; + } + + .additionalInfoList { + font-size: 16px; + } + + .eventImage { + width: 100%; + height: auto; + aspect-ratio: 4 / 3; + } + + .eventActions { + flex-direction: column; + gap: 1rem; + } +} \ No newline at end of file diff --git a/src/components/Event/UpcomingEventCard/UpcomingEventCard.tsx b/src/components/Event/UpcomingEventCard/UpcomingEventCard.tsx new file mode 100644 index 0000000..560311f --- /dev/null +++ b/src/components/Event/UpcomingEventCard/UpcomingEventCard.tsx @@ -0,0 +1,79 @@ +import { EventN4D } from "need4deed-sdk"; +import { useTranslation } from "react-i18next"; +import { formatDateRange, getImageUrl } from "../../../utils"; +import styles from "./UpcomingEventCard.module.css"; +import { Button } from "../../core/button"; + +interface UpcomingEventCardProps { + event: EventN4D; + fallbackPicUrl: string; +} + +function UpcomingEventCard({ event, fallbackPicUrl }: UpcomingEventCardProps) { + const { t } = useTranslation(); + + const eventStatus = event.active + ? t("homepage.events.headLine") + : t("event.past"); + + const [street, cityZip, floor] = event.address.split("\\n"); + const formattedAddress = `${street}, ${floor}`; + + return ( +
+
+
+

{eventStatus}

+
+

{event.title}

+
{event.subTitle}
+ + {event.date && ( +
+
+ {formatDateRange( + new Date(event.date), + event.dateEnd && new Date(event.dateEnd), + )} +
+
+ )} + +
+
{formattedAddress}
+
{cityZip}
+
+ +
{event.locationComment}
+
+
+
{event.description.replace(/\\n/g, "\n")}
+
+
{event.additionalTitle}
+
    + {event.additionalInfo && + event.additionalInfo.map((info) => ( +
  • +
    {info}
    +
  • + ))} +
+
+
+
+
+ ); +} + +export default UpcomingEventCard; diff --git a/src/components/Event/index.css b/src/components/Event/index.css index eef2bab..2235d9d 100644 --- a/src/components/Event/index.css +++ b/src/components/Event/index.css @@ -1,136 +1,9 @@ -.event-container h1 { - margin-block: 1rem 0; -} - -.event-container h6 { - font-weight: 550; -} - -.event-container strong { - font-weight: 700; -} - -.event-timeslot { - font-weight: bold; -} - -.event-location { - margin-block-start: 2rem; -} - -.event-list-container { - margin-block-start: 2rem; -} - -.bullet-list { - margin-block-start: 1rem; -} - -.bullet-list li { - margin-block-end: 0.2rem; -} - -.event-rsvp { +.n4d-section { display: flex; flex-direction: column; - align-items: center; - margin-block-start: 2rem; -} - -.event-rsvp p:first-of-type { - margin-block: 0; -} - -.event-rsvp p:nth-of-type(2) { - margin-block: 1rem; -} - -.event-date h6 { - font-size: 20px; - margin: 2rem auto; + gap: 5rem } -.event-rsvp a { - display: block; - width: fit-content; - text-decoration: none; - color: var(--n4d-neutral-900); - background: var(--n4d-primary); - padding: 0.3rem; - border-radius: 0.3rem; -} - -.event-rsvp a:hover { - background: var(--n4d-primary-darker); -} - -.pic-and-text { - display: grid; - grid-template-columns: 1fr 2fr; - gap: 2rem; - margin-block: 1rem; - width: 100%; -} - -.event-donate { -} - -.event-donate a { - color: var(--n4d-primary); -} - -.event-lines-together { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.event-lines-together * { - margin-block: 0; - padding-block: 0; -} - -.event-pic { - background-size: cover; - background-repeat: no-repeat; - width: 100%; - height: 100%; -} - -.event-status { - align-items: center; - padding: 0.375rem 1rem; - background: var(--color-white); - width: fit-content; - margin-bottom: 1rem; -} - -.event-status p { - color: var(--color-midnight-bright); - margin: 0; - font-weight: 600; -} - -.event-text h2, -h6, -p { - color: var(--color-midnight) -} - -@media (max-width: 576px) { - .pic-and-text { - grid-template-columns: unset; - } - .event-pic { - height: 12rem; - } - .sommer-fest-pic { - width: 100%; - height: 15rem; - } -} - -.volunteerEmail { - color: var(--n4d-tertiary); - text-decoration: underline; -} +.upcoming-events { + background: var(--layout-static-page-background-default); +} \ No newline at end of file From d8902d972d75d191901578fb77e07586c7cc7280 Mon Sep 17 00:00:00 2001 From: arturasmckwcz Date: Fri, 26 Sep 2025 15:30:54 +0000 Subject: [PATCH 12/13] fix: typings --- package.json | 2 +- src/components/forms/utils.ts | 4 ++-- yarn.lock | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index fecc544..51035dd 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "bootstrap": "^5.3.3", "email-validator": "^2.0.4", "i18next": "^23.10.1", - "need4deed-sdk": "", + "need4deed-sdk": "*", "react": "^18.2.0", "react-bootstrap": "^2.10.1", "react-cookie-consent": "^9.0.0", diff --git a/src/components/forms/utils.ts b/src/components/forms/utils.ts index 1df88c1..4ee7811 100644 --- a/src/components/forms/utils.ts +++ b/src/components/forms/utils.ts @@ -45,7 +45,7 @@ export function getSelectedIds(state: Selected[]): OptionId[] { } export function parseFormStateDTOVolunteer(value: VolunteerData) { - const data = {} as VolunteerParsedData; + const data: Record = {}; data.origin_opportunity = value.opportunityId ? +value.opportunityId : undefined; @@ -66,7 +66,7 @@ export function parseFormStateDTOVolunteer(value: VolunteerData) { data.comments = value.comments; data.language = value.language; - return data; + return data as unknown as VolunteerParsedData; } export function parseFormStateDTOOpportunity(value: OpportunityData) { diff --git a/yarn.lock b/yarn.lock index 43be9d4..9524124 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10947,10 +10947,10 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -need4deed-sdk@: - version "0.0.12" - resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.12.tgz#8d66a411f4ef6ce3ed5cb867457e51faf63df74c" - integrity sha512-SxTPpBMnbOD8csadN1UV/li7lXKPa9psUB0HexmC1o6PDIowMcp9ky+Ylw1dHAlQVJCYm7JdpNYq/YMobguMQA== +need4deed-sdk@*: + version "0.0.14" + resolved "https://registry.yarnpkg.com/need4deed-sdk/-/need4deed-sdk-0.0.14.tgz#4102b41266689760f0294c4b6c64f550e7c02583" + integrity sha512-E7fl2uCp6ScsUNDDkhVc9iPgB9IytE6fl2+N6oeZdLNF0lult8qr39s1yGJO7+OoOS14HQLQ/JZdy+7bvjKDtg== neo-async@^2.6.0: version "2.6.2" From ffdb597c1eb106215922d9a18282c73dc9bb29ea Mon Sep 17 00:00:00 2001 From: arturasmckwcz Date: Sat, 27 Sep 2025 11:50:34 +0000 Subject: [PATCH 13/13] fix: update podcast link --- src/components/FooterPartners/footer/ContactSocials.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FooterPartners/footer/ContactSocials.tsx b/src/components/FooterPartners/footer/ContactSocials.tsx index 401dec8..fcae0be 100644 --- a/src/components/FooterPartners/footer/ContactSocials.tsx +++ b/src/components/FooterPartners/footer/ContactSocials.tsx @@ -84,7 +84,7 @@ function Socials() { color="var(--color-orchid-light)" /> - +