From 7fa86a2077d99dac9aa4690adaedfe1c34e7b032 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Fri, 7 Mar 2025 11:46:42 +0000 Subject: [PATCH] Merged in feat/SW-1555-jobylon-feed-filter (pull request #1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feat/SW-1555 jobylon feed filter * feat(SW-1555): Added jobylon feed component * feat(SW-1555): Added filter functionality for Jobylon feed Approved-by: Matilda Landström --- .../JobList/Filter/filter.module.css | 4 + .../JobylonFeed/JobList/Filter/index.tsx | 71 ++++++++++++ .../JobylonFeed/JobList/index.tsx | 76 ++++++++++++ .../JobylonFeed/JobList/jobList.module.css | 10 ++ .../JobylonFeed/JobList/reducer.ts | 61 ++++++++++ .../JobylonFeed/JobList/utils.ts | 109 ++++++++++++++++++ .../JobylonFeed/JobylonCard/index.tsx | 17 ++- .../DynamicContent/JobylonFeed/index.tsx | 25 +--- .../JobylonFeed/jobylonFeed.module.css | 10 -- .../ContentType/StaticPages/index.tsx | 2 +- .../TempDesignSystem/Select/index.tsx | 2 +- apps/scandic-web/i18n/dictionaries/en.json | 7 ++ .../server/routers/partners/jobylon/output.ts | 30 +++-- .../types/components/blocks/jobylonFeed.ts | 46 ++++++++ apps/scandic-web/types/enums/jobylonFeed.ts | 6 + 15 files changed, 424 insertions(+), 52 deletions(-) create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/filter.module.css create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/index.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/index.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/jobList.module.css create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/reducer.ts create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/utils.ts create mode 100644 apps/scandic-web/types/components/blocks/jobylonFeed.ts create mode 100644 apps/scandic-web/types/enums/jobylonFeed.ts diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/filter.module.css b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/filter.module.css new file mode 100644 index 000000000..86014b9a7 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/filter.module.css @@ -0,0 +1,4 @@ +.filterForm { + display: grid; + gap: var(--Spacing-x1); +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/index.tsx new file mode 100644 index 000000000..1bb085b2a --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/Filter/index.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useIntl } from "react-intl" + +import Select from "@/components/TempDesignSystem/Select" + +import styles from "./filter.module.css" + +import type { Key } from "react-aria-components" + +import type { JobylonFilterItem } from "@/types/components/blocks/jobylonFeed" +import { JobylonFilterKey } from "@/types/enums/jobylonFeed" + +interface FilterProps { + onFilterChange: (filter: JobylonFilterKey, value: Key) => void + countryFilters: JobylonFilterItem[] + cityFilters: JobylonFilterItem[] + departmentFilters: JobylonFilterItem[] + categoryFilters: JobylonFilterItem[] +} + +export default function Filter({ + onFilterChange, + countryFilters, + cityFilters, + departmentFilters, + categoryFilters, +}: FilterProps) { + const intl = useIntl() + + return ( +
+ onFilterChange(JobylonFilterKey.city, value)} + /> + onFilterChange(JobylonFilterKey.category, value)} + /> +
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/index.tsx new file mode 100644 index 000000000..369ef3fea --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/index.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useReducer } from "react" +import { useIntl } from "react-intl" + +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" + +import JobylonCard from "../JobylonCard" +import Filter from "./Filter" +import { init, reducer } from "./reducer" + +import styles from "./jobList.module.css" + +import type { Key } from "react-aria-components" + +import { ActionType } from "@/types/components/blocks/jobylonFeed" +import type { JobylonFilterKey } from "@/types/enums/jobylonFeed" +import type { JobylonItem } from "@/types/trpc/routers/jobylon" + +interface JobListProps { + allJobs: JobylonItem[] +} + +export default function JobList({ allJobs }: JobListProps) { + const intl = useIntl() + const [state, dispatch] = useReducer(reducer, { allJobs }, init) + + function handleFilterChange(filter: JobylonFilterKey, value: Key) { + const payload = { filter, value } + dispatch({ type: ActionType.UPDATE_FILTER, payload }) + } + + const countryFilters = [ + { label: intl.formatMessage({ id: "All countries" }), value: "" }, + ...state.countryFilters, + ] + const cityFilters = [ + { label: intl.formatMessage({ id: "All locations" }), value: "" }, + ...state.cityFilters, + ] + const departmentFilters = [ + { label: intl.formatMessage({ id: "All hotels and offices" }), value: "" }, + ...state.departmentFilters, + ] + const categoryFilters = [ + { label: intl.formatMessage({ id: "All categories" }), value: "" }, + ...state.categoryFilters, + ] + + return ( +
+ + + {intl.formatMessage( + { + id: "{count, plural, one {{count} Result} other {{count} Results}}", + }, + { count: state.jobs.length } + )} + +
    + {state.jobs.map((job) => ( +
  • + +
  • + ))} +
+
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/jobList.module.css b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/jobList.module.css new file mode 100644 index 000000000..86b4fd4e2 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/jobList.module.css @@ -0,0 +1,10 @@ +.jobList { + display: grid; + gap: var(--Spacing-x2); +} + +.list { + list-style: none; + display: grid; + gap: var(--Spacing-x2); +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/reducer.ts b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/reducer.ts new file mode 100644 index 000000000..3790e5972 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/reducer.ts @@ -0,0 +1,61 @@ +import { getFilteredJobs, getFiltersFromJobs } from "./utils" + +import { + type Action, + ActionType, + type InitState, + type State, +} from "@/types/components/blocks/jobylonFeed" + +export function init(initState: InitState): State { + const { country, city, department, category } = getFiltersFromJobs( + initState.allJobs + ) + + return { + allJobs: initState.allJobs, + jobs: initState.allJobs, + countryFilters: country, + cityFilters: city, + departmentFilters: department, + categoryFilters: category, + chosenCategoryFilter: "", + chosenCityFilter: "", + chosenCountryFilter: "", + chosenDepartmentFilter: "", + } +} + +export function reducer(state: State, action: Action) { + const type = action.type + switch (type) { + case ActionType.UPDATE_FILTER: { + const filters = { + country: state.chosenCountryFilter, + city: state.chosenCityFilter, + department: state.chosenDepartmentFilter, + category: state.chosenCategoryFilter, + [action.payload.filter]: action.payload.value, + } + const jobs = getFilteredJobs(state.allJobs, filters) + const { country, city, department, category } = getFiltersFromJobs(jobs) + + return { + ...state, + chosenCountryFilter: filters.country, + chosenCityFilter: filters.city, + chosenDepartmentFilter: filters.department, + chosenCategoryFilter: filters.category, + countryFilters: country, + cityFilters: city, + departmentFilters: department, + categoryFilters: category, + jobs, + } + } + default: + const unhandledActionType: never = type + console.info(`Unhandled type: ${unhandledActionType}`) + return state + } +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/utils.ts b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/utils.ts new file mode 100644 index 000000000..9c82aba9f --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobList/utils.ts @@ -0,0 +1,109 @@ +import type { Key } from "react-aria-components" + +import type { JobylonFilters } from "@/types/components/blocks/jobylonFeed" +import { JobylonFilterKey } from "@/types/enums/jobylonFeed" +import type { JobylonItem } from "@/types/trpc/routers/jobylon" + +export function getFiltersFromJobs(jobs: JobylonItem[]) { + const filters = jobs.reduce( + (acc, job) => { + if (job.locations.length) { + job.locations.forEach((location) => { + if (location.country && location.countryShort) { + if ( + !acc[JobylonFilterKey.country].find( + (f) => f.value === location.countryShort + ) + ) { + acc[JobylonFilterKey.country].push({ + value: location.countryShort, + label: location.country, + }) + } + } + if (location.city) { + if ( + !acc[JobylonFilterKey.city].find((f) => f.value === location.city) + ) { + acc[JobylonFilterKey.city].push({ + value: location.city, + label: location.city, + }) + } + } + }) + } + if (job.departments.length) { + job.departments.forEach((department) => { + if ( + !acc[JobylonFilterKey.department].find( + (f) => f.value === department.id + ) + ) { + acc[JobylonFilterKey.department].push({ + value: department.id, + label: department.name, + }) + } + }) + } + if (job.categories.length) { + job.categories.forEach((category) => { + if ( + !acc[JobylonFilterKey.category].find( + (f) => f.value === category.text + ) + ) { + acc[JobylonFilterKey.category].push({ + value: category.text, + label: category.text, + }) + } + }) + } + return acc + }, + { + [JobylonFilterKey.country]: [], + [JobylonFilterKey.category]: [], + [JobylonFilterKey.city]: [], + [JobylonFilterKey.department]: [], + } + ) + + // Sort each filter array by label + Object.keys(filters).forEach((key) => { + const typedKey = key as JobylonFilterKey + filters[typedKey].sort((a, b) => a.label.localeCompare(b.label)) + }) + + return filters +} + +export function getFilteredJobs( + jobs: JobylonItem[], + filters: Record +) { + const countryFilter = filters[JobylonFilterKey.country] + const cityFilter = filters[JobylonFilterKey.city] + const categoryFilter = filters[JobylonFilterKey.category] + const departmentFilter = filters[JobylonFilterKey.department] + + return jobs.filter((job) => { + const countryMatch = countryFilter + ? job.locations.some( + (location) => location.countryShort === countryFilter + ) + : true + const cityMatch = cityFilter + ? job.locations.some((location) => location.city === cityFilter) + : true + const departmentMatch = filters[JobylonFilterKey.department] + ? job.departments.some((department) => department.id === departmentFilter) + : true + const categoryMatch = filters[JobylonFilterKey.category] + ? job.categories.some((category) => category.text === categoryFilter) + : true + return countryMatch && cityMatch && departmentMatch && categoryMatch + }) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobylonCard/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobylonCard/index.tsx index 883ddfac0..5df6aa44a 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobylonCard/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/JobylonCard/index.tsx @@ -1,9 +1,14 @@ +"use client" + +import { useIntl } from "react-intl" + +import { dt } from "@/lib/dt" + import { OpenInNewSmallIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import { getIntl } from "@/i18n" -import { getLang } from "@/i18n/serverContext" +import useLang from "@/hooks/useLang" import styles from "./jobylonCard.module.css" @@ -13,13 +18,13 @@ interface JobylonCardProps { job: JobylonItem } -export default async function JobylonCard({ job }: JobylonCardProps) { - const intl = await getIntl() - const lang = getLang() +export default function JobylonCard({ job }: JobylonCardProps) { + const intl = useIntl() + const lang = useLang() const deadlineText = job.toDate ? intl.formatMessage( { id: "Deadline: {date}" }, - { date: job.toDate.locale(lang).format("Do MMMM") } + { date: dt(job.toDate).locale(lang).format("Do MMMM") } ) : intl.formatMessage({ id: "Open for application" }) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/index.tsx index 897671117..2bc8a4ba7 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/index.tsx @@ -3,12 +3,8 @@ import { getJobylonFeed } from "@/lib/trpc/memoizedRequests" import SectionContainer from "@/components/Section/Container" import SectionHeader from "@/components/Section/Header" import SectionLink from "@/components/Section/Link" -import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" -import { getIntl } from "@/i18n" -import JobylonCard from "./JobylonCard" - -import styles from "./jobylonFeed.module.css" +import JobList from "./JobList" interface JobylonFeedProps { title?: string @@ -21,7 +17,6 @@ export default async function JobylonFeed({ subtitle, link, }: JobylonFeedProps) { - const intl = await getIntl() const allJobs = await getJobylonFeed() if (!allJobs) { @@ -37,23 +32,7 @@ export default async function JobylonFeed({ headingAs="h3" headingLevel="h2" /> -
- - {intl.formatMessage( - { - id: "{count, plural, one {{count} Result} other {{count} Results}}", - }, - { count: allJobs.length } - )} - -
    - {allJobs.map((job) => ( -
  • - -
  • - ))} -
-
+ ) diff --git a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/jobylonFeed.module.css b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/jobylonFeed.module.css index b72fc5592..e69de29bb 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/jobylonFeed.module.css +++ b/apps/scandic-web/components/Blocks/DynamicContent/JobylonFeed/jobylonFeed.module.css @@ -1,10 +0,0 @@ -.list { - list-style: none; - display: grid; - gap: var(--Spacing-x2); -} - -.content { - display: grid; - gap: var(--Spacing-x2); -} diff --git a/apps/scandic-web/components/ContentType/StaticPages/index.tsx b/apps/scandic-web/components/ContentType/StaticPages/index.tsx index 886b4c500..a29fbab33 100644 --- a/apps/scandic-web/components/ContentType/StaticPages/index.tsx +++ b/apps/scandic-web/components/ContentType/StaticPages/index.tsx @@ -17,7 +17,7 @@ import styles from "./staticPage.module.css" import type { StaticPageProps } from "./staticPage" -export default function StaticPage({ +export default async function StaticPage({ content, tracking, pageType, diff --git a/apps/scandic-web/components/TempDesignSystem/Select/index.tsx b/apps/scandic-web/components/TempDesignSystem/Select/index.tsx index 6505430d9..2babf8d88 100644 --- a/apps/scandic-web/components/TempDesignSystem/Select/index.tsx +++ b/apps/scandic-web/components/TempDesignSystem/Select/index.tsx @@ -122,7 +122,7 @@ export default function Select({ aria-label={item.label} className={`${styles.listBoxItem} ${showRadioButton && styles.showRadioButton} ${optionsIcon && styles.iconLabel}`} id={item.value} - key={item.label} + key={`${item.value}_${item.label}`} data-testid={item.label} > {optionsIcon ? optionsIcon : null} diff --git a/apps/scandic-web/i18n/dictionaries/en.json b/apps/scandic-web/i18n/dictionaries/en.json index f9ad20ea4..aaf0431ba 100644 --- a/apps/scandic-web/i18n/dictionaries/en.json +++ b/apps/scandic-web/i18n/dictionaries/en.json @@ -34,6 +34,10 @@ "Age": "Age", "Airport": "Airport", "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.": "All add-ons are delivered at the same time. Changes to delivery times will affect earlier add-ons.", + "All categories": "All categories", + "All countries": "All countries", + "All hotels and offices": "All hotels and offices", + "All locations": "All locations", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", "All-day breakfast": "All-day breakfast", "Allergy-friendly room": "Allergy room", @@ -121,6 +125,7 @@ "Cancellation number": "Cancellation number", "Cancellation policy": "Cancellation policy", "Cancelled": "Cancelled", + "Category": "Category", "Change room": "Change room", "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.": "Changes can be made until {time} on {date}, subject to availability. Room rates may vary.", "Check for level upgrade": "Check for level upgrade", @@ -297,6 +302,7 @@ "Hotel": "Hotel", "Hotel details": "Hotel details", "Hotel facilities": "Hotel facilities", + "Hotel or office": "Hotel or office", "Hotel reservation": "Hotel reservation", "Hotel surroundings": "Hotel surroundings", "Hotels": "Hotels", @@ -361,6 +367,7 @@ "Link my accounts": "Link my accounts", "Link your accounts": "Link your accounts", "Location": "Location", + "Location (shown in local language)": "Location (shown in local language)", "Location in hotel": "Location in hotel", "Locations": "Locations", "Log in": "Log in", diff --git a/apps/scandic-web/server/routers/partners/jobylon/output.ts b/apps/scandic-web/server/routers/partners/jobylon/output.ts index 94695b49d..958736c4a 100644 --- a/apps/scandic-web/server/routers/partners/jobylon/output.ts +++ b/apps/scandic-web/server/routers/partners/jobylon/output.ts @@ -2,16 +2,22 @@ import { z } from "zod" import { dt } from "@/lib/dt" -const categoriesSchema = z.array( - z - .object({ category: z.object({ id: z.number(), text: z.string() }) }) - .transform(({ category }) => { - return { - id: category.id, - text: category.text, - } - }) -) +const categoriesSchema = z + .array( + z + .object({ category: z.object({ id: z.number(), text: z.string() }) }) + .transform(({ category }) => { + return { + id: category.id, + text: category.text, + } + }) + ) + .transform((categories) => + categories.filter( + (category): category is NonNullable => !!category + ) + ) const departmentsSchema = z .array( @@ -99,6 +105,8 @@ export const jobylonItemSchema = z const now = dt.utc() const fromDate = from_date ? dt(from_date) : null const toDate = to_date ? dt(to_date) : null + // Transformed to string as Dayjs objects cannot be passed to client components + const toDateAsString = toDate?.toString() ?? null return { id, @@ -109,7 +117,7 @@ export const jobylonItemSchema = z (!toDate || now.isSameOrBefore(toDate)), categories, departments, - toDate, + toDate: toDateAsString, locations, url: urls, } diff --git a/apps/scandic-web/types/components/blocks/jobylonFeed.ts b/apps/scandic-web/types/components/blocks/jobylonFeed.ts new file mode 100644 index 000000000..8c0a383b0 --- /dev/null +++ b/apps/scandic-web/types/components/blocks/jobylonFeed.ts @@ -0,0 +1,46 @@ +import type { Key } from "react-aria-components" + +import type { JobylonFilterKey } from "@/types/enums/jobylonFeed" +import type { JobylonItem } from "@/types/trpc/routers/jobylon" + +export interface JobylonFilterItem { + label: string + value: Key +} + +export interface JobylonFilters + extends Record { + [JobylonFilterKey.country]: JobylonFilterItem[] + [JobylonFilterKey.city]: JobylonFilterItem[] + [JobylonFilterKey.department]: JobylonFilterItem[] + [JobylonFilterKey.category]: JobylonFilterItem[] +} + +export enum ActionType { + UPDATE_FILTER = "UPDATE_FILTER", +} + +interface UpdateFilter { + payload: { + filter: JobylonFilterKey + value: Key + } + type: ActionType.UPDATE_FILTER +} + +export type Action = UpdateFilter + +export interface State { + allJobs: JobylonItem[] + jobs: JobylonItem[] + chosenCountryFilter: Key + chosenCityFilter: Key + chosenDepartmentFilter: Key + chosenCategoryFilter: Key + countryFilters: JobylonFilterItem[] + cityFilters: JobylonFilterItem[] + departmentFilters: JobylonFilterItem[] + categoryFilters: JobylonFilterItem[] +} + +export interface InitState extends Pick {} diff --git a/apps/scandic-web/types/enums/jobylonFeed.ts b/apps/scandic-web/types/enums/jobylonFeed.ts new file mode 100644 index 000000000..d44c9e642 --- /dev/null +++ b/apps/scandic-web/types/enums/jobylonFeed.ts @@ -0,0 +1,6 @@ +export enum JobylonFilterKey { + country = "country", + city = "city", + department = "department", + category = "category", +}