diff --git a/package.json b/package.json index 9055436..908e576 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kanttiinit-web", - "version": "11.0.3", + "version": "12.0.0", "description": "Kanttiinit.fi web client.", "main": "index.js", "engines": { diff --git a/src/api.ts b/src/api.ts index 386b759..6b48859 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,34 +12,50 @@ import { type Update, } from './types'; +const coursesCache = new Map(); + +const coursesCacheKey = ( + restaurantId: number | string, + dateStr: string, + lang: string, +) => `${restaurantId}-${dateStr}-${lang}`; + export const getCourses = async ( restaurantId: number, day: Date, lang: Lang, ): Promise => { + const dateStr = format(day, 'y-MM-dd'); + const key = coursesCacheKey(restaurantId, dateStr, lang); + if (coursesCache.has(key)) { + return coursesCache.get(key) as CourseType[]; + } const restaurant = await http.get( - `/restaurants/${restaurantId}/menu?day=${format( - day, - 'y-MM-dd', - )}&lang=${lang}`, + `/restaurants/${restaurantId}/menu?day=${dateStr}&lang=${lang}`, ); - if (!restaurant.menus.length) { - return []; - } else { - return restaurant.menus[0].courses; - } + const courses: CourseType[] = restaurant.menus.length + ? restaurant.menus[0].courses + : []; + coursesCache.set(key, courses); + return courses; }; -export const getMenus = ( +export const getMenus = async ( restaurantIds: number[], days: Date[], lang: string, ): Promise => { - return http.get( + const result: MenuType = await http.get( `/menus?lang=${lang}&restaurants=${restaurantIds.join( ',', )}&days=${days.map(day => format(day, 'y-MM-dd')).join(',')}`, ); + for (const [restaurantId, dates] of Object.entries(result)) { + for (const [dateStr, courses] of Object.entries(dates)) { + coursesCache.set(coursesCacheKey(restaurantId, dateStr, lang), courses); + } + } + return result; }; export const sendFeedback = (message: string, email: string) => diff --git a/src/components/AreaSelector.tsx b/src/components/AreaSelector.tsx index 5d0249a..b19211e 100644 --- a/src/components/AreaSelector.tsx +++ b/src/components/AreaSelector.tsx @@ -3,7 +3,6 @@ import { css, styled } from 'solid-styled-components'; import { FilledStarIcon, WalkIcon } from '../icons'; import { computedState, resources, setState, state } from '../state'; import type allTranslations from '../translations'; -import Button from './Button'; const iconStyles = css` margin-right: 0.5ch; @@ -37,52 +36,71 @@ const specialAreas: SpecialArea[] = [ }, ]; -const AreaWrapper = styled.div` - width: 50%; - box-sizing: border-box; -`; - -const AreaButton = styled(Button)<{ selected: boolean }>` - background-color: ${props => - props.selected ? 'var(--gray6)' : 'transparent'}; - color: inherit; +const AreaButton = styled.button<{ selected: boolean }>` width: 100%; - padding: 1em 0.5em; - border-radius: 4px; - font-weight: inherit; - text-align: center; - outline: none; - color: ${props => (props.selected ? 'var(--accent_color)' : 'var(--gray1)')}; + border-radius: var(--radius-sm); + padding: 0.6em 0.75em; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + font-size: 0.875rem; + font-family: inherit; + font-weight: ${props => (props.selected ? '500' : 'inherit')}; + background: ${props => (props.selected ? 'var(--bg-interactive)' : 'transparent')}; + color: ${props => (props.selected ? 'var(--accent_color)' : 'var(--text-primary)')}; + border: none; cursor: pointer; + transition: background 0.1s, color 0.1s; + + &:hover { + background: var(--bg-interactive); + } - &:hover, &:focus { - background: var(--gray6); + outline: 2px solid var(--accent_color); + outline-offset: -2px; } `; +const Checkmark = styled.span` + color: var(--accent_color); + font-size: 0.9rem; + font-weight: 600; +`; + +const Divider = styled.hr` + border: none; + border-top: 1px solid var(--border-subtle); + margin: 4px 8px; +`; + const Area = (props: { area: { icon?: JSX.Element; label: JSX.Element; id: number }; selectedAreaId: number; selectArea: (id: number) => void; -}) => ( - +}) => { + const selected = () => props.selectedAreaId === props.area.id; + return ( e.key === 'Enter' && props.selectArea(props.area.id) } onMouseUp={() => props.selectArea(props.area.id)} - selected={props.selectedAreaId === props.area.id} + selected={selected()} > - {props.area.icon && ( -
- {props.area.icon} -
- )} - {props.area.label} + + {props.area.icon && ( + + {props.area.icon} + + )} + {props.area.label} + + {selected() && }
-
-); + ); +}; interface Props { onAreaSelected?: () => void; @@ -90,11 +108,10 @@ interface Props { const Container = styled.menu` margin: 0; - padding: 0; + padding: 4px 0; display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; + flex-direction: column; + gap: 2px; user-select: none; `; @@ -123,7 +140,12 @@ export default function AreaSelector(props: Props) { /> )} - (a.name > b.name ? -1 : 1))}> + + (a.name > b.name ? 1 : -1))} + > {area => ( ` border: none; padding: 0.8em 1.2em; - border-radius: 0.4em; + border-radius: var(--radius-md); font-family: inherit; font-size: 0.8rem; display: inline-block; - text-transform: uppercase; min-width: 4rem; background: ${props => props.color === 'secondary' || props.secondary - ? 'var(--gray3)' + ? 'var(--text-muted)' : 'var(--accent_color)'}; text-align: center; - color: var(--gray6); - outline: none; - font-weight: 500; - transition: transform 0.1s; + color: white; + font-weight: 600; + letter-spacing: 0.01em; + transition: transform 0.1s, box-shadow 0.1s; opacity: 0.95; &:hover { opacity: 1; + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } &:active { - transform: scale(0.98); + transform: translateY(0) scale(0.98); + box-shadow: none; } &:focus { - color: var(--gray6); + color: white; + outline: 2px solid var(--accent_color); + outline-offset: 2px; } &:disabled { diff --git a/src/components/ChangeLog.tsx b/src/components/ChangeLog.tsx index 833ed2a..fb7103a 100644 --- a/src/components/ChangeLog.tsx +++ b/src/components/ChangeLog.tsx @@ -14,13 +14,13 @@ import PageContainer from './PageContainer'; const UpdateWrapper = styled.div` margin-bottom: 0.5em; display: flex; - color: var(--gray2); + color: var(--text-secondary); padding: 0.5em; border-radius: 0.2em; cursor: pointer; &:hover { - background: var(--gray5); + background: var(--bg-interactive); } &:last-child { @@ -47,7 +47,7 @@ const PublishedAt = styled.p` text-transform: uppercase; font-weight: 500; margin: 0 0 0; - color: var(--gray2) !important; + color: var(--text-secondary) !important; `; const ArrowDownIcon = styled(CaretDownIcon)<{ isVisible: boolean }>` diff --git a/src/components/Contact.tsx b/src/components/Contact.tsx index 2507ab9..d10ca57 100644 --- a/src/components/Contact.tsx +++ b/src/components/Contact.tsx @@ -1,3 +1,4 @@ +import { createSignal } from 'solid-js'; import { computedState } from '../state'; import { useFeedback } from '../utils'; import Button from './Button'; @@ -6,6 +7,7 @@ import PageContainer from './PageContainer'; const Contact = () => { const [feedback, send] = useFeedback(); + const [acknowledged, setAcknowledged] = createSignal(false); const onSubmit = (e: any) => { e.preventDefault(); @@ -17,6 +19,15 @@ const Contact = () => { {feedback.sent ? ( computedState.translations().thanksForFeedback + ) : !acknowledged() ? ( + <> +

+ {computedState.translations().tosShort} +

+ + ) : (
{ label={computedState.translations().message} rows={10} /> -

- {computedState.translations().tosShort} -

+ {form => ( <> diff --git a/src/components/Restaurant/PlaceHolder.tsx b/src/components/Restaurant/PlaceHolder.tsx index 7a68e33..8562221 100644 --- a/src/components/Restaurant/PlaceHolder.tsx +++ b/src/components/Restaurant/PlaceHolder.tsx @@ -18,12 +18,12 @@ const PlaceholderContainer = styled(Container)` const AnimationBase = styled.div<{ width: number }>` animation: ${animation} 1.2s ease-in-out infinite; - background-color: var(--gray6); + background-color: var(--bg-interactive); background-image: linear-gradient( 90deg, - var(--gray6), - var(--gray6), - var(--gray6) + var(--bg-interactive), + var(--bg-interactive), + var(--bg-interactive) ); background-size: 200px 100%; background-repeat: no-repeat; diff --git a/src/components/Restaurant/Restaurant.tsx b/src/components/Restaurant/Restaurant.tsx index 1c6319c..7361bbd 100644 --- a/src/components/Restaurant/Restaurant.tsx +++ b/src/components/Restaurant/Restaurant.tsx @@ -55,10 +55,10 @@ const Distance = (props: { distance?: number }) => { export const Container = styled.article<{ noCourses?: boolean }>` display: flex; flex-direction: column; - background-color: var(--gray7); + background-color: var(--bg-surface); padding: 1.2rem; border-radius: 8px; - border: solid 1px var(--gray5); + border: solid 1px var(--border-subtle); box-shadow: 0px 1px 2px 0px rgba(50, 50, 50, 0.1); box-sizing: border-box; width: calc(25% - 0.5rem); @@ -128,7 +128,7 @@ export const Container = styled.article<{ noCourses?: boolean }>` margin: 0.25rem 0; border-left: none; border-right: none; - border-color: var(--gray5); + border-color: var(--border-subtle); border-radius: 0; } @@ -145,10 +145,10 @@ export const Header = styled.header` const RestaurantName = styled.h2<{ noCourses?: boolean; isClosed?: boolean }>` color: ${props => props.isClosed - ? 'var(--gray1)' + ? 'var(--text-primary)' : props.noCourses - ? 'var(--gray3)' - : 'var(--gray1)'}; + ? 'var(--text-muted)' + : 'var(--text-primary)'}; font-weight: 500; margin: 0; max-width: 60%; @@ -160,7 +160,7 @@ const RestaurantName = styled.h2<{ noCourses?: boolean; isClosed?: boolean }>` `; const RestaurantMeta = styled.section` - color: var(--gray2); + color: var(--text-secondary); font-size: 0.8rem; font-weight: 500; text-align: right; @@ -171,7 +171,7 @@ const RestaurantMeta = styled.section` `; const ActionsContainer = styled.section` - color: var(--gray4); + color: var(--text-disabled); font-size: 0.75em; text-transform: uppercase; font-weight: 500; @@ -203,6 +203,30 @@ const StyledActionLink = styled(Link)` } `; +const EditLink = styled(Link)` + && { + display: inline-flex; + align-items: center; + gap: 0.3em; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-full); + padding: 0.22em 0.55em 0.22em 0.35em; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 500; + text-transform: none; + + &:hover, + &:focus { + outline: none; + color: var(--accent_color); + border-color: var(--accent_color); + } + } +`; + +const EditLabel = styled.span``; + const StyledNativeActionLink = styled.a<{ color: string }>` ${actionLinkStyles} color: ${props => props.color} !important; @@ -214,13 +238,13 @@ export const courseListStyles = ` flex: 1; min-height: 5rem; font-size: 0.9em; - color: var(--gray1); + color: var(--text-primary); margin-bottom: 0.5rem; `; const StyledCourseList = styled(CourseList)<{ noCourses?: boolean }>` ${courseListStyles} - ${props => (props.noCourses ? 'var(--gray3)' : '')} + ${props => (props.noCourses ? 'var(--text-muted)' : '')} `; const ClosedText = styled.small` @@ -305,12 +329,13 @@ const Restaurant = (props: Props) => { - - - + + {computedState.translations().fixInfo} + { const params = useParams(); const [restaurant] = createResource( @@ -127,84 +166,102 @@ const RestaurantModal = () => { return ( + + + } > - {restaurant => ( - - - - - - - - {restaurant.address} - - - - - - {computedState.translations().homepage} - + + } + > + {restaurant => ( + +
- + + + + + + {restaurant.address} + + + + + + {computedState.translations().homepage} + + + + +
-
- - - {hours => { - const startDate = formattedDay( - setIsoDay(new Date(), hours.startDay + 1), - 'EEEEEE', - ); - const endDate = formattedDay( - setIsoDay(new Date(), (hours.endDay || 0) + 1), - 'EEEEEE', - ); - return ( - - - {startDate()} - {hours.endDay && ( - -  –  - {endDate()} - - )} - - - {hours.hour.replace('-', '–') || - computedState.translations().closed} - - - ); - }} - - -
- - -
- )} + + + {hours => { + const startDate = formattedDay( + setIsoDay(new Date(), hours.startDay + 1), + 'EEEEEE', + ); + const endDate = formattedDay( + setIsoDay(new Date(), (hours.endDay || 0) + 1), + 'EEEEEE', + ); + return ( + + + {startDate()} + {hours.endDay && ( + +  –  + {endDate()} + + )} + + + {hours.hour.replace('-', '–') || + computedState.translations().closed} + + + ); + }} + + + + + + + + + + + )} +
); }; diff --git a/src/components/RoundedButton.tsx b/src/components/RoundedButton.tsx index aa9942e..6825bed 100644 --- a/src/components/RoundedButton.tsx +++ b/src/components/RoundedButton.tsx @@ -31,20 +31,20 @@ export const RoundedButton = styled.button<{ flex-grow: 1; background: transparent; border: 1px solid ${props => props.color}; - border-radius: 1rem; + border-radius: var(--radius-full); transition: background 0.1s, color 0.1s; - padding: 0.4rem 0.8rem 0.45rem; - outline: none; + padding: 0.35rem 0.75rem 0.4rem; &:focus { - background: var(--gray5); + outline: 2px solid currentColor; + outline-offset: 1px; } ${props => props.selected ? ` background: ${props.color}; - color: var(--gray6); + color: white; &:focus { filter: brightness(115%); diff --git a/src/components/Settings/PriceCategorySelector.tsx b/src/components/Settings/PriceCategorySelector.tsx index 5fa86eb..f07fc81 100644 --- a/src/components/Settings/PriceCategorySelector.tsx +++ b/src/components/Settings/PriceCategorySelector.tsx @@ -1,10 +1,7 @@ -import { For } from 'solid-js'; -import { styled } from 'solid-styled-components'; -import { MoneyIcon } from '../../icons'; -import { state } from '../../state'; +import { computedState, state } from '../../state'; import { priceCategorySettings } from '../../translations'; import { PriceCategory } from '../../types'; -import { Button } from '../Radio'; +import Radio from '../Radio'; type Props = { value: PriceCategory; @@ -17,58 +14,17 @@ const categories = [ PriceCategory.regular, ]; -const ButtonContainer = styled.div` - border: solid 2px var(--accent_color); - border-radius: 4px; - display: inline-block; - overflow: hidden; -`; - -const Item = styled(Button)` - border-radius: 0; - color: ${props => (props.selected ? 'var(--gray7)' : 'var(--accent_color)')}; - min-width: 3rem; - padding-top: 0.6rem; - - svg { - font-size: 1rem; - - &:first-child { - margin-left: 0; - } - } - - :first-child { - border-radius: 1em 0 0 1em; - } - - :last-child { - border-radius: 0 1em 1em 0; - border-right: none; - } - - :focus { - background: var(--accent_color); - border-color: transparent; - } -`; - const PriceCategorySelector = (props: Props) => { - const valueIndex = () => categories.indexOf(props.value); return ( <> - - - {(c, i) => ( - props.onChange(c)} - selected={valueIndex() >= i()} - > - {() => } - - )} - - + ({ + label: computedState.translations()[c], + value: c, + }))} + selected={props.value} + onChange={props.onChange} + />

{priceCategorySettings[props.value][state.preferences.lang]}

diff --git a/src/components/Settings/PropertySelector.tsx b/src/components/Settings/PropertySelector.tsx index e0a8538..ce4103f 100644 --- a/src/components/Settings/PropertySelector.tsx +++ b/src/components/Settings/PropertySelector.tsx @@ -28,7 +28,7 @@ export default function PropertySelector(props: { getArrayWithToggled(state.preferences.properties, p.key), ) } - color={p.desired ? 'var(--friendly)' : 'var(--gray3)'} + color={p.desired ? 'var(--friendly)' : 'var(--text-muted)'} selected={isPropertySelected(p.key)} > {state.preferences.lang === 'fi' ? p.name_fi : p.name_en} diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index 989ebfa..69b8c35 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -1,5 +1,6 @@ import type { JSXElement } from 'solid-js'; import { styled } from 'solid-styled-components'; +import { breakSmall } from '../../globalStyles'; import { computedState, setState, state } from '../../state'; import { DarkModeChoice, HighlighOperator, Lang, Order } from '../../types'; import FavoriteSelector from '../FavoriteSelector'; @@ -9,25 +10,41 @@ import Toggle from '../Toggle'; import PriceCategorySelector from './PriceCategorySelector'; import PropertySelector from './PropertySelector'; -interface ItemProps { - label: JSXElement; - children: JSXElement; -} +const SettingsCard = styled.div` + background: var(--bg-surface); + border-radius: var(--radius-lg); + border: solid 1px var(--border-subtle); + box-shadow: 0px 1px 2px 0px rgba(50, 50, 50, 0.1); + padding: 0.25rem 0; + margin-bottom: 0.75rem; + overflow: hidden; +`; -const ItemTitle = styled.h2` - font-size: 0.8rem; - text-transform: uppercase; - font-weight: 500; - color: var(--gray3); - margin-top: 2em; +const SettingsRow = styled.div<{ column?: boolean; mobileColumn?: boolean }>` + display: flex; + align-items: ${props => (props.column ? 'flex-start' : 'center')}; + flex-direction: ${props => (props.column ? 'column' : 'row')}; + justify-content: space-between; + padding: 0.75rem 1rem; + gap: 1rem; + + & + & { + border-top: 1px solid var(--border-subtle); + } + + @media (max-width: ${breakSmall}) { + ${props => + props.mobileColumn + ? 'flex-direction: column; align-items: flex-start;' + : ''} + } `; -const Item = (props: ItemProps) => ( - <> - {props.label} - {props.children} - -); +const RowLabel = styled.span` + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; +`; const orders = [Order.AUTOMATIC, Order.ALPHABET, Order.DISTANCE]; @@ -36,85 +53,114 @@ const languageOptions = [ { label: 'English', value: Lang.EN }, ]; +interface RowProps { + label: JSXElement; + children: JSXElement; + column?: boolean; + mobileColumn?: boolean; +} + +const Row = (props: RowProps) => ( + + {props.label} + {props.children} + +); + const Settings = () => { return ( - - setState('preferences', 'lang', value)} - /> - - - setState('preferences', 'maxPriceCategory', value)} - /> - - - setState('preferences', 'darkMode', value)} - /> - - - ({ - label: computedState.translations()[order], - value: order, - }))} - selected={state.preferences.order} - onChange={value => setState('preferences', 'order', value)} - /> - - - setState('preferences', 'useLocation', value)} - /> - - - - - - - setState('preferences', 'highlightOperator', value) - } - /> - - - - - - - + + + setState('preferences', 'lang', value)} + /> + + + setState('preferences', 'darkMode', value)} + /> + + + + + ({ + label: computedState.translations()[order], + value: order, + }))} + selected={state.preferences.order} + onChange={value => setState('preferences', 'order', value)} + /> + + + setState('preferences', 'useLocation', value)} + /> + + + + + + setState('preferences', 'maxPriceCategory', value) + } + /> + + + + + + + + + setState('preferences', 'highlightOperator', value) + } + /> + + + + + + + + + + ); }; diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx index 74256af..98db9e1 100644 --- a/src/components/Toggle.tsx +++ b/src/components/Toggle.tsx @@ -5,58 +5,56 @@ interface Props { selected: boolean; } -const StyledToggle = styled.span<{ switchedOn: boolean }>` +const Track = styled.button` position: relative; - display: inline-block; - border: 2px solid var(--gray3); - background: var(--gray3); - border-radius: 20px; - padding: 10px 0; - min-width: 4em; - min-height: 1em; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - outline: none; - - &:focus { - background: var(--gray1); + display: inline-flex; + align-items: center; + width: 44px; + height: 26px; + border-radius: 13px; + border: none; + padding: 0; + cursor: pointer; + flex-shrink: 0; + background: var(--border); + transition: background 0.2s ease; + + &[data-on] { + background: var(--accent_color); } - &:after { - cursor: pointer; - position: absolute; - background: var(--gray6); - box-sizing: border-box; - top: 50%; - border-radius: 30px; - margin: 0.1em; - margin-top: -25%; - width: 2em; - height: 2em; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - content: ''; - ${props => (props.switchedOn ? 'margin-left: 1.9em;' : '')} + &:focus-visible { + outline: 2px solid var(--accent_color); + outline-offset: 2px; } +`; - ${props => - props.switchedOn - ? ` - background: var(--accent_color); - border-color: var(--accent_color); - - &:focus { - background: var(--accent_color); - filter: brightness(120%); - } - ` - : ''} +const Knob = styled.span` + position: absolute; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); + pointer-events: none; `; const Toggle = (props: Props) => ( - props.onChange(!props.selected)} - /> + onKeyDown={(e: KeyboardEvent) => + e.key === 'Enter' && props.onChange(!props.selected) + } + > + + ); export default Toggle; diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 2e50570..5881dd7 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -13,8 +13,8 @@ import translations from '../translations'; const Container = styled.div` font-size: 0.8rem; position: absolute; - background: var(--gray2); - color: var(--gray7); + background: var(--text-secondary); + color: var(--bg-surface); padding: 0.25rem 0.5rem; margin: 1em; z-index: 99999; diff --git a/src/components/TopBar/TopBar.tsx b/src/components/TopBar/TopBar.tsx index 01d584c..2ed4bd0 100644 --- a/src/components/TopBar/TopBar.tsx +++ b/src/components/TopBar/TopBar.tsx @@ -1,8 +1,8 @@ -import { createSignal, onCleanup, onMount } from 'solid-js'; +import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { styled } from 'solid-styled-components'; import { breakSmall } from '../../globalStyles'; -import { MapIcon, NewsIcon, SettingsIcon } from '../../icons'; -import { computedState, setState, state } from '../../state'; +import { CaretDownIcon, MapIcon, NewsIcon, SettingsIcon } from '../../icons'; +import { computedState, resources, setState, state } from '../../state'; import { Lang } from '../../types'; import AreaSelector from '../AreaSelector'; import ClickOutside from '../ClickOutside'; @@ -12,25 +12,24 @@ import Link from '../Link'; import EN from './en.svg'; import FI from './fi.svg'; -const Container = styled.header<{ darkMode: boolean }>` - background: linear-gradient(to bottom, var(--gray6) 0%, var(--gray7) 100%); +const Container = styled.header` + background: var(--topbar-bg); + backdrop-filter: blur(16px) saturate(1.8); + -webkit-backdrop-filter: blur(16px) saturate(1.8); + border-bottom: 1px solid var(--topbar-border); box-sizing: border-box; padding: 0 0.5em; position: fixed; width: 100%; z-index: 10; - color: var(--gray3); - border-bottom: 1px solid var(--gray5); + color: var(--text-muted); user-select: none; @media (max-width: ${breakSmall}) { - background-color: var(--gray7); justify-content: flex-start; padding: 0.2em; padding-left: 1rem; } - - ${props => (props.darkMode ? 'background: var(--gray7);' : '')} `; const Content = styled.div` @@ -56,38 +55,109 @@ const AreaSelectorButton = styled(ClickOutside)` const AreaSelectorContainer = styled.div<{ isOpen: boolean }>` position: absolute; right: 0; - top: 32px; - width: 20em; - background: var(--gray7); + top: 36px; + width: 22em; + background: var(--bg-surface); padding: 0.4em; - box-shadow: 0rem 0.1rem 0.6rem -0.2rem rgba(0, 0, 0, 0.3); - border-radius: 4px; - border: solid 1px var(--gray5); + box-shadow: var(--shadow-popover); + border-radius: var(--radius-md); + transform-origin: top right; + + /* Closed — fast snappy dismiss */ opacity: 0; - transition: opacity 75ms; + transform: translateY(-2px) scaleX(0.96) scaleY(0.5); pointer-events: none; + transition: opacity 0.08s ease-in, transform 0.1s ease-in; + /* Open — spring entry animation */ ${props => props.isOpen - ? `opacity: 1; - pointer-events: all; - ` + ? ` + pointer-events: all; + animation: + popoverFadeIn 0.06s ease-out both, + popoverSpringIn 0.25s cubic-bezier(0.34, 1.2, 0.64, 1) both; + ` : ''} + @keyframes popoverFadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes popoverSpringIn { + from { transform: translateY(-2px) scaleX(0.96) scaleY(0.5); } + to { transform: translateY(0) scaleX(1) scaleY(1); } + } + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + transform: none !important; + transition: opacity 0.15s ease-out; + opacity: ${props => (props.isOpen ? 1 : 0)}; + } + @media (max-width: ${breakSmall}) { + transform-origin: top center; top: 52px; + left: 0.5rem; + right: 0.5rem; + width: auto; + border: 1px solid var(--border-subtle); + } +`; + +const MobileOverlay = styled.div<{ open: boolean }>` + display: none; + + @media (max-width: ${breakSmall}) { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + padding-bottom: 2.5rem; + position: fixed; + top: 0; left: 0; - width: 100%; - box-sizing: border-box; + right: 0; + bottom: 0; + z-index: 9; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-out; + + ${props => (props.open ? 'opacity: 1; pointer-events: auto;' : '')} } `; -const iconLinkStyles = ` +const MobileOverlayClose = styled.div` + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; + padding: 0.6rem 1.5rem; + border-radius: var(--radius-full); + background: var(--bg-surface); + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-subtle); +`; + +const PopoverHeader = styled.div` text-transform: uppercase; + font-size: 0.65rem; + color: var(--text-disabled); + padding: 0.5rem 0.75rem 0.25rem; + letter-spacing: 0.06em; +`; + +const iconLinkStyles = ` font-weight: 500; font-size: 0.8rem; display: flex; align-items: center; + gap: 0.3em; padding: 0 1em; :last-child { @@ -121,6 +191,19 @@ const NativeIconLink = styled.a` ${iconLinkStyles} `; +const AreaTriggerLabel = styled.span` + @media (max-width: ${breakSmall}) { + display: none; + } +`; + +const CaretIcon = styled(CaretDownIcon)<{ open: boolean }>` + display: block !important; + font-size: 0.7rem; + transition: transform 0.15s ease-out; + ${props => (props.open ? 'transform: rotate(180deg);' : '')} +`; + const FlagImg = styled.img` cursor: pointer; height: 16px; @@ -196,46 +279,68 @@ export default function TopBar() { ); } + const [areas] = resources.areas; + + const currentAreaLabel = createMemo(() => { + const t = computedState.translations(); + const selected = state.preferences.selectedArea; + if (selected === -2) return t.nearby; + if (selected === -1) return t.starred; + const area = areas()?.find(a => a.id === selected); + return area?.name ?? t.selectArea; + }); + return ( - - - - {computedState.unseenUpdates().length > 0 && ( - - - - - - )} - + <> + + + {computedState.translations().closeModal} + + + + + + {computedState.unseenUpdates().length > 0 && ( + + + + + + )} + + e.key === 'Enter' && toggleAreaSelector()} + > + + {currentAreaLabel()} + + + + + {computedState.translations().selectArea} + + + + + + + {computedState.translations().settings} + e.key === 'Enter' && toggleAreaSelector()} + onClick={toggleLang} + onKeyDown={e => e.key === 'Enter' && toggleLang()} > - - {computedState.translations().selectArea} + - - - - - - - {computedState.translations().settings} - - e.key === 'Enter' && toggleLang()} - > - - - - + + + ); } diff --git a/src/globalStyles.ts b/src/globalStyles.ts index 6bbad04..6701917 100644 --- a/src/globalStyles.ts +++ b/src/globalStyles.ts @@ -13,10 +13,11 @@ export default createGlobalStyles` body { font-family: "Interface", sans-serif; margin: 0; - background-color: var(--gray6); + background-color: var(--bg-app); --accent_color: #09ACFE; + /* Raw palette — keep for derived tokens and legacy references */ --gray1: #464646; --gray2: #636363; --gray3: #777; @@ -34,18 +35,72 @@ export default createGlobalStyles` --priceCategory_studentPremium: #8b8f4f; --priceCategory_regular: #875555; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 16px rgba(0,0,0,0.12), 0 1px 4px rgba(0,0,0,0.06); + --shadow-popover: 0 8px 32px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06); + + /* Semantic background tokens */ + --bg-app: var(--gray6); + --bg-surface: var(--gray7); + --bg-inset: var(--gray6); + --bg-interactive: var(--gray5); + + /* Semantic border tokens */ + --border-subtle: var(--gray5); + --border: var(--gray4); + + /* Semantic text tokens */ + --text-primary: var(--gray1); + --text-secondary: var(--gray2); + --text-muted: var(--gray3); + --text-disabled: var(--gray4); + + /* Component tokens */ + --radio-track: var(--gray5); + --radio-selected: var(--gray7); + + --topbar-bg: rgba(254, 254, 254, 0.82); + --topbar-border: rgba(0, 0, 0, 0.07); + &.dark { - --accent_color: #0ba3cb; + /* Raw palette — blue-teal hue (~205°) */ + --gray7: #1b2530; + --gray6: #121c26; + --gray5: #203040; + --gray4: #405870; + --gray3: #5c7c90; + --gray2: #8aaabb; + --gray1: #c8d8e2; + + /* Semantic backgrounds — independently tuned, clear hierarchy */ + --bg-app: #0d1318; /* page canvas + modal base */ + --bg-surface: #131c26; /* restaurant cards, elevated above page */ + --bg-inset: #1c2d44; /* settings cards within modal, further elevated */ + --bg-interactive: #213248; /* hover/pressed states */ + + /* Semantic borders — visible against respective surfaces */ + --border-subtle: #182c40; /* dividers, subtle outlines */ + --border: #243d56; /* card outlines, focused states */ + + /* Semantic text — cool tint, strong contrast hierarchy */ + --text-primary: #dce8ee; + --text-secondary: #8aaabb; + --text-muted: #587888; + --text-disabled: #384e5c; - --gray7: #2B3138; - --gray6: #202329; - --gray5: #313131; - --gray4: #989898; - --gray3: #adadad; - --gray2: #b3b3b3; - --gray1: #c3c3c3; + /* Component tokens */ + --topbar-bg: rgba(19, 28, 38, 0.90); + --topbar-border: rgba(255, 255, 255, 0.07); + --accent_color: #1ab0d8; + --radio-track: #080d12; + --radio-selected: #1c2d44; - --hearty: #fe346e; + --hearty: #f23d6e; --friendly: #06CBB0; } } diff --git a/src/icons.ts b/src/icons.ts index a87177e..8fcbe06 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,6 +1,5 @@ export { AiFillEdit as EditIcon, - AiFillHome as HomeIcon, AiFillStar as FilledStarIcon, AiFillWarning as WarningIcon, AiOutlineLink as LinkIcon, @@ -11,7 +10,6 @@ export { } from 'solid-icons/bi'; export { FaSolidAngleDown as CaretDownIcon, - FaSolidMap as MapIcon, FaSolidPersonWalking as WalkIcon, } from 'solid-icons/fa'; export { @@ -19,13 +17,17 @@ export { FiCopy as CopyIcon, FiMoreVertical as MoreIcon, } from 'solid-icons/fi'; -export { - IoLocationSharp as LocationIcon, - IoNewspaperSharp as NewsIcon, - IoSettingsSharp as SettingsIcon, -} from 'solid-icons/io'; export { RiFinanceMoneyEuroCircleFill as MoneyIcon } from 'solid-icons/ri'; -export { TbOutlineBike as BikeIcon } from 'solid-icons/tb'; +export { + TbOutlineAdjustmentsHorizontal as SettingsIcon, + TbOutlineBell as NewsIcon, + TbOutlineBike as BikeIcon, + TbOutlineCurrentLocation as LocationIcon, + TbOutlineHome as HomeIcon, + TbOutlineLoader as LoaderIcon, + TbOutlineMapPin as MapIcon, + TbOutlineSchool as StudentIcon, +} from 'solid-icons/tb'; export { VsHeart as HeartIcon, VsHeartFilled as HeartFilledIcon, diff --git a/src/translations.tsx b/src/translations.tsx index 604f030..7e3f3ae 100644 --- a/src/translations.tsx +++ b/src/translations.tsx @@ -329,6 +329,10 @@ const translations = { fi: 'Ehdota tietojen korjausta ravintolalle %restaurantName%', en: 'Suggest a fix for %restaurantName%', }, + fixInfo: { + fi: 'Korjaa tietoja', + en: 'Fix info', + }, location: { fi: 'Sijainti', en: 'Location', @@ -358,8 +362,16 @@ const translations = { en: 'Dark', }, tosShort: { - fi: 'Huom. Kanttiinit tarjoaa ruokalistat muokkaamattomana, ota yhteyttä itse ravintolaan jos palautteesi koskee ruokaa tai sen sisältöä', - en: 'Note: Kanttiinit displays the restaurant menus unedited, please contact the restaurant itself if your feedback concerns the food or its contents', + fi: 'Kanttiinit on palvelu, joka näyttää ruokalistat kootusti, mutta ei vastaa ravintoloiden toiminnasta. Palautteessa ruuan sisällöstä tai allergeeneista ota yhteyttä suoraan ravintolaan.', + en: "Kanttiinit is a menu aggregator and doesn't operate any restaurants. For feedback about food contents or allergens, contact the restaurant directly.", + }, + reportDisclaimer: { + fi: 'Voit ehdottaa korjauksia aukioloaikoihin, sijaintiin ja muihin perustietoihin. Kanttiinit vain näyttää ruokalistat kootussa paikassa — emme vastaa ruokalistojen sisällöstä. Ota ruokaan liittyvissä asioissa yhteyttä suoraan ravintolaan.', + en: "You can suggest corrections to opening hours, location, and other details. Kanttiinit is an aggregator — we don't control menu content. For that, contact the restaurant directly.", + }, + continueButton: { + fi: 'Jatka', + en: 'Continue', }, priceCategory: { fi: 'Hintaluokka', @@ -382,12 +394,12 @@ const translations = { en: 'Student lunch', }, [PriceCategory.studentPremium]: { - fi: 'Joitain alennuksia opiskelijoille', - en: 'Some discounts for students', + fi: 'Opiskelija-ale', + en: 'Student discount', }, [PriceCategory.regular]: { - fi: 'Ei opiskelija-alennuksia', - en: 'No student discounts', + fi: 'Ei alennuksia', + en: 'No discounts', }, };