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 ( -
-
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/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..244e8648 100644 --- a/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx +++ b/src/components/Dashboard/Profile/sections/OpportunityDetails/OpportunityDetailsDisplay.tsx @@ -1,12 +1,14 @@ import { EmptyPlaceholder } from "@/components/core/common/EmptyPlaceholder"; import { Tags } from "@/components/core/common/Tags"; import { formatAvailability } from "@/components/Dashboard/Profile/sections/VolunteerProfile/formatters"; +import { useApiLanguages } from "@/components/Dashboard/Profile/sections/VolunteerProfile/hooks"; 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,7 +21,19 @@ export function OpportunityDetailsDisplay({ opportunity }: Props) { const opp = opportunity as OpportunityWithDetails; const prefix = "dashboard.opportunityProfile.opportunityDetails"; - const mainCommunication = formatLanguagesByPurpose(opp.languages, LangPurpose.GENERAL, t); + const { data: apiLanguages = [] } = useApiLanguages(); + + const isEventType = opp.volunteerType === VolunteerStateTypeType.EVENTS; + const isAccompanying = opp.volunteerType === VolunteerStateTypeType.ACCOMPANYING; + + const languageIdToTitle: Record = {}; + apiLanguages.forEach((lang) => { + languageIdToTitle[String(lang.id)] = lang.title; + }); + + const mainCommunication = isAccompanying + ? (languageIdToTitle[opp.accompanyingDetails?.languageToTranslate ?? ""] ?? EMPTY_PLACEHOLDER_VALUE) + : formatLanguagesByPurpose(opp.languages, LangPurpose.GENERAL, t); const residentsSpeak = formatLanguagesByPurpose(opp.languages, LangPurpose.RECIPIENT, t); const schedule = formatAvailability(opp.availability, t); const activities = extractOptionTitles(opp.activities, lang); @@ -51,7 +65,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/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 (