diff --git a/app/api/menu_service.tsx b/app/api/menu_service.tsx index 70b1737..63452c7 100644 --- a/app/api/menu_service.tsx +++ b/app/api/menu_service.tsx @@ -1,6 +1,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { rootQueryUrl, menusEndpoint } from '~/root'; import type { DailyMenu, DailyMenuDTO } from '~/interfaces'; +import { TAG_ID_MAP } from '~/interfaces'; import { getAccessToken } from '~/api/user_service'; import i18n from '~/i18n'; @@ -76,8 +77,23 @@ function upsertLocalDraft( return stored; } +function convertTagsToObjects(menu: any) { + return { + ...menu, + dishes: (menu.dishes || []).map((dish: any) => ({ + ...dish, + tags: (dish.tags || []) + .map((tag: string) => ({ + id: TAG_ID_MAP[tag as keyof typeof TAG_ID_MAP], + value: tag, + })) + .filter((t: any) => t.id), + })), + }; +} + function getUiLanguageCode() { - return (i18n.language || 'en').split('-')[0].toLowerCase(); + return (i18n.language || 'en').split('-')[0].toUpperCase(); } function mapLocalizedDishes(rawDishes: any[]): DailyMenu['dishes'] { @@ -90,7 +106,7 @@ function mapLocalizedDishes(rawDishes: any[]): DailyMenu['dishes'] { image: dishData?.image || '', category: dishData?.category || 'MAIN_COURSE', price: String(item?.price ?? dishData?.price ?? '0'), - allergens: Array.isArray(dishData?.allergens) ? dishData.allergens : [], + tags: Array.isArray(dishData?.tags) ? dishData.tags : [], } as any; }); } @@ -180,19 +196,35 @@ export const useUpdateDailyMenuWithToken = (token: string) => } const currentLanguage = getUiLanguageCode(); + const body = JSON.stringify(convertTagsToObjects(menu)); + console.log('[updateMenu] sending body:', body); - const response = await fetch(`${rootQueryUrl}/${menusEndpoint}/${restaurantId}`, { - method: 'PUT', + // Try POST first (create), fall back to PUT (update) if 405 + let response = await fetch(`${rootQueryUrl}/${menusEndpoint}/${restaurantId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'Accept-Language': currentLanguage, }, - body: JSON.stringify(menu), + body, }); + if (response.status === 405) { + response = await fetch(`${rootQueryUrl}/${menusEndpoint}/${restaurantId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Accept-Language': currentLanguage, + }, + body, + }); + } + if (!response.ok) { const text = await response.text().catch(() => ''); + console.log('[updateMenu] error response:', text); throw new Error(`Failed to update menu (${response.status}): ${text}`); } @@ -371,7 +403,7 @@ export const menuService = { name: dish.name, category: dish.category ?? 'MAIN_COURSE', price: typeof dish.price === 'number' ? dish.price : Number(dish.price ?? 0), - allergens: dish.allergens ?? [], + tags: (dish as any).tags?.map((t: any) => t.value ?? t) ?? [], })), }; } catch { @@ -443,7 +475,7 @@ export const menuService = { 'Content-Type': 'application/json', 'Accept-Language': currentLanguage, }, - body: JSON.stringify(menu), + body: JSON.stringify(convertTagsToObjects(menu)), }); if (!response.ok) { diff --git a/app/api/restaurant_service.tsx b/app/api/restaurant_service.tsx index c2a7750..be2d385 100644 --- a/app/api/restaurant_service.tsx +++ b/app/api/restaurant_service.tsx @@ -1,10 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; import { rootQueryUrl, allRestaurantsEndpoint } from '~/root'; import { apiGet, useApiPut } from '~/api/api'; -import type { Restaurant, RestaurantDetailsDTO, UpdateRestaurantRequest } from '~/interfaces'; +import type { + Restaurant, + RankedRestaurant, + RestaurantDetailsDTO, + UpdateRestaurantRequest, +} from '~/interfaces'; export let useGetAllRestaurants = () => apiGet('restaurants', `${rootQueryUrl}/${allRestaurantsEndpoint}`); +export let useGetRestaurantRecommendations = (token: string | undefined) => + useQuery({ + queryKey: ['restaurantRecommendations', token], + queryFn: async () => { + const response = await fetch(`${rootQueryUrl}/${allRestaurantsEndpoint}/me/recommendation`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error(`API Error: ${response.status}`); + return response.json() as Promise; + }, + enabled: !!token, + }); + export let useGetRestaurantDetails = (id: string) => apiGet( `restaurant-${id}`, diff --git a/app/api/user_service.tsx b/app/api/user_service.tsx index 57f8cfa..edb2285 100644 --- a/app/api/user_service.tsx +++ b/app/api/user_service.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { rootQueryUrl } from '~/root'; +import type { TagValue, TagType } from '~/interfaces'; export interface RestaurantListDTO { id: string; @@ -104,3 +105,42 @@ export const useGetFirstOwnedRestaurant = (token: string | undefined) => { enabled: isBrowser && !!token, }); }; + +export type PreferenceType = 'INCLUDE' | 'EXCLUDE'; + +export interface UserPreferenceDTO { + tagValue: TagValue; + tagType: TagType; + preferenceType: PreferenceType; +} + +export const useGetPreferences = (token: string | undefined) => + useQuery({ + queryKey: ['userPreferences'], + queryFn: async () => { + const response = await fetch(`${rootQueryUrl}/api/users`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) throw new Error('Failed to fetch preferences'); + return response.json(); + }, + enabled: !!token, + }); + +export async function savePreferences( + token: string, + preferences: Array<{ tagValue: TagValue; preferenceType: PreferenceType }> +): Promise { + const response = await fetch(`${rootQueryUrl}/api/users/preferences`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ preferences }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Failed to save preferences (${response.status}): ${text}`); + } +} diff --git a/app/components/menu/dish/dish_info_box.tsx b/app/components/menu/dish/dish_info_box.tsx index 8469a30..d684901 100644 --- a/app/components/menu/dish/dish_info_box.tsx +++ b/app/components/menu/dish/dish_info_box.tsx @@ -1,23 +1,17 @@ import type { Dish } from '~/interfaces'; -import { Beef, Milk, Nut, Wheat, CirclePile } from 'lucide-react'; +import { Milk, Nut, Wheat, Leaf, Vegan } from 'lucide-react'; import type { ComponentType } from 'react'; -const ALLERGEN_ICON_MAP: Record; label: string }> = - { - GLUTEN: { icon: Wheat, label: 'Gluten' }, - LACTOSE: { icon: Milk, label: 'Lactose' }, - MEAT: { icon: Beef, label: 'Meat' }, - NUTS: { icon: Nut, label: 'Nuts' }, - SESAME: { icon: CirclePile, label: 'Sesame' }, - }; +const TAG_ICON_MAP: Record; label: string }> = { + GLUTEN: { icon: Wheat, label: 'Gluten' }, + LACTOSE: { icon: Milk, label: 'Lactose' }, + NUTS: { icon: Nut, label: 'Nuts' }, + VEGAN: { icon: Vegan, label: 'Vegan' }, + VEGETARIAN: { icon: Leaf, label: 'Vegetarian' }, +}; export function DishInfo({ dish }: { dish: Dish }) { - const allergenIcons = (dish.allergens ?? []) - .map(allergen => { - const key = allergen.toUpperCase(); - return ALLERGEN_ICON_MAP[key]; - }) - .filter(Boolean); + const tagIcons = (dish.tags ?? []).map(tag => TAG_ICON_MAP[tag.value]).filter(Boolean); return (
@@ -28,8 +22,8 @@ export function DishInfo({ dish }: { dish: Dish }) {
- {allergenIcons.length > 0 ? ( - allergenIcons.map(({ icon: Icon, label }) => ( + {tagIcons.length > 0 ? ( + tagIcons.map(({ icon: Icon, label }) => ( }, ]; - // map UI filter values to the dish_filters expected strings - const filterValueMap: Record = { - vegan: 'vegan', - vegetarian: 'vegetarian', - lactoseFree: 'lactose-free', - glutenFree: 'gluten-free', - }; - - const dishFilterStrings = filters.map(f => filterValueMap[f]); + const dishFilterStrings = filters as string[]; const filteredByAllergens: Dish[] = filterDishes(dishes, dishFilterStrings); const normalizedQuery = query.trim().toLowerCase(); const filteredDishes = filteredByAllergens.filter(dish => { @@ -103,29 +95,26 @@ export function DishListComponent({ className="!bg-white dark:!bg-black border-gray-300 dark:border-zinc-700 shadow-sm" /> -
-
-
- {filterOptions.map(option => { - const active = filters.includes(option.value); - - return ( - - ); - })} -
+
+
+ {filterOptions.map(option => { + const active = filters.includes(option.value); + return ( + + ); + })}
diff --git a/app/components/menu/menu_translations.ts b/app/components/menu/menu_translations.ts index f925a43..f90442a 100644 --- a/app/components/menu/menu_translations.ts +++ b/app/components/menu/menu_translations.ts @@ -3,9 +3,14 @@ type TranslateFn = (key: string) => string; const ALLERGEN_KEY_MAP: Record = { GLUTEN: 'menuForm.allergenGluten', LACTOSE: 'menuForm.allergenLactose', - MEAT: 'menuForm.allergenMeat', NUTS: 'menuForm.allergenNuts', SESAME: 'menuForm.allergenSesame', + VEGAN: 'filters.vegan', + VEGETARIAN: 'filters.vegetarian', + ITALIAN: 'filters.italian', + POLISH: 'filters.polish', + ASIAN: 'filters.asian', + FAST_FOOD: 'filters.fastFood', }; const CATEGORY_KEY_MAP: Record = { diff --git a/app/components/overview/filter_bar.tsx b/app/components/overview/filter_bar.tsx index 0f6b5ba..9d48dc4 100644 --- a/app/components/overview/filter_bar.tsx +++ b/app/components/overview/filter_bar.tsx @@ -7,7 +7,17 @@ import { useTranslation } from 'react-i18next'; import { ToggleGroup, ToggleGroupItem } from '~/shadcn/components/ui/toggle-group'; -export type FilterValue = 'vegan' | 'vegetarian' | 'lactoseFree' | 'glutenFree' | 'hasMenuToday'; +export type FilterValue = + | 'vegan' + | 'vegetarian' + | 'lactoseFree' + | 'glutenFree' + | 'nutsFree' + | 'hasMenuToday' + | 'italian' + | 'polish' + | 'asian' + | 'fastFood'; export interface FilterBarProps { value: FilterValue[]; diff --git a/app/components/overview/overview_component.tsx b/app/components/overview/overview_component.tsx index 11bd17d..8f73e53 100644 --- a/app/components/overview/overview_component.tsx +++ b/app/components/overview/overview_component.tsx @@ -16,8 +16,9 @@ import { BottomNav } from '~/components/shared/bottom_nav'; import { SearchBar } from '~/components/overview/search_bar'; import { type FilterValue } from '~/components/overview/filter_bar'; import { useRestaurantItemsWithFilters } from '~/components/overview/service_section/use_restaurant_items_with_filters'; -import { useGetAllRestaurants } from '~/api/restaurant_service'; +import { useGetAllRestaurants, useGetRestaurantRecommendations } from '~/api/restaurant_service'; import { useGetAllVendingMachines } from '~/api/vending_machine_service'; +import { useKeycloak } from '@react-keycloak/web'; import { appRoutes } from '~/lib/app_routes'; import { useRestaurantStore } from '~/store/restaurant_store'; import type { Facility, Restaurant, VendingMachine } from '~/interfaces'; @@ -137,12 +138,16 @@ function VendingMachineCard({ export function OverviewComponent() { const { t } = useTranslation(); const navigate = useNavigate(); + const { keycloak, initialized } = useKeycloak(); const setSelectedRestaurant = useRestaurantStore(state => state.setSelectedRestaurant); const [mode, setMode] = React.useState<'restaurants' | 'vending'>('restaurants'); const [filters, setFilters] = React.useState([]); const [query, setQuery] = React.useState(''); const [selectedPoint, setSelectedPoint] = React.useState(null); + const token = initialized && keycloak.authenticated ? keycloak.token : undefined; + const { data: recommendationsData } = useGetRestaurantRecommendations(token); + const { isPending, error, items } = useRestaurantItemsWithFilters({ carouselItemSource: useGetAllRestaurants, filters, @@ -168,8 +173,18 @@ export function OverviewComponent() { return name.includes(normalizedQuery) || description.includes(normalizedQuery); }); - const openItems = filteredItems.filter(item => item.openNow); - const closedItems = filteredItems.filter(item => !item.openNow); + const scoreMap = React.useMemo(() => { + if (!recommendationsData) return {} as Record; + return Object.fromEntries(recommendationsData.map(r => [r.restaurant.id, r.score])); + }, [recommendationsData]); + + const sortedFilteredItems = React.useMemo(() => { + if (!recommendationsData) return filteredItems; + return [...filteredItems].sort((a, b) => (scoreMap[b.id] ?? 0) - (scoreMap[a.id] ?? 0)); + }, [filteredItems, recommendationsData, scoreMap]); + + const openItems = sortedFilteredItems.filter(item => item.openNow); + const closedItems = sortedFilteredItems.filter(item => !item.openNow); const filteredVendingMachines = (vendingMachines ?? []).filter(vendingMachine => { if (!normalizedQuery) return true; @@ -290,29 +305,26 @@ export function OverviewComponent() { /> {mode === 'restaurants' && ( -
-
-
- {filterOptions.map(option => { - const active = filters.includes(option.value); - - return ( - - ); - })} -
+
+
+ {filterOptions.map(option => { + const active = filters.includes(option.value); + return ( + + ); + })}
)} diff --git a/app/components/overview/service_section/use_restaurant_items_with_filters.ts b/app/components/overview/service_section/use_restaurant_items_with_filters.ts index 5a8b2c3..a0f91f4 100644 --- a/app/components/overview/service_section/use_restaurant_items_with_filters.ts +++ b/app/components/overview/service_section/use_restaurant_items_with_filters.ts @@ -15,6 +15,8 @@ import { hasVegetarianOption, hasLactoseFreeOption, hasGlutenFreeOption, + hasNutsFreeOption, + hasCuisineOption, } from '~/shadcn/lib/dish_filters'; interface UseRestaurantItemsWithFiltersProps { @@ -82,6 +84,16 @@ export function useRestaurantItemsWithFilters({ return hasLactoseFreeOption(menu); case 'glutenFree': return hasGlutenFreeOption(menu); + case 'nutsFree': + return hasNutsFreeOption(menu); + case 'italian': + return hasCuisineOption(menu, 'ITALIAN'); + case 'polish': + return hasCuisineOption(menu, 'POLISH'); + case 'asian': + return hasCuisineOption(menu, 'ASIAN'); + case 'fastFood': + return hasCuisineOption(menu, 'FAST_FOOD'); case 'hasMenuToday': return !!menu && !!menu.dishes && menu.dishes.length > 0; default: @@ -91,5 +103,5 @@ export function useRestaurantItemsWithFilters({ }); } - return { isPending, error, items: filtered }; + return { isPending, error, items: filtered, menusData: menusData ?? {} }; } diff --git a/app/components/profile/profile_component.tsx b/app/components/profile/profile_component.tsx index 9c3e1a8..0d14171 100644 --- a/app/components/profile/profile_component.tsx +++ b/app/components/profile/profile_component.tsx @@ -1,9 +1,178 @@ +import React from 'react'; import { TopBar } from '~/components/shared/top_bar'; import { BottomNav } from '~/components/shared/bottom_nav'; import { useKeycloak } from '@react-keycloak/web'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { appRoutes } from '~/lib/app_routes'; +import { useGetPreferences, useGetCurrentUserWithToken, savePreferences } from '~/api/user_service'; +import type { TagValue } from '~/interfaces'; + +const ALL_TAGS: TagValue[] = [ + 'ITALIAN', + 'POLISH', + 'ASIAN', + 'FAST_FOOD', + 'VEGAN', + 'VEGETARIAN', + 'GLUTEN', + 'LACTOSE', + 'NUTS', + 'SESAME', +]; + +function PreferencesSection({ + token, + userReady, + t, +}: { + token: string | undefined; + userReady: boolean; + t: (key: string) => string; +}) { + const { data: prefsData, isLoading } = useGetPreferences(userReady ? token : undefined); + const [include, setInclude] = React.useState([]); + const [exclude, setExclude] = React.useState([]); + const [saving, setSaving] = React.useState(false); + const [saved, setSaved] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!prefsData) return; + setInclude(prefsData.filter(p => p.preferenceType === 'INCLUDE').map(p => p.tagValue)); + setExclude(prefsData.filter(p => p.preferenceType === 'EXCLUDE').map(p => p.tagValue)); + }, [prefsData]); + + const toggleInclude = (value: TagValue) => { + setInclude(prev => (prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value])); + setExclude(prev => prev.filter(v => v !== value)); + setSaved(false); + }; + + const toggleExclude = (value: TagValue) => { + setExclude(prev => (prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value])); + setInclude(prev => prev.filter(v => v !== value)); + setSaved(false); + }; + + const handleSave = async () => { + if (!token) return; + setSaving(true); + setError(null); + try { + // Ensure user exists in backend DB before saving preferences + const meRes = await fetch('/api/users/me', { headers: { Authorization: `Bearer ${token}` } }); + const meText = await meRes.text().catch(() => ''); + console.log('[preferences] /api/users/me ->', meRes.status, meText.slice(0, 200)); + if (!meRes.ok) { + throw new Error(`/api/users/me failed (${meRes.status}): ${meText}`); + } + await savePreferences(token, [ + ...include.map(tagValue => ({ tagValue, preferenceType: 'INCLUDE' as const })), + ...exclude.map(tagValue => ({ tagValue, preferenceType: 'EXCLUDE' as const })), + ]); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : t('profile.failedToSavePreferences')); + } finally { + setSaving(false); + } + }; + + const tagLabel = (tag: TagValue): string => { + const map: Partial> = { + VEGAN: t('filters.vegan'), + VEGETARIAN: t('filters.vegetarian'), + GLUTEN: t('menuForm.allergenGluten'), + LACTOSE: t('menuForm.allergenLactose'), + NUTS: t('menuForm.allergenNuts'), + SESAME: t('menuForm.allergenSesame'), + ITALIAN: t('profile.tagItalian'), + POLISH: t('profile.tagPolish'), + ASIAN: t('profile.tagAsian'), + FAST_FOOD: t('profile.tagFastFood'), + }; + return map[tag] ?? tag; + }; + + if (isLoading) { + return ( +

{t('profile.loadingPreferences')}

+ ); + } + + return ( +
+
+

+ {t('profile.preferences')} +

+

+ {t('profile.preferencesSubtitle')} +

+
+ +
+

+ {t('profile.iPrefer')} +

+
+ {ALL_TAGS.map(tag => ( + + ))} +
+
+ +
+

+ {t('profile.iAvoid')} +

+
+ {ALL_TAGS.map(tag => ( + + ))} +
+
+ + {error &&

{error}

} + + +
+ ); +} export default function ProfileComponent() { const { keycloak, initialized } = useKeycloak(); @@ -30,6 +199,7 @@ export default function ProfileComponent() { const token = keycloak.tokenParsed; const roles = token?.realm_access?.roles || []; const isOwner = roles.includes('restaurant_owner'); + const { data: backendUser } = useGetCurrentUserWithToken(keycloak.token); if (!token) { return ( @@ -131,6 +301,17 @@ export default function ProfileComponent() { {t('profile.openManagerPanel')} )} + + {!isOwner && ( + <> +
+ + + )}
diff --git a/app/components/staff/menu/common/dish_editor.tsx b/app/components/staff/menu/common/dish_editor.tsx index 65323d3..4f6da95 100644 --- a/app/components/staff/menu/common/dish_editor.tsx +++ b/app/components/staff/menu/common/dish_editor.tsx @@ -1,25 +1,25 @@ import * as React from 'react'; -import { MENU_ALLERGENS, type Dish, type Allergen } from '~/interfaces'; +import { + MENU_DIETARY_TAGS, + MENU_ALLERGEN_TAGS, + MENU_CUISINE_TAGS, + type Dish, + type TagValue, +} from '~/interfaces'; import { useTranslation } from 'react-i18next'; import { translateAllergen, translateCategory } from '~/components/menu/menu_translations'; const CATEGORY_OPTIONS = ['SOUP', 'MAIN_COURSE']; interface DishEditorProps { - dish: Dish & { category?: string }; + dish: Dish & { category?: string; tags?: string[] | any[] }; index: number; onDishChange: (index: number, field: keyof Dish | 'category', value: any) => void; - onAllergenToggle: (index: number, allergen: Allergen) => void; + onTagToggle: (index: number, tag: TagValue) => void; onRemove: (index: number) => void; } -export function DishEditor({ - dish, - index, - onDishChange, - onAllergenToggle, - onRemove, -}: DishEditorProps) { +export function DishEditor({ dish, index, onDishChange, onTagToggle, onRemove }: DishEditorProps) { const { t } = useTranslation(); return ( @@ -82,24 +82,66 @@ export function DishEditor({ />
-
- -
- {MENU_ALLERGENS.map(allergen => { - const selected = (dish.allergens || []).includes(allergen); - return ( - - ); - })} +
+
+

+ {t('menuForm.dietaryTags')} +

+
+ {MENU_DIETARY_TAGS.map(tag => { + const selected = (dish.tags || []).includes(tag); + return ( + + ); + })} +
+
+
+

+ {t('menuForm.allergenTags')} +

+
+ {MENU_ALLERGEN_TAGS.map(tag => { + const selected = (dish.tags || []).includes(tag); + return ( + + ); + })} +
+
+
+

+ {t('menuForm.cuisine')} +

+
+ {MENU_CUISINE_TAGS.map(tag => { + const selected = (dish.tags || []).includes(tag); + return ( + + ); + })} +
diff --git a/app/components/staff/menu/dish_editor.tsx b/app/components/staff/menu/dish_editor.tsx index 76fd8c9..5f062a4 100644 --- a/app/components/staff/menu/dish_editor.tsx +++ b/app/components/staff/menu/dish_editor.tsx @@ -1,25 +1,20 @@ import * as React from 'react'; -import type { Dish, Allergen } from '~/interfaces'; +import type { Dish, TagValue } from '~/interfaces'; +import { MENU_DIETARY_TAGS, MENU_ALLERGEN_TAGS, MENU_CUISINE_TAGS } from '~/interfaces'; import { useTranslation } from 'react-i18next'; +import { translateAllergen } from '~/components/menu/menu_translations'; -const ALLERGEN_OPTIONS: Allergen[] = ['GLUTEN', 'LACTOSE', 'MEAT', 'NUTS']; const CATEGORY_OPTIONS = ['SOUP', 'MAIN_COURSE']; interface DishEditorProps { dish: Dish; index: number; onDishChange: (index: number, field: keyof Dish, value: any) => void; - onAllergenToggle: (index: number, allergen: Allergen) => void; + onTagToggle: (index: number, tag: TagValue) => void; onRemove: (index: number) => void; } -export function DishEditor({ - dish, - index, - onDishChange, - onAllergenToggle, - onRemove, -}: DishEditorProps) { +export function DishEditor({ dish, index, onDishChange, onTagToggle, onRemove }: DishEditorProps) { const { t } = useTranslation(); return ( @@ -82,24 +77,66 @@ export function DishEditor({ />
-
- -
- {ALLERGEN_OPTIONS.map(allergen => { - const selected = (dish.allergens || []).includes(allergen); - return ( - - ); - })} +
+
+

+ {t('menuForm.dietaryTags')} +

+
+ {MENU_DIETARY_TAGS.map(tag => { + const selected = ((dish as any).tags || []).includes(tag); + return ( + + ); + })} +
+
+
+

+ {t('menuForm.allergenTags')} +

+
+ {MENU_ALLERGEN_TAGS.map(tag => { + const selected = ((dish as any).tags || []).includes(tag); + return ( + + ); + })} +
+
+
+

+ {t('menuForm.cuisine')} +

+
+ {MENU_CUISINE_TAGS.map(tag => { + const selected = ((dish as any).tags || []).includes(tag); + return ( + + ); + })} +
diff --git a/app/components/staff/menu/manual_menu/menu_draft.tsx b/app/components/staff/menu/manual_menu/menu_draft.tsx index a17ec42..0df24db 100644 --- a/app/components/staff/menu/manual_menu/menu_draft.tsx +++ b/app/components/staff/menu/manual_menu/menu_draft.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { useKeycloak } from '@react-keycloak/web'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { menuService, type StoredMenuDraft } from '~/api/menu_service'; -import type { DailyMenu, DailyMenuDTO, Dish, Allergen } from '~/interfaces'; +import type { DailyMenu, DailyMenuDTO, Dish, TagValue } from '~/interfaces'; import { LoadingSpinner } from '~/components/staff/common/loading_spinner'; import { ErrorState, EmptyState } from '~/components/staff/common/error_state'; import { StatusBanner } from '~/components/staff/common/status_banner'; @@ -130,17 +130,15 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { }); }; - const handleAllergenToggle = (dishIndex: number, allergen: Allergen) => { + const handleTagToggle = (dishIndex: number, tag: TagValue) => { if (!menu || !menu.dishes) return; - const dish = menu.dishes[dishIndex]; - const allergens = dish.allergens || []; + const dish = menu.dishes[dishIndex] as any; + const tags = dish.tags || []; - const updatedAllergens = allergens.includes(allergen) - ? allergens.filter(a => a !== allergen) - : [...allergens, allergen]; + const updatedTags = tags.includes(tag) ? tags.filter((t: string) => t !== tag) : [...tags, tag]; - handleDishChange(dishIndex, 'allergens', updatedAllergens); + handleDishChange(dishIndex, 'tags' as any, updatedTags); }; const handleRemoveDish = (index: number) => { @@ -187,7 +185,7 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { name: dish.name, category: dish.category ?? 'MAIN_COURSE', price: normalizedPrice, - allergens: dish.allergens ?? [], + tags: (dish as any).tags ?? [], }; if (typeof dish.id === 'string' && isUuid(dish.id)) { @@ -222,7 +220,7 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { name: dish.name, category: dish.category ?? 'MAIN_COURSE', price: typeof dish.price === 'number' ? dish.price : Number(dish.price ?? 0), - allergens: dish.allergens ?? [], + tags: dish.tags ?? [], })), }; @@ -370,7 +368,7 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { dish={dish} index={index} onDishChange={handleDishChange} - onAllergenToggle={handleAllergenToggle} + onTagToggle={handleTagToggle} onRemove={handleRemoveDish} /> ))} diff --git a/app/components/staff/menu/manual_menu/menu_form.tsx b/app/components/staff/menu/manual_menu/menu_form.tsx index 8071f79..ccaae50 100644 --- a/app/components/staff/menu/manual_menu/menu_form.tsx +++ b/app/components/staff/menu/manual_menu/menu_form.tsx @@ -14,7 +14,12 @@ import { import { Checkbox } from '~/shadcn/components/ui/checkbox'; import { Plus, Trash } from 'lucide-react'; import { Alert, AlertTitle, AlertDescription } from '~/shadcn/components/ui/alert'; -import { MENU_ALLERGENS, type DishDTO } from '~/interfaces'; +import { + MENU_DIETARY_TAGS, + MENU_ALLERGEN_TAGS, + MENU_CUISINE_TAGS, + type DishDTO, +} from '~/interfaces'; import { menuService, useSaveMenuDraftWithToken, @@ -82,7 +87,7 @@ export function DailyMenuForm({ }, [searchParams, restaurantId]); const addDish = () => { - setDishes([...dishes, { name: '', category: '', price: 0, allergens: [] }]); + setDishes([...dishes, { name: '', category: '', price: 0, tags: [] }]); }; const updateDish = (index: number, field: keyof DishDTO, value: any) => { @@ -91,12 +96,12 @@ export function DailyMenuForm({ setDishes(updated); }; - const toggleAllergen = (index: number, allergen: string) => { + const toggleTag = (index: number, tag: string) => { const updated = [...dishes]; - const exists = updated[index].allergens.includes(allergen); - updated[index].allergens = exists - ? updated[index].allergens.filter(a => a !== allergen) - : [...updated[index].allergens, allergen]; + const exists = updated[index].tags.includes(tag); + updated[index].tags = exists + ? updated[index].tags.filter(a => a !== tag) + : [...updated[index].tags, tag]; setDishes(updated); }; @@ -344,25 +349,69 @@ export function DailyMenuForm({ />
-
- -
- {MENU_ALLERGENS.map(a => ( - - ))} +
+
+

+ {t('menuForm.dietaryTags')} +

+
+ {MENU_DIETARY_TAGS.map(tag => ( + + ))} +
+
+
+

+ {t('menuForm.allergenTags')} +

+
+ {MENU_ALLERGEN_TAGS.map(tag => ( + + ))} +
+
+
+

+ {t('menuForm.cuisine')} +

+
+ {MENU_CUISINE_TAGS.map(tag => ( + + ))} +
diff --git a/app/components/staff/menu/manual_menu/published_menu_panel.tsx b/app/components/staff/menu/manual_menu/published_menu_panel.tsx index dc932e0..d4bbd4e 100644 --- a/app/components/staff/menu/manual_menu/published_menu_panel.tsx +++ b/app/components/staff/menu/manual_menu/published_menu_panel.tsx @@ -3,7 +3,7 @@ import { useKeycloak } from '@react-keycloak/web'; import { useNavigate } from 'react-router-dom'; import { Plus } from 'lucide-react'; import { menuService, type PublishedMenu } from '~/api/menu_service'; -import type { DailyMenu, Dish, Allergen } from '~/interfaces'; +import type { DailyMenu, Dish, TagValue } from '~/interfaces'; import { LoadingSpinner } from '~/components/staff/common/loading_spinner'; import { ErrorState, EmptyState } from '~/components/staff/common/error_state'; import { StatusBanner } from '~/components/staff/common/status_banner'; @@ -63,7 +63,7 @@ export function PublishedMenuPanel({ restaurantId }: PublishedMenuPanelProps) { dishes: (item.dishes ?? []).map((dish: any) => ({ ...dish, category: dish.category ?? 'MAIN_COURSE', - allergens: Array.isArray(dish.allergens) ? dish.allergens : [], + tags: Array.isArray(dish.tags) ? dish.tags.map((t: any) => t.value ?? t) : [], price: typeof dish.price === 'string' ? dish.price : String(dish.price ?? ''), })), }); @@ -137,17 +137,15 @@ export function PublishedMenuPanel({ restaurantId }: PublishedMenuPanelProps) { }); }; - const handleAllergenToggle = (dishIndex: number, allergen: Allergen) => { + const handleTagToggle = (dishIndex: number, tag: TagValue) => { if (!menu || !menu.dishes) return; - const dish = menu.dishes[dishIndex]; - const allergens = dish.allergens || []; + const dish = menu.dishes[dishIndex] as any; + const tags = dish.tags || []; - const updatedAllergens = allergens.includes(allergen) - ? allergens.filter(a => a !== allergen) - : [...allergens, allergen]; + const updatedTags = tags.includes(tag) ? tags.filter((t: string) => t !== tag) : [...tags, tag]; - handleDishChange(dishIndex, 'allergens', updatedAllergens); + handleDishChange(dishIndex, 'tags' as any, updatedTags); }; const handleRemoveDish = (index: number) => { @@ -174,7 +172,7 @@ export function PublishedMenuPanel({ restaurantId }: PublishedMenuPanelProps) { image: '', category: 'MAIN_COURSE', price: '0', - allergens: [], + tags: [], } as any, ], }); @@ -218,7 +216,7 @@ export function PublishedMenuPanel({ restaurantId }: PublishedMenuPanelProps) { ...dish, category: dish.category ?? 'MAIN_COURSE', price: String(typeof dish.price === 'number' ? dish.price : Number(dish.price ?? 0)), - allergens: dish.allergens ?? [], + tags: (dish as any).tags ?? [], })), }; @@ -375,7 +373,7 @@ export function PublishedMenuPanel({ restaurantId }: PublishedMenuPanelProps) { dish={dish as any} index={index} onDishChange={handleDishChange} - onAllergenToggle={handleAllergenToggle} + onTagToggle={handleTagToggle} onRemove={handleRemoveDish} /> ))} diff --git a/app/components/staff/menu/menu_draft.tsx b/app/components/staff/menu/menu_draft.tsx index 295dfbc..f0c596c 100644 --- a/app/components/staff/menu/menu_draft.tsx +++ b/app/components/staff/menu/menu_draft.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useKeycloak } from '@react-keycloak/web'; import { useNavigate } from 'react-router-dom'; import { menuService } from '~/api/menu_service'; -import type { DailyMenu, Dish, Allergen } from '~/interfaces'; +import type { DailyMenu, Dish, TagValue } from '~/interfaces'; import { LoadingSpinner } from '~/components/staff/common/loading_spinner'; import { ErrorState, EmptyState } from '~/components/staff/common/error_state'; import { StatusBanner } from '~/components/staff/common/status_banner'; @@ -66,17 +66,15 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { }); }; - const handleAllergenToggle = (dishIndex: number, allergen: Allergen) => { + const handleTagToggle = (dishIndex: number, tag: TagValue) => { if (!menu || !menu.dishes) return; - const dish = menu.dishes[dishIndex]; - const allergens = dish.allergens || []; + const dish = menu.dishes[dishIndex] as any; + const tags = dish.tags || []; - const updatedAllergens = allergens.includes(allergen) - ? allergens.filter(a => a !== allergen) - : [...allergens, allergen]; + const updatedTags = tags.includes(tag) ? tags.filter((t: string) => t !== tag) : [...tags, tag]; - handleDishChange(dishIndex, 'allergens', updatedAllergens); + handleDishChange(dishIndex, 'tags' as any, updatedTags); }; const handleRemoveDish = (index: number) => { @@ -145,7 +143,7 @@ export function StaffMenuDraft({ restaurantId }: StaffMenuDraftProps) { dish={dish} index={index} onDishChange={handleDishChange} - onAllergenToggle={handleAllergenToggle} + onTagToggle={handleTagToggle} onRemove={handleRemoveDish} /> ))} diff --git a/app/i18n.ts b/app/i18n.ts index a6ee009..fa7fef3 100644 --- a/app/i18n.ts +++ b/app/i18n.ts @@ -41,7 +41,7 @@ const resources = { restaurants: 'Restaurants', restaurantsTitle: 'Restaurants', restaurantsSubtitle: - 'Explore today’s open spots. Filter based on available dietary options today.', + "Explore today's open spots. Filter based on available dietary options today.", vendingMachines: 'Vending Machines', vendingMachinesTitle: 'Vending Machines', vendingMachinesSubtitle: 'Browse available vending machines and jump straight to the map.', @@ -66,17 +66,24 @@ const resources = { toggleLactoseFree: 'Toggle lactose free', toggleGlutenFree: 'Toggle gluten free', toggleHasMenuToday: 'Toggle menu for today', + nutsFree: 'Nuts-free', + italian: 'Italian', + polish: 'Polish', + asian: 'Asian', + fastFood: 'Fast food', + preferSection: 'I prefer', + avoidSection: 'I avoid', }, menu: { dishesTitle: '{{name}}', - dishesSubtitle: 'Explore today’s dishes and filter by your preferences.', + dishesSubtitle: "Explore today's dishes and filter by your preferences.", loadingMenu: 'Loading menu...', errorLoadingMenu: 'Error loading menu:', noMenuYet: 'This restaurant has not published a menu yet.', noDishes: 'No dishes available for this restaurant', restaurantName: 'Restaurant: {{name}}', restaurantId: 'Restaurant ID: {{id}}', - todayMenuSubtitle: 'Explore today’s dishes and filter by your preferences.', + todayMenuSubtitle: "Explore today's dishes and filter by your preferences.", }, profile: { pageTitle: 'Your profile', @@ -122,6 +129,18 @@ const resources = { errorCreatingRestaurant: 'An error occurred while creating the restaurant.', failedToUpdateRestaurant: 'Failed to update restaurant. Please try again.', errorUpdatingRestaurant: 'An error occurred while updating the restaurant.', + preferences: 'Preferences', + preferencesSubtitle: 'Restaurants will be sorted based on your preferences.', + iPrefer: 'I prefer', + iAvoid: 'I avoid', + savePreferences: 'Save preferences', + preferencesSaved: 'Saved!', + failedToSavePreferences: 'Failed to save preferences. Please try again.', + loadingPreferences: 'Loading preferences…', + tagItalian: 'Italian', + tagPolish: 'Polish', + tagAsian: 'Asian', + tagFastFood: 'Fast food', }, manager: { panelTitle: 'Manager panel', @@ -181,7 +200,10 @@ const resources = { category: 'Category', selectCategory: 'Select category', price: 'Price', - allergens: 'Allergens', + allergens: 'Ingredients', + cuisine: 'Cuisine', + dietaryTags: 'Dietary', + allergenTags: 'Ingredients', allergenGluten: 'Gluten', allergenLactose: 'Lactose', allergenMeat: 'Meat', @@ -341,6 +363,13 @@ const resources = { toggleLactoseFree: 'Przełącz filtr bez laktozy', toggleGlutenFree: 'Przełącz filtr bezglutenowy', toggleHasMenuToday: 'Przełącz ma menu na dziś', + nutsFree: 'Bez orzechów', + italian: 'Włoska', + polish: 'Polska', + asian: 'Azjatycka', + fastFood: 'Fast food', + preferSection: 'Preferuję', + avoidSection: 'Unikam', }, menu: { dishesTitle: '{{name}}', @@ -397,6 +426,18 @@ const resources = { errorCreatingRestaurant: 'Wystąpił błąd podczas tworzenia restauracji.', failedToUpdateRestaurant: 'Nie udało się zaktualizować restauracji. Spróbuj ponownie.', errorUpdatingRestaurant: 'Wystąpił błąd podczas aktualizacji restauracji.', + preferences: 'Preferencje', + preferencesSubtitle: 'Restauracje będą sortowane na podstawie Twoich preferencji.', + iPrefer: 'Preferuję', + iAvoid: 'Unikam', + savePreferences: 'Zapisz preferencje', + preferencesSaved: 'Zapisano!', + failedToSavePreferences: 'Nie udało się zapisać preferencji. Spróbuj ponownie.', + loadingPreferences: 'Ładowanie preferencji…', + tagItalian: 'Włoska', + tagPolish: 'Polska', + tagAsian: 'Azjatycka', + tagFastFood: 'Fast food', }, manager: { panelTitle: 'Panel menedżera', @@ -457,7 +498,10 @@ const resources = { category: 'Kategoria', selectCategory: 'Wybierz kategorię', price: 'Cena', - allergens: 'Alergeny', + allergens: 'Składniki', + cuisine: 'Kuchnia', + dietaryTags: 'Dieta', + allergenTags: 'Składniki', allergenGluten: 'Gluten', allergenLactose: 'Laktoza', allergenMeat: 'Mięso', diff --git a/app/interfaces.tsx b/app/interfaces.tsx index c40c172..4382dd7 100644 --- a/app/interfaces.tsx +++ b/app/interfaces.tsx @@ -41,9 +41,43 @@ export interface Restaurant { openNow: boolean; } -export type Allergen = 'NUTS' | 'GLUTEN' | 'MEAT' | 'LACTOSE' | 'SESAME'; +export type TagType = 'CUISINE' | 'ALLERGEN' | 'DIETARY'; +export type TagValue = + | 'ITALIAN' + | 'POLISH' + | 'ASIAN' + | 'FAST_FOOD' + | 'NUTS' + | 'GLUTEN' + | 'LACTOSE' + | 'SESAME' + | 'VEGAN' + | 'VEGETARIAN'; + +export interface Tag { + id: string; + value: TagValue; + tagType: TagType; + name: string; +} -export const MENU_ALLERGENS: Allergen[] = ['GLUTEN', 'LACTOSE', 'MEAT', 'NUTS', 'SESAME']; +export const MENU_ALLERGENS: TagValue[] = ['GLUTEN', 'LACTOSE', 'NUTS', 'VEGAN', 'VEGETARIAN']; +export const MENU_DIETARY_TAGS: TagValue[] = ['VEGAN', 'VEGETARIAN']; +export const MENU_ALLERGEN_TAGS: TagValue[] = ['GLUTEN', 'LACTOSE', 'NUTS', 'SESAME']; +export const MENU_CUISINE_TAGS: TagValue[] = ['ITALIAN', 'POLISH', 'ASIAN', 'FAST_FOOD']; + +export const TAG_ID_MAP: Record = { + ITALIAN: 'a0000000-0000-0000-0000-000000000001', + POLISH: 'a0000000-0000-0000-0000-000000000002', + ASIAN: 'a0000000-0000-0000-0000-000000000003', + FAST_FOOD: 'a0000000-0000-0000-0000-000000000004', + NUTS: 'a0000000-0000-0000-0000-000000000005', + GLUTEN: 'a0000000-0000-0000-0000-000000000006', + LACTOSE: 'a0000000-0000-0000-0000-000000000007', + SESAME: 'a0000000-0000-0000-0000-000000000010', + VEGAN: 'a0000000-0000-0000-0000-000000000008', + VEGETARIAN: 'a0000000-0000-0000-0000-000000000009', +}; export interface DailyMenu { id: string; @@ -64,6 +98,11 @@ export interface VendingMachine { export type Facility = Restaurant | VendingMachine; +export interface RankedRestaurant { + restaurant: Restaurant; + score: number; +} + export interface FacilityInfoProps { selectedPoint: Facility | null; onClose: () => void; @@ -77,7 +116,7 @@ export interface Dish { category?: string; price: string; image: string; - allergens: string[]; + tags: Tag[]; } export interface DishDTO { @@ -85,7 +124,7 @@ export interface DishDTO { name: string; category: string; price: number; - allergens: string[]; + tags: string[]; } export interface DailyMenuDTO { diff --git a/app/shadcn/lib/dish_filters.ts b/app/shadcn/lib/dish_filters.ts index 94e15ad..2c6101b 100644 --- a/app/shadcn/lib/dish_filters.ts +++ b/app/shadcn/lib/dish_filters.ts @@ -1,45 +1,56 @@ -import type { DailyMenu, Dish } from "~/interfaces"; +import type { DailyMenu, Dish } from '~/interfaces'; -function normalizeAllergens(dish: Dish): string[] { - return (dish.allergens ?? []).map((a) => String(a).toLowerCase()); +function hasTag(dish: Dish, value: string): boolean { + return (dish.tags ?? []).some(t => t.value === value); } export function isVeganDish(dish: Dish): boolean { - const allergens = normalizeAllergens(dish); - if (allergens.length === 0) return true; - return !allergens.includes("meat") && !allergens.includes("lactose"); + return hasTag(dish, 'VEGAN'); } export function isVegetarianDish(dish: Dish): boolean { - const allergens = normalizeAllergens(dish); - if (allergens.length === 0) return true; - return !allergens.includes("meat"); + return hasTag(dish, 'VEGETARIAN'); } export function isLactoseFreeDish(dish: Dish): boolean { - const allergens = normalizeAllergens(dish); - if (allergens.length === 0) return true; - return !allergens.includes("lactose"); + return !hasTag(dish, 'LACTOSE'); } export function isGlutenFreeDish(dish: Dish): boolean { - const allergens = normalizeAllergens(dish); - if (allergens.length === 0) return true; - return !allergens.includes("gluten"); + return !hasTag(dish, 'GLUTEN'); +} + +export function isNutsFreeDish(dish: Dish): boolean { + return !hasTag(dish, 'NUTS'); +} + +export function hasCuisineTag(dish: Dish, cuisine: string): boolean { + return hasTag(dish, cuisine); } export function matchesDishFilter(dish: Dish, filter: string): boolean { switch (filter) { - case "vegan": + case 'vegan': return isVeganDish(dish); - case "vegetarian": + case 'vegetarian': return isVegetarianDish(dish); - case "lactose-free": - case "lactoseFree": + case 'lactose-free': + case 'lactoseFree': return isLactoseFreeDish(dish); - case "gluten-free": - case "glutenFree": + case 'gluten-free': + case 'glutenFree': return isGlutenFreeDish(dish); + case 'nuts-free': + case 'nutsFree': + return isNutsFreeDish(dish); + case 'italian': + return hasCuisineTag(dish, 'ITALIAN'); + case 'polish': + return hasCuisineTag(dish, 'POLISH'); + case 'asian': + return hasCuisineTag(dish, 'ASIAN'); + case 'fastFood': + return hasCuisineTag(dish, 'FAST_FOOD'); default: return true; } @@ -51,7 +62,7 @@ export function filterDishes( ): Dish[] { if (!dishes) return []; if (!filters || filters.length === 0) return dishes; - return dishes.filter((d) => filters.every((f) => matchesDishFilter(d, f))); + return dishes.filter(d => filters.every(f => matchesDishFilter(d, f))); } export function hasVeganOption(menu: DailyMenu | null): boolean { @@ -72,4 +83,14 @@ export function hasLactoseFreeOption(menu: DailyMenu | null): boolean { export function hasGlutenFreeOption(menu: DailyMenu | null): boolean { if (!menu?.dishes) return false; return menu.dishes.some(isGlutenFreeDish); -} \ No newline at end of file +} + +export function hasNutsFreeOption(menu: DailyMenu | null): boolean { + if (!menu?.dishes) return false; + return menu.dishes.some(isNutsFreeDish); +} + +export function hasCuisineOption(menu: DailyMenu | null, cuisine: string): boolean { + if (!menu?.dishes) return false; + return menu.dishes.some(d => hasCuisineTag(d, cuisine)); +} diff --git a/app/shadcn/lib/restaurant_filters.ts b/app/shadcn/lib/restaurant_filters.ts index 5dcea72..0791a9f 100644 --- a/app/shadcn/lib/restaurant_filters.ts +++ b/app/shadcn/lib/restaurant_filters.ts @@ -1,42 +1,41 @@ -import type { DailyMenu, Dish } from "~/interfaces" +import type { DailyMenu, Dish } from '~/interfaces'; + +function hasTag(dish: Dish, value: string): boolean { + return (dish.tags ?? []).some(t => t.value === value); +} function isVeganDish(dish: Dish): boolean { - if (!dish.allergens || dish.allergens.length === 0) return true - return !dish.allergens.includes("MEAT") && - !dish.allergens.includes("LACTOSE") + return hasTag(dish, 'VEGAN'); } function isVegetarianDish(dish: Dish): boolean { - if (!dish.allergens || dish.allergens.length === 0) return true - return !dish.allergens.includes("MEAT") + return hasTag(dish, 'VEGETARIAN'); } function isLactoseFreeDish(dish: Dish): boolean { - if (!dish.allergens || dish.allergens.length === 0) return true - return !dish.allergens.includes("LACTOSE") + return !hasTag(dish, 'LACTOSE'); } function isGlutenFreeDish(dish: Dish): boolean { - if (!dish.allergens || dish.allergens.length === 0) return true - return !dish.allergens.includes("GLUTEN") + return !hasTag(dish, 'GLUTEN'); } export function hasVeganOption(menu: DailyMenu | null): boolean { - if (!menu?.dishes) return false - return menu.dishes.some(isVeganDish) + if (!menu?.dishes) return false; + return menu.dishes.some(isVeganDish); } export function hasVegetarianOption(menu: DailyMenu | null): boolean { - if (!menu?.dishes) return false - return menu.dishes.some(isVegetarianDish) + if (!menu?.dishes) return false; + return menu.dishes.some(isVegetarianDish); } export function hasLactoseFreeOption(menu: DailyMenu | null): boolean { - if (!menu?.dishes) return false - return menu.dishes.some(isLactoseFreeDish) + if (!menu?.dishes) return false; + return menu.dishes.some(isLactoseFreeDish); } export function hasGlutenFreeOption(menu: DailyMenu | null): boolean { - if (!menu?.dishes) return false - return menu.dishes.some(isGlutenFreeDish) + if (!menu?.dishes) return false; + return menu.dishes.some(isGlutenFreeDish); } diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html new file mode 100644 index 0000000..b80af7f --- /dev/null +++ b/public/silent-check-sso.html @@ -0,0 +1,8 @@ + + + + + +