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..dd331d21 100644
--- a/public/locales/de/translations.json
+++ b/public/locales/de/translations.json
@@ -423,7 +423,8 @@
"header": {
"button": {
"login": "Anmelden",
- "joinVolunteer": "Jetzt sich engagieren"
+ "joinVolunteer": "Jetzt sich engagieren",
+ "dashboard": "Dashboard"
}
},
"login": {
@@ -1064,6 +1065,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..c4d5abdf 100644
--- a/public/locales/en/translations.json
+++ b/public/locales/en/translations.json
@@ -423,7 +423,8 @@
"header": {
"button": {
"login": "Log in",
- "joinVolunteer": "Join as a volunteer"
+ "joinVolunteer": "Join as a volunteer",
+ "dashboard": "Dashboard"
}
},
"login": {
@@ -1063,6 +1064,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 (
-
-
-
-
-
- Login
-
-
- Persons
-
-
- Volunteer form
-
-
- Opportunity form
-
-
-
-
-
-
- );
+ 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 (
+
+
+
+
+
+ Login
+
+
+ Persons
+
+
+ Volunteer form
+
+
+ Opportunity form
+
+
+
+
+
+ );
+}
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/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..8a42731c 100644
--- a/src/components/Dashboard/Opportunities/OpportunityCard.tsx
+++ b/src/components/Dashboard/Opportunities/OpportunityCard.tsx
@@ -8,7 +8,13 @@ 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 {
+ formatAccompanyingDate,
+ formatAvailability,
+ statusColorMap,
+ statusIconMap,
+ volunteerTypeIconMap,
+} from "./OpportunityCard.helpers";
import { Card, LanguageRow, StatusDiv, StatusTagsDiv, TagDiv, TitleParagraph } from "./styles";
type Props = {
@@ -20,14 +26,29 @@ export function OpportunityCard({ opportunity, volunteerId }: 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 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;
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/helpers.ts b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts
index 2a46e59e..26c76c85 100644
--- a/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts
+++ b/src/components/Dashboard/Profile/sections/AccompanyingDetails/helpers.ts
@@ -42,5 +42,5 @@ export const getInitialFormValues = (
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/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/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 (