diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..5bf0d47d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lint & Typecheck + +on: + pull_request: + branches: + - develop + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + + - name: restore yarn cache + id: yarn-cache + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn- + + - name: install dependencies + if: steps.yarn-cache.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - run: yarn lint + - run: yarn typecheck diff --git a/.gitignore b/.gitignore index df571b81..fb251c41 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts # dev dev/ dev.* +.env.local diff --git a/next.config.ts b/next.config.ts index c80f1e95..1a72891e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,11 +4,22 @@ import type { NextConfig } from "next"; const apiURL = process.env.API_URL || "http://localhost:5000"; const CLOUDFRONT_HOSTNAME = "d2nwrdddg8skub.cloudfront.net"; - const nextConfig: NextConfig = { + typescript: { ignoreBuildErrors: true }, + eslint: { ignoreDuringBuilds: true }, compiler: { styledComponents: true, - }, + }, + async redirects() { + const eventPageDestination = + "https://docs.google.com/forms/d/e/1FAIpQLSft1xi4NrQB_O6-OyOvVm_HcDSzQtog_3MMj2XAIVNaLKEJxA/viewform?usp=dialog"; + return [ + { source: "/event-page", destination: eventPageDestination, permanent: false }, + { source: "/event-page/", destination: eventPageDestination, permanent: false }, + { source: "/:lang/event-page", destination: eventPageDestination, permanent: false }, + { source: "/:lang/event-page/", destination: eventPageDestination, permanent: false }, + ]; + }, async rewrites() { return [{ source: `/${apiPrefix}/:path*`, destination: `${apiURL}/:path*` }]; }, diff --git a/package.json b/package.json index e9d4879f..3ab76ba2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "date-fns": "^4.1.0", "email-validator": "^2.0.4", "i18next": "^25.3.2", - "need4deed-sdk": "^0.0.73", + "need4deed-sdk": "^0.0.77", "next": "15.3.8", "react": "^19.0.0", "react-day-picker": "^9.13.0", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..3b69795a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/locales/de/translations.json b/public/locales/de/translations.json index e51072a8..6f5e276c 100644 --- a/public/locales/de/translations.json +++ b/public/locales/de/translations.json @@ -252,6 +252,8 @@ "header": "Art des Bedarfs", "volunteering": "Freizeit", "accompanying": "Begleitung", + "regular": "Regelmäßig", + "events": "Veranstaltung", "helpText": "Es gibt zwei Arten von Unterstützung: Freiwilliges Engagement bei verschiedenen Aktivitäten (meist Freizeitaktivitäten) und Begleitung zu Terminen.", "helpTitle": "" }, @@ -346,6 +348,7 @@ "accompanyingDesc": "Nur Freiwillige anzeigen, die bereit sind, Bewohner zu einzelnen Terminen zu begleiten", "district": "Bezirk", "languages": "Sprachen", + "activities": "Aktivitäten", "engagement": { "header": "Engagement", "active": "Aktiv", @@ -423,7 +426,8 @@ "header": { "button": { "login": "Anmelden", - "joinVolunteer": "Jetzt sich engagieren" + "joinVolunteer": "Jetzt sich engagieren", + "dashboard": "Dashboard" } }, "login": { @@ -1021,6 +1025,7 @@ "accompanyingDetailsTitle": "Begleitdetails", "accompanyingDetails": { "appointmentAddress": "Terminadresse", + "appointmentPostcode": "Terminpostleitzahl", "appointmentDate": "Termindatum", "appointmentTime": "Terminzeit", "refugeeNumber": "Geflüchtetennummer", @@ -1064,6 +1069,8 @@ "mainCommunication": "Hauptkommunikation", "residentsSpeak": "Bewohner sprechen", "schedule": "Zeitplan", + "eventDate": "Veranstaltungsdatum", + "eventTime": "Veranstaltungszeit", "numberOfVolunteers": "Anzahl der Freiwilligen", "activities": "Aktivitäten", "skills": "Fähigkeiten & Erfahrung", diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index fab9f6fd..cd43ae8c 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -252,6 +252,8 @@ "header": "Type Of Opportunity", "volunteering": "Volunteering Opportunity", "accompanying": "Accompanying Appointment", + "regular": "Regular", + "events": "Events", "helpText": "There are two ways of helping: Volunteering doing various activities and Accompanying to appointments essentially providing translation", "helpTitle": "" }, @@ -346,6 +348,7 @@ "accompanyingDesc": "Only show volunteers that are open to bring residents to individual appointments", "district": "District", "languages": "Languages", + "activities": "Activities", "engagement": { "header": "Engagement", "active": "Active", @@ -423,7 +426,8 @@ "header": { "button": { "login": "Log in", - "joinVolunteer": "Join as a volunteer" + "joinVolunteer": "Join as a volunteer", + "dashboard": "Dashboard" } }, "login": { @@ -1020,6 +1024,7 @@ "accompanyingDetailsTitle": "Accompanying details", "accompanyingDetails": { "appointmentAddress": "Appointment address", + "appointmentPostcode": "Appointment postcode", "appointmentDate": "Appointment date", "appointmentTime": "Appointment time", "refugeeNumber": "Refugee number", @@ -1063,6 +1068,8 @@ "mainCommunication": "Main communication", "residentsSpeak": "Residents speak", "schedule": "Schedule", + "eventDate": "Event date", + "eventTime": "Event time", "numberOfVolunteers": "Number of volunteers", "activities": "Activities", "skills": "Skills & experience", diff --git a/src/app/[lang]/dashboard/home/page.tsx b/src/app/[lang]/dashboard/home/page.tsx deleted file mode 100644 index 929f4e3e..00000000 --- a/src/app/[lang]/dashboard/home/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DashboardHome } from "@/components/Dashboard"; - -export default function DashboardHomePage() { - return ; -} diff --git a/src/app/[lang]/dashboard/page.tsx b/src/app/[lang]/dashboard/page.tsx index 7bce77a0..984d2183 100644 --- a/src/app/[lang]/dashboard/page.tsx +++ b/src/app/[lang]/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { Landing } from "@/components/Dashboard/Landing"; +import { DashboardHome } from "@/components/Dashboard"; -export default function DashboardLandingPage() { - return ; +export default function DashboardPage() { + return ; } diff --git a/src/app/[lang]/forms/volunteer/page.tsx b/src/app/[lang]/forms/volunteer/page.tsx index 60b345c5..6429b9b1 100644 --- a/src/app/[lang]/forms/volunteer/page.tsx +++ b/src/app/[lang]/forms/volunteer/page.tsx @@ -1,11 +1,5 @@ -"use client"; import BecomeVolunteer from "@/components/forms/BecomeVolunteer/BecomeVolunteer"; -import { PageLayout } from "@/components/Layout"; export default function VolunteerPage() { - return ( - - ; - - ); + return ; } diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 6e92fb0d..302f4f47 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -1,36 +1,9 @@ -import { TestimonialsSection } from "@/components/Testimonials"; +import { Landing } from "@/components/Website/Landing"; import { Lang } from "need4deed-sdk"; -import Link from "next/link"; -import styles from "./page.module.css"; +import { use } from "react"; -export default async function Home({ params }: { params: Promise<{ lang: Lang }> }) { - const lang = (await params).lang; +export default function Home({ params }: { params: Promise<{ lang: Lang }> }) { + const { lang } = use(params); - if (![Lang.EN, Lang.DE].includes(lang)) { - return null; - } - - return ( -
-
HEADER
-
-
- - Login - - - Persons - - - Volunteer form - - - Opportunity form - -
-
- -
FOOTER
-
- ); + return ; } diff --git a/src/app/[lang]/tmp-home/page.module.css b/src/app/[lang]/tmp-home/page.module.css new file mode 100644 index 00000000..a11c8f31 --- /dev/null +++ b/src/app/[lang]/tmp-home/page.module.css @@ -0,0 +1,168 @@ +.page { + --gray-rgb: 0, 0, 0; + --gray-alpha-200: rgba(var(--gray-rgb), 0.08); + --gray-alpha-100: rgba(var(--gray-rgb), 0.05); + + --button-primary-hover: #383838; + --button-secondary-hover: #f2f2f2; + + display: grid; + grid-template-rows: 20px 1fr 20px; + align-items: center; + justify-items: center; + min-height: 100svh; + padding: 80px; + gap: 64px; + font-family: var(--font-geist-sans); +} + +@media (prefers-color-scheme: dark) { + .page { + --gray-rgb: 255, 255, 255; + --gray-alpha-200: rgba(var(--gray-rgb), 0.145); + --gray-alpha-100: rgba(var(--gray-rgb), 0.06); + + --button-primary-hover: #ccc; + --button-secondary-hover: #1a1a1a; + } +} + +.main { + display: flex; + flex-direction: column; + gap: 32px; + grid-row-start: 2; +} + +.main ol { + font-family: var(--font-geist-mono); + padding-left: 0; + margin: 0; + font-size: 14px; + line-height: 24px; + letter-spacing: -0.01em; + list-style-position: inside; +} + +.main li:not(:last-of-type) { + margin-bottom: 8px; +} + +.main code { + font-family: inherit; + background: var(--gray-alpha-100); + padding: 2px 4px; + border-radius: 4px; + font-weight: 600; +} + +.ctas { + display: flex; + gap: 16px; +} + +.ctas a { + appearance: none; + border-radius: 128px; + height: 48px; + padding: 0 20px; + border: none; + border: 1px solid transparent; + transition: + background 0.2s, + color 0.2s, + border-color 0.2s; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + line-height: 20px; + font-weight: 500; +} + +a.primary { + background: var(--foreground); + color: var(--background); + gap: 8px; +} + +a.secondary { + border-color: var(--gray-alpha-200); + min-width: 158px; +} + +.footer { + grid-row-start: 3; + display: flex; + gap: 24px; +} + +.footer a { + display: flex; + align-items: center; + gap: 8px; +} + +.footer img { + flex-shrink: 0; +} + +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + a.primary:hover { + background: var(--button-primary-hover); + border-color: transparent; + } + + a.secondary:hover { + background: var(--button-secondary-hover); + border-color: transparent; + } + + .footer a:hover { + text-decoration: underline; + text-underline-offset: 4px; + } +} + +@media (max-width: 600px) { + .page { + padding: 32px; + padding-bottom: 80px; + } + + .main { + align-items: center; + } + + .main ol { + text-align: center; + } + + .ctas { + flex-direction: column; + } + + .ctas a { + font-size: 14px; + height: 40px; + padding: 0 16px; + } + + a.secondary { + min-width: auto; + } + + .footer { + flex-wrap: wrap; + align-items: center; + justify-content: center; + } +} + +@media (prefers-color-scheme: dark) { + .logo { + filter: invert(); + } +} diff --git a/src/app/[lang]/tmp-home/page.tsx b/src/app/[lang]/tmp-home/page.tsx new file mode 100644 index 00000000..efc6f57e --- /dev/null +++ b/src/app/[lang]/tmp-home/page.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import styles from "./page.module.css"; + +export default async function Home() { + return ( +
+
HEADER
+
+
+ + Login + + + Persons + + + Volunteer form + + + Opportunity form + +
+
+
FOOTER
+
+ ); +} diff --git a/src/components/Dashboard/Agents/AgentCard.tsx b/src/components/Dashboard/Agents/AgentCard.tsx index dea3b56b..7556a251 100644 --- a/src/components/Dashboard/Agents/AgentCard.tsx +++ b/src/components/Dashboard/Agents/AgentCard.tsx @@ -58,7 +58,7 @@ export const AgentCard = ({ agent }: Props) => { {t("dashboard.agentProfile.volunteerSearch")} - + e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} role="presentation"> {t("dashboard.agentProfile.trustLevel")} diff --git a/src/components/Dashboard/Agents/helpers.ts b/src/components/Dashboard/Agents/helpers.ts index 492cca26..6463beb1 100644 --- a/src/components/Dashboard/Agents/helpers.ts +++ b/src/components/Dashboard/Agents/helpers.ts @@ -29,7 +29,7 @@ export function getNormalizedAgent(agent: AgentListItem): Omit< ...agent, type: agent.type, district: agent.district, - volunteerSearch: agent.volunteerSearch, + volunteerSearch: agent.volunteerSearch ?? AgentVolunteerSearchType.NOT_NEEDED, trustLevel: agent.trustLevel ? agent.trustLevel : AgentTrustType.UNKNOWN, serviceType: agent.serviceType, }; diff --git a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx index a6483a90..798c3ae4 100644 --- a/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx +++ b/src/components/Dashboard/Opportunities/Filters/FiltersContent.tsx @@ -13,18 +13,15 @@ type Props = { export default function FiltersContent({ setFilter, filter }: Props) { const { t } = useTranslation(); - const { districtFilters, languageFilters, statusFilters, typeFilters } = createOpportunityFilterItems( - filter, - setFilter, - t, - ); - + const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters } = + createOpportunityFilterItems(filter, setFilter, t); return ( + ); } diff --git a/src/components/Dashboard/Opportunities/Filters/constants.ts b/src/components/Dashboard/Opportunities/Filters/constants.ts index ef49499a..4dae3c67 100644 --- a/src/components/Dashboard/Opportunities/Filters/constants.ts +++ b/src/components/Dashboard/Opportunities/Filters/constants.ts @@ -1,4 +1,4 @@ -import { OpportunityStatusType, OpportunityType, QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, OpportunityStatusType, OpportunityType, QueryParamsKeys } from "need4deed-sdk"; import { OpportunityCardsFilter } from "./types"; export const defaultOpportunityCardsFilter: OpportunityCardsFilter = { @@ -16,4 +16,5 @@ export const defaultOpportunityCardsFilter: OpportunityCardsFilter = { [OpportunityType.EVENTS]: false, [OpportunityType.REGULAR]: false, }, + [EntityTableName.ACTIVITY]: {}, }; diff --git a/src/components/Dashboard/Opportunities/Filters/helpers.ts b/src/components/Dashboard/Opportunities/Filters/helpers.ts index 9009cd13..48536219 100644 --- a/src/components/Dashboard/Opportunities/Filters/helpers.ts +++ b/src/components/Dashboard/Opportunities/Filters/helpers.ts @@ -1,7 +1,7 @@ import { TFunction } from "i18next"; import { generateNestedFilterControlItems } from "../../common/CardsFilter/helpers"; import { SetFilter } from "../../common/CardsFilter/types"; -import { QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, QueryParamsKeys } from "need4deed-sdk"; import { OpportunityCardsFilter } from "./types"; export const createOpportunityFilterItems = ( @@ -31,7 +31,14 @@ export const createOpportunityFilterItems = ( t(`dashboard.opportunities.filters.type.${key}`), ); - return { districtFilters, languageFilters, statusFilters, typeFilters }; + const activityFilters = generateNestedFilterControlItems( + filter[EntityTableName.ACTIVITY], + setFilter, + EntityTableName.ACTIVITY, + (key) => key, + ); + + return { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters }; }; export const createSelectedOpportunityFiltersAsFlatArray = ( @@ -39,10 +46,9 @@ export const createSelectedOpportunityFiltersAsFlatArray = ( setFilter: SetFilter, t: TFunction, ) => { - const { districtFilters, languageFilters, statusFilters, typeFilters } = createOpportunityFilterItems( - filter, - setFilter, - t, + const { districtFilters, languageFilters, statusFilters, typeFilters, activityFilters } = + createOpportunityFilterItems(filter, setFilter, t); + return [...districtFilters, ...languageFilters, ...statusFilters, ...typeFilters, ...activityFilters].filter( + (f) => f.checked, ); - return [...districtFilters, ...languageFilters, ...statusFilters, ...typeFilters].filter((f) => f.checked); }; diff --git a/src/components/Dashboard/Opportunities/Filters/types.ts b/src/components/Dashboard/Opportunities/Filters/types.ts index f8229393..0e9b9bd6 100644 --- a/src/components/Dashboard/Opportunities/Filters/types.ts +++ b/src/components/Dashboard/Opportunities/Filters/types.ts @@ -1,4 +1,4 @@ -import { QueryParamsKeys } from "need4deed-sdk"; +import { EntityTableName, QueryParamsKeys } from "need4deed-sdk"; import { SelectionMap } from "../../common/CardsFilter/types"; export interface OpportunityCardsFilter { @@ -7,6 +7,7 @@ export interface OpportunityCardsFilter { [QueryParamsKeys.LANGUAGE]: SelectionMap; status: SelectionMap; type: SelectionMap; + [EntityTableName.ACTIVITY]: SelectionMap; } export type OpportunityCardFilterKeys = keyof OpportunityCardsFilter; diff --git a/src/components/Dashboard/Opportunities/Opportunities.tsx b/src/components/Dashboard/Opportunities/Opportunities.tsx index e593e3d2..85f7fe57 100644 --- a/src/components/Dashboard/Opportunities/Opportunities.tsx +++ b/src/components/Dashboard/Opportunities/Opportunities.tsx @@ -72,7 +72,8 @@ export function Opportunities() { setCardsFilter((prev) => { const district = createFilterFromOption(apiFilterOptions, EntityTableName.DISTRICT); const language = createFilterFromOption(apiFilterOptions, EntityTableName.LANGUAGE); - return { ...prev, district, language }; + const activity = createFilterFromOption(apiFilterOptions, EntityTableName.ACTIVITY); + return { ...prev, district, language, activity }; }); }, [apiFilterOptions]); diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx index 9423549d..4434ca49 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.helpers.tsx @@ -9,6 +9,14 @@ export function formatAvailability(availability: ApiVolunteerOpportunityGetList[ return parts.join(", "); } +export function formatAccompanyingDate(details?: { + appointmentDate?: string; + appointmentTime?: string; +}): string | null { + if (!details?.appointmentDate) return null; + return [details.appointmentDate, details.appointmentTime].filter(Boolean).join(" "); +} + export const statusColorMap: Record = { [OpportunityStatusType.NEW]: "var(--color-red-500)", [OpportunityStatusType.SEARCHING]: "var(--color-orange-500, var(--color-red-500))", diff --git a/src/components/Dashboard/Opportunities/OpportunityCard.tsx b/src/components/Dashboard/Opportunities/OpportunityCard.tsx index 0bdf17ae..6ba62439 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCard.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCard.tsx @@ -1,4 +1,4 @@ -import { ApiVolunteerOpportunityGetList, LangPurpose, ProfileVolunteeringType } from "need4deed-sdk"; +import { ApiVolunteerOpportunityGetList, LangPurpose, OptionItem, ProfileVolunteeringType } from "need4deed-sdk"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; @@ -7,27 +7,49 @@ import { Paragraph } from "@/components/styled/text"; import CardDetail from "../Volunteers/CardDetail"; import { CardParagraph } from "../Volunteers/VolunteerCard"; import { IconName } from "../Volunteers/icon"; -import { getLanguagesByPurpose, getOptionTitles } from "./helpers"; -import { formatAvailability, statusColorMap, statusIconMap, volunteerTypeIconMap } from "./OpportunityCard.helpers"; +import { getActivityTitles, getLanguagesByPurpose, getOptionTitles } from "./helpers"; +import { + formatAccompanyingDate, + formatAvailability, + statusColorMap, + statusIconMap, + volunteerTypeIconMap, +} from "./OpportunityCard.helpers"; import { Card, LanguageRow, StatusDiv, StatusTagsDiv, TagDiv, TitleParagraph } from "./styles"; type Props = { opportunity: ApiVolunteerOpportunityGetList; volunteerId?: string; + activitiesList?: OptionItem[]; }; -export function OpportunityCard({ opportunity, volunteerId }: Props) { +export function OpportunityCard({ opportunity, volunteerId, activitiesList }: Props) { const { t, i18n } = useTranslation(); const router = useRouter(); - const { id, title, volunteerType, statusOpportunity, languages, activities, location, availability } = opportunity; + const { + id, + title, + volunteerType, + statusOpportunity, + languages, + activities, + location, + availability, + accompanyingDetails, + } = opportunity; const mainCommunication = getLanguagesByPurpose(languages, LangPurpose.GENERAL); const recipientLanguage = getLanguagesByPurpose(languages, LangPurpose.RECIPIENT); - const activityTitles = getOptionTitles(activities); const locationTitles = getOptionTitles(location); + const activityTitles = getActivityTitles(activities, activitiesList); - const scheduleText = availability?.length > 0 ? formatAvailability(availability) : null; + const isAccompanying = volunteerType === ProfileVolunteeringType.ACCOMPANYING; + const scheduleText = isAccompanying + ? formatAccompanyingDate(accompanyingDetails) + : availability?.length > 0 + ? formatAvailability(availability) + : null; const handleCardClick = () => { if (!id) return; @@ -82,9 +104,11 @@ export function OpportunityCard({ opportunity, volunteerId }: Props) { )} - - - + {activityTitles.length > 0 && ( + + + + )} {scheduleText && } diff --git a/src/components/Dashboard/Opportunities/OpportunityCardList.tsx b/src/components/Dashboard/Opportunities/OpportunityCardList.tsx index 2bda28ae..84997d58 100644 --- a/src/components/Dashboard/Opportunities/OpportunityCardList.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityCardList.tsx @@ -1,9 +1,10 @@ -import { ApiVolunteerOpportunityGetList } from "need4deed-sdk"; +import { ApiVolunteerOpportunityGetList, OptionItem } from "need4deed-sdk"; import { PaginatedGrid } from "@/components/core/paginatedGrid"; import { OpportunityCard } from "./OpportunityCard"; import { OpportunityCardListContainer } from "./styles"; type Props = { + activitiesList?: OptionItem[]; opportunities: ApiVolunteerOpportunityGetList[]; count: number; columns: number; @@ -21,9 +22,10 @@ export function OpportunityCardList({ currentPage, setCurrentPage, volunteerId, + activitiesList, }: Props) { const items = opportunities.map((opp) => ( - + )); return ( diff --git a/src/components/Dashboard/Opportunities/OpportunityListController.tsx b/src/components/Dashboard/Opportunities/OpportunityListController.tsx index 80c8e1b1..4f52d085 100644 --- a/src/components/Dashboard/Opportunities/OpportunityListController.tsx +++ b/src/components/Dashboard/Opportunities/OpportunityListController.tsx @@ -58,6 +58,7 @@ export function OpportunityListController({ return ( { + if (value === true) { + const paramValue = + (options?.serializeToIDs && options.apiFilterOptions?.activity?.find((d) => d.title === key)?.id) || key; + params.append(EntityTableName.ACTIVITY, String(paramValue)); + } + }); + return asString ? params.toString() : params; } @@ -64,22 +81,27 @@ export function deserializeOpportunityFilters( const queryDistricts = searchParams.getAll(QueryParamsKeys.DISTRICT); queryDistricts.forEach((d) => { - if (newFilter.district[d] !== undefined) newFilter.district[d] = true; + newFilter.district[d] = true; }); const queryLanguages = searchParams.getAll(QueryParamsKeys.LANGUAGE); queryLanguages.forEach((l) => { - if (newFilter.language[l] !== undefined) newFilter.language[l] = true; + newFilter.language[l] = true; }); const queryStatus = searchParams.getAll("status"); queryStatus.forEach((s) => { - if (newFilter.status[s] !== undefined) newFilter.status[s] = true; + newFilter.status[s] = true; }); const queryType = searchParams.getAll("type"); queryType.forEach((s) => { - if (newFilter.type[s] !== undefined) newFilter.type[s] = true; + newFilter.type[s] = true; + }); + + const queryActivities = searchParams.getAll(EntityTableName.ACTIVITY); + queryActivities.forEach((l) => { + newFilter.activity[l] = true; }); return newFilter; @@ -97,3 +119,9 @@ export function getOptionTitles(items: OptionById[] | undefined): string[] { if (!items || !Array.isArray(items)) return []; return items.map((item) => (typeof item.title === "string" ? item.title : "")).filter(Boolean); } + +export function getActivityTitles(activities: OptionById[], activityList: OptionItem[] | undefined): string[] { + if (!activities?.length || !activityList?.length) return []; + const activityMap = new Map(activityList.map((item) => [String(item.id), item.title])); + return activities.map((act) => activityMap.get(String(act.id))).filter((title): title is string => Boolean(title)); +} diff --git a/src/components/Dashboard/Profile/ProfilePage.tsx b/src/components/Dashboard/Profile/ProfilePage.tsx index 09d08f0b..6c8d6631 100644 --- a/src/components/Dashboard/Profile/ProfilePage.tsx +++ b/src/components/Dashboard/Profile/ProfilePage.tsx @@ -7,12 +7,12 @@ import { BackLink, PageContainer } from "./styles"; import { ProfileEntityProps } from "./types"; const ProfilePage = (props: ProfileEntityProps) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const { sections, heading, header } = useProfileSections(props); return ( - + {t("dashboard.volunteerProfile.backToDashboard")} diff --git a/src/components/Dashboard/Profile/common/statusMaps.ts b/src/components/Dashboard/Profile/common/statusMaps.ts index 6a7b784d..ea9dcdda 100644 --- a/src/components/Dashboard/Profile/common/statusMaps.ts +++ b/src/components/Dashboard/Profile/common/statusMaps.ts @@ -37,74 +37,78 @@ export type StatusValue = | AgentVolunteerSearch | AgentTrustLevel; -export const statusColorMap: Record = { - [VolunteerStateEngagementType.ACTIVE]: "var(--color-green-100)", - [VolunteerStateEngagementType.AVAILABLE]: "var(--color-violet-100)", - [VolunteerStateEngagementType.TEMP_UNAVAILABLE]: "var( --color-red-50)", - [VolunteerStateEngagementType.UNRESPONSIVE]: "var(--color-grey-50)", - [VolunteerStateEngagementType.INACTIVE]: "var(--color-grey-50)", - [VolunteerStateEngagementType.NEW]: "var(--color-green-100)", - [OpportunityMatchStatus.UNMATCHED]: "var(--color-grey-50)", - [OpportunityMatchStatus.PENDING_MATCH]: "var(--color-violet-100)", - [OpportunityMatchStatus.MATCHED]: "var(--color-green-100)", - [OpportunityMatchStatus.NEEDS_REMATCH]: "var(--color-red-50)", - [VolunteerStateMatchType.NO_MATCHES]: "var(--color-grey-50)", - [VolunteerStateMatchType.PENDING_MATCH]: "var(--color-violet-100)", - [VolunteerStateMatchType.MATCHED]: "var(--color-green-100)", - [VolunteerStateMatchType.NEEDS_REMATCH]: "var(--color-red-50)", - [VolunteerStateTypeType.ACCOMPANYING]: "var(--color-blue-500)", - [VolunteerStateTypeType.EVENTS]: "var(--color-blue-500)", - [VolunteerStateTypeType.REGULAR]: "var(--color-blue-500)", - [VolunteerStateTypeType.REGULAR_ACCOMPANYING]: "var(--color-blue-500)", - [OpportunityStatusType.SEARCHING]: "var(--color-violet-100)", - [OpportunityStatusType.NEW]: "var(--color-violet-100)", - [OpportunityStatusType.ACTIVE]: "var(--color-green-50)", - [OpportunityStatusType.PAST]: "var(--color-grey-50)", - [AgentVolunteerSearch.NOT_NEEDED]: "var(--color-grey-50)", - [AgentVolunteerSearch.VOLUNTEERS_FOUND]: "var(--color-green-100)", - [AgentVolunteerSearch.SEARCHING]: "var(--color-red-50)", - [AgentTrustLevel.UNKNOWN]: "var(--color-grey-50)", - [AgentTrustLevel.LOW]: "var(--color-red-50)", - [AgentTrustLevel.HIGH]: "var(--color-green-100)", - [AgentEngagementStatusType.NEW]: "var(--color-violet-100)", - [AgentEngagementStatusType.ACTIVE]: "var(--color-green-100)", - [AgentEngagementStatusType.INACTIVE]: "var(--color-grey-50)", - [AgentEngagementStatusType.UNRESPONSIVE]: "var(--color-grey-50)", -}; +// Several SDK enums share the same underlying string values (e.g. "new", "active", +// "pending-match"). Object literals with computed duplicate keys are a TS error, so +// these maps are built with Object.fromEntries — arrays have no such restriction and +// the last entry for a given key wins, matching the original intended behaviour. +export const statusColorMap: Record = Object.fromEntries([ + [VolunteerStateEngagementType.ACTIVE, "var(--color-green-100)"], + [VolunteerStateEngagementType.AVAILABLE, "var(--color-violet-100)"], + [VolunteerStateEngagementType.TEMP_UNAVAILABLE, "var( --color-red-50)"], + [VolunteerStateEngagementType.UNRESPONSIVE, "var(--color-grey-50)"], + [VolunteerStateEngagementType.INACTIVE, "var(--color-grey-50)"], + [VolunteerStateEngagementType.NEW, "var(--color-green-100)"], + [OpportunityMatchStatus.UNMATCHED, "var(--color-grey-50)"], + [OpportunityMatchStatus.PENDING_MATCH, "var(--color-violet-100)"], + [OpportunityMatchStatus.MATCHED, "var(--color-green-100)"], + [OpportunityMatchStatus.NEEDS_REMATCH, "var(--color-red-50)"], + [VolunteerStateMatchType.NO_MATCHES, "var(--color-grey-50)"], + [VolunteerStateMatchType.PENDING_MATCH, "var(--color-violet-100)"], + [VolunteerStateMatchType.MATCHED, "var(--color-green-100)"], + [VolunteerStateMatchType.NEEDS_REMATCH, "var(--color-red-50)"], + [VolunteerStateTypeType.ACCOMPANYING, "var(--color-blue-500)"], + [VolunteerStateTypeType.EVENTS, "var(--color-blue-500)"], + [VolunteerStateTypeType.REGULAR, "var(--color-blue-500)"], + [VolunteerStateTypeType.REGULAR_ACCOMPANYING, "var(--color-blue-500)"], + [OpportunityStatusType.SEARCHING, "var(--color-violet-100)"], + [OpportunityStatusType.NEW, "var(--color-violet-100)"], + [OpportunityStatusType.ACTIVE, "var(--color-green-50)"], + [OpportunityStatusType.PAST, "var(--color-grey-50)"], + [AgentVolunteerSearch.NOT_NEEDED, "var(--color-grey-50)"], + [AgentVolunteerSearch.VOLUNTEERS_FOUND, "var(--color-green-100)"], + [AgentVolunteerSearch.SEARCHING, "var(--color-red-50)"], + [AgentTrustLevel.UNKNOWN, "var(--color-grey-50)"], + [AgentTrustLevel.LOW, "var(--color-red-50)"], + [AgentTrustLevel.HIGH, "var(--color-green-100)"], + [AgentEngagementStatusType.NEW, "var(--color-violet-100)"], + [AgentEngagementStatusType.ACTIVE, "var(--color-green-100)"], + [AgentEngagementStatusType.INACTIVE, "var(--color-grey-50)"], + [AgentEngagementStatusType.UNRESPONSIVE, "var(--color-grey-50)"], +]) as Record; type IconComponent = React.ComponentType<{ size?: number; color?: string }>; -export const statusIconMap: Record = { - [VolunteerStateEngagementType.ACTIVE]: ChartLineIcon, - [VolunteerStateEngagementType.AVAILABLE]: CalendarBlankIcon, - [VolunteerStateEngagementType.TEMP_UNAVAILABLE]: CalendarXIcon, - [VolunteerStateEngagementType.UNRESPONSIVE]: PhoneXIcon, - [VolunteerStateEngagementType.INACTIVE]: StopCircleIcon, - [VolunteerStateEngagementType.NEW]: SparkleIcon, - [OpportunityMatchStatus.UNMATCHED]: ProhibitInsetIcon, - [OpportunityMatchStatus.PENDING_MATCH]: HourglassIcon, - [OpportunityMatchStatus.MATCHED]: CheckCircleIcon, - [OpportunityMatchStatus.NEEDS_REMATCH]: ArrowsClockwiseIcon, - [VolunteerStateMatchType.NO_MATCHES]: ProhibitInsetIcon, - [VolunteerStateMatchType.PENDING_MATCH]: HourglassIcon, - [VolunteerStateMatchType.MATCHED]: CheckCircleIcon, - [VolunteerStateMatchType.NEEDS_REMATCH]: ArrowsClockwiseIcon, - [VolunteerStateTypeType.ACCOMPANYING]: UsersIcon, - [VolunteerStateTypeType.EVENTS]: UsersIcon, - [VolunteerStateTypeType.REGULAR]: UsersIcon, - [VolunteerStateTypeType.REGULAR_ACCOMPANYING]: UsersIcon, - [OpportunityStatusType.NEW]: SparkleIcon, - [OpportunityStatusType.ACTIVE]: ChartLineIcon, - [OpportunityStatusType.SEARCHING]: HourglassIcon, - [OpportunityStatusType.PAST]: StopCircleIcon, - [AgentVolunteerSearch.NOT_NEEDED]: HandPalmIcon, - [AgentVolunteerSearch.VOLUNTEERS_FOUND]: CheckCircleIcon, - [AgentVolunteerSearch.SEARCHING]: BinocularsIcon, - [AgentTrustLevel.UNKNOWN]: QuestionIcon, - [AgentTrustLevel.LOW]: SmileySadIcon, - [AgentTrustLevel.HIGH]: SmileyIcon, - [AgentEngagementStatusType.NEW]: SparkleIcon, - [AgentEngagementStatusType.ACTIVE]: ChartLineIcon, - [AgentEngagementStatusType.INACTIVE]: StopCircleIcon, - [AgentEngagementStatusType.UNRESPONSIVE]: PhoneXIcon, -}; +export const statusIconMap: Record = Object.fromEntries([ + [VolunteerStateEngagementType.ACTIVE, ChartLineIcon], + [VolunteerStateEngagementType.AVAILABLE, CalendarBlankIcon], + [VolunteerStateEngagementType.TEMP_UNAVAILABLE, CalendarXIcon], + [VolunteerStateEngagementType.UNRESPONSIVE, PhoneXIcon], + [VolunteerStateEngagementType.INACTIVE, StopCircleIcon], + [VolunteerStateEngagementType.NEW, SparkleIcon], + [OpportunityMatchStatus.UNMATCHED, ProhibitInsetIcon], + [OpportunityMatchStatus.PENDING_MATCH, HourglassIcon], + [OpportunityMatchStatus.MATCHED, CheckCircleIcon], + [OpportunityMatchStatus.NEEDS_REMATCH, ArrowsClockwiseIcon], + [VolunteerStateMatchType.NO_MATCHES, ProhibitInsetIcon], + [VolunteerStateMatchType.PENDING_MATCH, HourglassIcon], + [VolunteerStateMatchType.MATCHED, CheckCircleIcon], + [VolunteerStateMatchType.NEEDS_REMATCH, ArrowsClockwiseIcon], + [VolunteerStateTypeType.ACCOMPANYING, UsersIcon], + [VolunteerStateTypeType.EVENTS, UsersIcon], + [VolunteerStateTypeType.REGULAR, UsersIcon], + [VolunteerStateTypeType.REGULAR_ACCOMPANYING, UsersIcon], + [OpportunityStatusType.NEW, SparkleIcon], + [OpportunityStatusType.ACTIVE, ChartLineIcon], + [OpportunityStatusType.SEARCHING, HourglassIcon], + [OpportunityStatusType.PAST, StopCircleIcon], + [AgentVolunteerSearch.NOT_NEEDED, HandPalmIcon], + [AgentVolunteerSearch.VOLUNTEERS_FOUND, CheckCircleIcon], + [AgentVolunteerSearch.SEARCHING, BinocularsIcon], + [AgentTrustLevel.UNKNOWN, QuestionIcon], + [AgentTrustLevel.LOW, SmileySadIcon], + [AgentTrustLevel.HIGH, SmileyIcon], + [AgentEngagementStatusType.NEW, SparkleIcon], + [AgentEngagementStatusType.ACTIVE, ChartLineIcon], + [AgentEngagementStatusType.INACTIVE, StopCircleIcon], + [AgentEngagementStatusType.UNRESPONSIVE, PhoneXIcon], +]) as unknown as Record; diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx index 64e9d9fd..1a22ae05 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsDisplay.tsx @@ -23,6 +23,14 @@ export const AccompanyingDetailsDisplay = ({ values, languageLabel }: Props) => setValue={() => {}} /> + {}} + /> + {values.appointmentDate ? format(values.appointmentDate, "dd.MM.yyyy") : EMPTY_PLACEHOLDER_VALUE} diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx index bcff065b..dce6d0b9 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/AccompanyingDetailsEdit.tsx @@ -60,6 +60,21 @@ export const AccompanyingDetailsEdit = ({ )} /> + }) => ( + + )} + /> + {errors.appointmentTime && ( - {t( - `dashboard.opportunityProfile.accompanyingDetails.validation.${errors.appointmentTime.message}`, - )} + {t(`dashboard.opportunityProfile.accompanyingDetails.validation.${errors.appointmentTime.message}`)} )} diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts index f36c634c..558db99c 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/accompanyingDetailsSchema.ts @@ -4,6 +4,7 @@ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; export const accompanyingDetailsSchema = z.object({ appointmentAddress: z.string().optional(), + appointmentPostcode: z.string().optional(), appointmentDate: z.date().nullable().optional(), appointmentTime: z .string() diff --git a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts index 2a46e59e..e1140a02 100644 --- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts +++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts @@ -38,9 +38,11 @@ export const getInitialFormValues = ( details: ApiOpportunityAccompanyingDetails | undefined, ): AccompanyingDetailsFormData => ({ appointmentAddress: details?.appointmentAddress || "", + appointmentPostcode: + (details as ApiOpportunityAccompanyingDetails & { appointmentPostcode?: string })?.appointmentPostcode || "", appointmentDate: parseDate(details?.appointmentDate), appointmentTime: parseTime(details?.appointmentTime), refugeeNumber: details?.refugeeNumber || "", refugeeName: details?.refugeeName || "", - languageToTranslate: details?.languageToTranslate || "", + languageToTranslate: details?.languageToTranslate?.toString() ?? "", }); diff --git a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx index dda42fd7..ff69ee40 100644 --- a/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx +++ b/src/components/Dashboard/Profile/sections/AgentOpportunities/AgentOpportunities.tsx @@ -1,32 +1,49 @@ -import { OpportunityVolunteerStatusType } from "need4deed-sdk"; +import { Heading4 } from "@/components/styled/text"; +import { apiPathAgent, cacheTTL } from "@/config/constants"; +import { useGetQuery } from "@/hooks/useGetQuery"; +import { ApiOpportunityGetList, Id } from "need4deed-sdk"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; +import { Accordion } from "../shared/Accordion"; import { SectionEmptyState } from "../shared/styles"; -import { Tabs } from "../shared/Tabs"; -import { useTabTransitions } from "../shared/useTabTransitions"; import { AgentOpportunitiesContainer } from "./styles"; -const tabsKeys = ["lookingForVolunteers", "active", "past"] as const; +type Props = { agentId: Id }; -const agentTabStatusOrder: OpportunityVolunteerStatusType[] = [ - OpportunityVolunteerStatusType.PENDING, - OpportunityVolunteerStatusType.ACTIVE, - OpportunityVolunteerStatusType.PAST, -]; +export const AgentOpportunities = ({ agentId }: Props) => { + const { t, i18n } = useTranslation(); + const router = useRouter(); -export const AgentOpportunities = () => { - const { t } = useTranslation(); + const { data, isLoading } = useGetQuery({ + queryKey: ["agent-opportunities", String(agentId)], + apiPath: `${apiPathAgent}/${agentId}/opportunity-linked`, + staleTime: cacheTTL, + enabled: !!agentId, + addLang: false, + }); - const { selectedTabIndex, setSelectedTabIndex, tabCounts } = useTabTransitions([], agentTabStatusOrder); + const opportunities = data ?? []; - const tabs = tabsKeys.map((key, index) => ({ - label: t(`dashboard.agentProfile.opportunitiesSec.tabs.${key}`), - count: tabCounts[index], - })); + if (isLoading) return ; return ( - - {t("dashboard.volunteerProfile.opportunitiesSec.emptyState")} + {opportunities.length === 0 ? ( + {t("dashboard.volunteerProfile.opportunitiesSec.emptyState")} + ) : ( + opportunities.map((opp) => ( + + {opp.title} + + } + subtitle={opp.statusOpportunity ? t(`dashboard.opportunities.status.${opp.statusOpportunity}`) : "-"} + onGoToProfile={() => router.push(`/${i18n.language}/dashboard/opportunities/${opp.id}`)} + /> + )) + )} ); }; diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx index a04f44ea..ddc77ad9 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx @@ -2,11 +2,12 @@ import { EmptyPlaceholder } from "@/components/core/common/EmptyPlaceholder"; import { Tags } from "@/components/core/common/Tags"; import { formatAvailability } from "@/components/Dashboard/Profile/sections/VolunteerProfile/formatters"; import { EditableField } from "@/components/EditableField/EditableField"; -import { ApiOpportunityGet, Lang, LangPurpose } from "need4deed-sdk"; +import { EMPTY_PLACEHOLDER_VALUE } from "@/config/constants"; +import { ApiOpportunityGet, Lang, LangPurpose, VolunteerStateTypeType } from "need4deed-sdk"; import { useTranslation } from "react-i18next"; import { FormDetails } from "../shared/styles"; import { extractOptionTitles, formatLanguagesByPurpose } from "./formatters"; -import { FieldRow, TagsValue } from "./styles"; +import { DateFieldRow, FieldRow, TagsValue } from "./styles"; import { OpportunityWithDetails } from "./types"; type Props = { @@ -19,6 +20,8 @@ export function OpportunityDetailsDisplay({ opportunity }: Props) { const opp = opportunity as OpportunityWithDetails; const prefix = "dashboard.opportunityProfile.opportunityDetails"; + const isEventType = opp.volunteerType === VolunteerStateTypeType.EVENTS; + const mainCommunication = formatLanguagesByPurpose(opp.languages, LangPurpose.GENERAL, t); const residentsSpeak = formatLanguagesByPurpose(opp.languages, LangPurpose.RECIPIENT, t); const schedule = formatAvailability(opp.availability, t); @@ -51,7 +54,27 @@ export function OpportunityDetailsDisplay({ opportunity }: Props) { setValue={() => {}} /> - {}} /> + {isEventType ? ( + <> + + + {EMPTY_PLACEHOLDER_VALUE} + + + + + {EMPTY_PLACEHOLDER_VALUE} + + + ) : ( + {}} + /> + )} String(a.id)), skills: opp.skills.map((s) => String(s.id)), }, @@ -136,24 +143,67 @@ export function OpportunityDetailsEdit({ opportunity, onCancel }: Props) { )} /> - ( - - -
- - {fieldState.error?.message && } -
-
- )} - /> + {isEventType ? ( + <> + ( + + + + field.onChange(d ?? null)} + locale={locale} + allowFuture + /> + + + )} + /> + + ( + + + + + {errors.eventTime && {errors.eventTime.message}} + + + )} + /> + + ) : ( + ( + + +
+ + {fieldState.error?.message && } +
+
+ )} + /> + )} string) => - z - .array(languageObjectSchema) - .superRefine((languages, ctx) => { - const hasCompleteRow = languages.some((lang) => lang.language !== ""); - if (!hasCompleteRow) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t(`${i18nPrefix}.languageRequired`), - }); - } - }); + z.array(languageObjectSchema).superRefine((languages, ctx) => { + const hasCompleteRow = languages.some((lang) => lang.language !== ""); + if (!hasCompleteRow) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t(`${i18nPrefix}.languageRequired`), + }); + } + }); export const createOpportunityDetailsSchema = (t: (key: string) => string) => z.object({ @@ -30,28 +28,26 @@ export const createOpportunityDetailsSchema = (t: (key: string) => string) => .string() .min(1, t(`${i18nPrefix}.descriptionRequired`)) .max(MAX_DESCRIPTION_LENGTH, t(`${i18nPrefix}.descriptionTooLong`)), - numberOfVolunteers: z - .string() - .refine((val) => val !== "" && val !== "0", { - message: t(`${i18nPrefix}.numberOfVolunteersRequired`), - }), + numberOfVolunteers: z.string().refine((val) => val !== "" && val !== "0", { + message: t(`${i18nPrefix}.numberOfVolunteersRequired`), + }), mainCommunication: languagesValidator(t), residentsSpeak: languagesValidator(t), - availability: z.custom( - (data) => { - if (!Array.isArray(data)) return false; - return data.some((day) => - day.timeSlots.some((slot: { selected: boolean }) => slot.selected), - ); - }, - t(`${i18nPrefix}.availabilityRequired`), - ), - activities: z - .array(z.string()) - .min(1, t(`${i18nPrefix}.activitiesRequired`)), - skills: z - .array(z.string()) - .min(1, t(`${i18nPrefix}.skillsRequired`)), + availability: z + .custom( + (data) => { + if (data === null || data === undefined) return true; + if (!Array.isArray(data)) return false; + return data.some((day) => day.timeSlots.some((slot: { selected: boolean }) => slot.selected)); + }, + t(`${i18nPrefix}.availabilityRequired`), + ) + .nullable() + .optional(), + eventDate: z.date().nullable().optional(), + eventTime: z.string().optional(), + activities: z.array(z.string()).min(1, t(`${i18nPrefix}.activitiesRequired`)), + skills: z.array(z.string()), }); export type OpportunityDetailsFormData = z.infer>; diff --git a/src/components/Dashboard/Profile/sections/OpportunityDetails/styles.ts b/src/components/Dashboard/Profile/sections/OpportunityDetails/styles.ts index c335000d..adfd34bf 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/styles.ts +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/styles.ts @@ -1,3 +1,4 @@ +import { HasError } from "@/types"; import styled from "styled-components"; export const FieldRow = styled.div` @@ -37,3 +38,49 @@ export const FieldGroup = styled(FieldRow)` min-width: 0; } `; + +export const DateFieldRow = styled.div` + display: var(--editableField-fieldWrapper-display); + border-bottom: var(--editableField-fieldWrapper-borderBottom); + padding: var(--editableField-fieldWrapper-padding); + color: var(--color-midnight); + width: var(--editableField-fieldWrapper-width); + align-items: var(--editableField-fieldWrapper-alignItems); + font-size: var(--editableField-fieldWrapper-fontSize); + gap: var(--editableField-fieldWrapper-gap); + + label { + font-weight: var(--editableField-fieldWrapper-label-fontWeight); + font-size: var(--editableField-fieldWrapper-label-fontSize); + width: var(--editableField-fieldWrapper-label-width); + flex-shrink: var(--editableField-fieldWrapper-label-flexShrink); + } +`; + +export const DatePickerContainer = styled.div` + flex: 1; +`; + +export const TimeInputWrapper = styled.div` + flex: 1; +`; + +export const TimeInput = styled.input` + width: 100%; + border-radius: var(--editableField-fieldWrapper-input-borderRadius); + padding: var(--editableField-fieldWrapper-input-padding); + color: var(--color-midnight); + border: ${(props) => + props.$hasError ? "2px solid var(--color-red-600)" : "var(--editableField-fieldWrapper-input-border)"}; + + &:focus { + outline: none; + border: ${(props) => (props.$hasError ? "2px solid var(--color-red-600)" : "2px solid var(--color-green-200)")}; + } +`; + +export const ErrorText = styled.span` + color: var(--color-red-600); + font-size: var(--font-size-14); + margin-top: var(--spacing-4); +`; diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts index c2816678..7057771a 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/formatters.ts @@ -31,6 +31,7 @@ export function formatLanguages( id: lang.id, language: String(dbId), level: proficiencyToLevel[lang.proficiency?.toLowerCase() || "native"] || LanguageLevel.NATIVE, + purpose: lang.purpose, }; }); } diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts index 99064b5f..acb35076 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/transformers.ts @@ -1,5 +1,5 @@ import { TFunction } from "i18next"; -import { ApiLanguage, ApiVolunteerGet, VolunteerStateTypeType } from "need4deed-sdk"; +import { ApiLanguage, ApiVolunteerGet, LangPurpose, VolunteerStateTypeType } from "need4deed-sdk"; import { apiToFormAvailability } from "./availabilityUtils"; import { LEVEL_TO_PROFICIENCY } from "./constants"; import { formatActivities, formatDistricts, formatLanguages, formatSkills, getVolunteerTypeLabel } from "./formatters"; @@ -52,6 +52,7 @@ export function transformLanguagesToApi(languages: VolunteerProfileFormData["lan id: parseInt(lang.language, 10), title: languageMapping.idToTitle[parseInt(lang.language, 10)] || "", proficiency: LEVEL_TO_PROFICIENCY[lang.level as unknown as number], + purpose: lang.purpose ?? LangPurpose.GENERAL, }) as ApiLanguage, ); } diff --git a/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts b/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts index b8c965ae..ea056bd2 100644 --- a/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts +++ b/src/components/Dashboard/Profile/sections/VolunteerProfile/volunteerProfileSchema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { Availability } from "@/components/forms/types"; import { LanguageLevel, LanguageObject } from "@/types"; +import { LangPurpose } from "need4deed-sdk"; export const createVolunteerProfileSchema = (t: (key: string) => string) => { return z.object({ @@ -10,6 +11,7 @@ export const createVolunteerProfileSchema = (t: (key: string) => string) => { id: z.number(), language: z.string(), level: z.union([z.enum(LanguageLevel), z.literal("")]), + purpose: z.nativeEnum(LangPurpose).optional(), }) satisfies z.ZodType, ) .min(1, t("dashboard.volunteerProfile.profileSection.validation.languageRequired")) diff --git a/src/components/Dashboard/Volunteers/helpers.ts b/src/components/Dashboard/Volunteers/helpers.ts index 13ac0c1b..6a686eb8 100644 --- a/src/components/Dashboard/Volunteers/helpers.ts +++ b/src/components/Dashboard/Volunteers/helpers.ts @@ -80,10 +80,14 @@ export function serializeFilters( params.delete(QueryParamsKeys.DISTRICT); Object.entries(filter.district).forEach(([key, value]) => { if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.district?.find((d) => d.title === key)?.id) || key; - - params.append(QueryParamsKeys.DISTRICT, String(paramValue)); + if (options?.serializeToIDs && options.apiFilterOptions) { + const districtId = options.apiFilterOptions.district?.find((d) => d.title === key)?.id; + if (districtId !== undefined) { + params.append(QueryParamsKeys.DISTRICT, String(districtId)); + } + } else { + params.append(QueryParamsKeys.DISTRICT, key); + } } }); @@ -91,10 +95,14 @@ export function serializeFilters( params.delete(QueryParamsKeys.LANGUAGE); Object.entries(filter.language).forEach(([key, value]) => { if (value === true) { - const paramValue = - (options?.serializeToIDs && options.apiFilterOptions?.language?.find((d) => d.title === key)?.id) || key; - - params.append(QueryParamsKeys.LANGUAGE, String(paramValue)); + if (options?.serializeToIDs && options.apiFilterOptions) { + const languageId = options.apiFilterOptions.language?.find((d) => d.title === key)?.id; + if (languageId !== undefined) { + params.append(QueryParamsKeys.LANGUAGE, String(languageId)); + } + } else { + params.append(QueryParamsKeys.LANGUAGE, key); + } } }); diff --git a/src/components/EditableField/EditableField.tsx b/src/components/EditableField/EditableField.tsx index d01310fd..85b531f7 100644 --- a/src/components/EditableField/EditableField.tsx +++ b/src/components/EditableField/EditableField.tsx @@ -535,7 +535,9 @@ export const EditableField = forwardRef(function EditableField { - // Handled by parent OptionRow onClick + if (type === "checkbox-list") { + handleCheckboxChange(option); + } }} onClick={(e) => e.stopPropagation()} /> diff --git a/src/components/EventsSection/EventsSection.tsx b/src/components/EventsSection/EventsSection.tsx new file mode 100644 index 00000000..af491597 --- /dev/null +++ b/src/components/EventsSection/EventsSection.tsx @@ -0,0 +1,3 @@ +export function EventsSection() { + return
Events Section (Coming in #137)
; +} diff --git a/src/components/EventsSection/index.ts b/src/components/EventsSection/index.ts new file mode 100644 index 00000000..73bc4895 --- /dev/null +++ b/src/components/EventsSection/index.ts @@ -0,0 +1 @@ +export * from "./EventsSection"; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 49d94cf6..36e1853e 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -10,6 +10,8 @@ import BurgerMenuItems from "./BurgerMenuItems"; import LoginRegister from "./LoginRegister"; import MenuItems from "./MenuItems"; import UserProfile from "./UserProfile"; +import MenuItem from "./MenuItem"; +import Link from "next/link"; interface HeaderContainerProps { height?: string; @@ -47,7 +49,7 @@ export function Header({ menuItemColor, burgerMenuItemColor = "var(--color-midnight)", }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [isBurgerMenuOpen, setIsBurgerMenuOpen] = useState(false); const user = useCurrentUser(); @@ -77,6 +79,11 @@ export function Header({ )} + {user && ( + + + + )} {user ? : } ); diff --git a/src/components/Header/LoginRegister.tsx b/src/components/Header/LoginRegister.tsx index d3e5ebcc..6cab3d3a 100644 --- a/src/components/Header/LoginRegister.tsx +++ b/src/components/Header/LoginRegister.tsx @@ -10,13 +10,13 @@ const LoginRegisterContainer = styled.div` `; export function LoginRegister() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const router = useRouter(); return (