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 (
+
+ )
+}
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",
+}