@autobusal/common 1.15.1 → 1.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,97 @@
3
3
  All notable changes to `@autobusal/common` are documented here. This project follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
5
 
6
+ > Note: 1.10.0 through 1.15.1 were published without changelog entries. The
7
+ > gap is left as-is rather than reconstructed after the fact.
8
+
9
+ ## [1.15.6] - 2026-08-01
10
+
11
+ ### Changed
12
+
13
+ - **An admin sees the real site while maintenance is on.** Deliberately
14
+ admin only - an operator or agent signing in still gets the holding page,
15
+ because the site is not open to them either. Presentation, not
16
+ protection: obtapi still decides what any account may do.
17
+ - `Calendar` takes an optional `minDate`, which can only RAISE the floor
18
+ the type already sets. Used by the return leg so it cannot start before
19
+ the outbound date.
20
+
21
+ ### Fixed
22
+
23
+ - `Required` rendered a fragment, so in the column-flex form rows the label
24
+ and its asterisk became two flex items and the asterisk dropped onto its
25
+ own line.
26
+
27
+ ## [1.15.5] - 2026-08-01
28
+
29
+ ### Added
30
+
31
+ - `Required` - marks a form label as mandatory. The asterisk is
32
+ `aria-hidden`, since a screen reader announcing "star" tells nobody
33
+ anything; the input's own validation carries the semantics.
34
+
35
+ ## [1.15.4] - 2026-08-01
36
+
37
+ ### Fixed
38
+
39
+ - **The language dropdown opened upward over the navbar.** It positioned
40
+ itself with `bottom: calc((-1 * languages * 27px) - 50px - 8px)`, guessing
41
+ where its own bottom edge should land by multiplying an assumed 27px row
42
+ height by the language count. The rows are not 27px, so the error grew
43
+ with every language a brand enabled - at thirteen it was far enough out to
44
+ open over the page instead of below the trigger. Now `top: 100%`, which
45
+ needs no arithmetic and cannot drift, plus a height cap so a brand with
46
+ twenty languages does not run off the screen.
47
+ - **`RouteItem` showed the wrong arrival country.** The city read
48
+ `locations[lastLocation]` but the country read `locations[1]`. Identical
49
+ on a two-stop route, wrong on anything longer: Tirana-Veria-Thessaloniki
50
+ rendered "Thessaloniki (alb)", because `locations[1]` is Elbasan.
51
+ - Country codes render upper case here too, matching the search results.
52
+
53
+ ## [1.15.3] - 2026-08-01
54
+
55
+ ### Changed
56
+
57
+ - **Language switcher shows just the language name**, left-aligned, with
58
+ the panel title left-aligned too. The "Change to" prefix on every row
59
+ said nothing the panel's own title and the flag beside it were not
60
+ already saying. One component serves both the public header and the
61
+ panel sidebar, so both are fixed.
62
+ - **The route map fits the trip** instead of sitting at a fixed zoom 6
63
+ centred on the first stop. Every route drew the same view of the Balkans
64
+ and none of them filled it, so following a route meant panning.
65
+
66
+ ### Fixed
67
+
68
+ - **`getCoordinates` sent every interior stop twice** - a four-stop route
69
+ went out as A,B,B,C,C,D,D,D, with the last point repeated against
70
+ itself. Directions APIs reject consecutive identical coordinates, so the
71
+ request failed and the map fell back to bare markers with no road line.
72
+ - **`useGetDriveData` cached on a bare `['drive-data']` key**, with the
73
+ coordinates absent - so every route on the site shared one itinerary and
74
+ the second route page you opened drew the first one's road.
75
+ - Stops with no coordinates are dropped rather than plotted at 0,0, which
76
+ put a pin off the west coast of Africa and dragged the fitted view
77
+ across the Atlantic.
78
+ - The query is skipped entirely when no `VITE_OPEN_ROUTE_SERVICE_KEY` is
79
+ configured, instead of a guaranteed 401 on every route page view.
80
+ - A stray `}` inside the `ButtonChange` styled-component template.
81
+
82
+ ## [1.15.2] - 2026-08-01
83
+
84
+ ### Added
85
+
86
+ - `RouteFeature` takes an optional `compact` prop: icon only, with the name
87
+ as a hover tooltip and a help cursor to advertise that the hover does
88
+ something. Opt-in, because the browse and route-view pages have the room
89
+ and their labels earn it - only the search results card asks for this.
90
+
91
+ ### Fixed
92
+
93
+ - `RouteFeature`'s image had **no `alt` at all**. That mattered already; it
94
+ matters more in compact mode, where the image is the only content and a
95
+ screen reader would otherwise announce nothing whatsoever per amenity.
96
+
6
97
  ## [1.9.0] - 2026-07-31
7
98
 
8
99
  ### Added
@@ -12,11 +12,19 @@ interface Props {
12
12
  defaultValue?: string
13
13
  t: TFunction<'general'>
14
14
  validation?: string
15
+ /**
16
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
17
+ * Earliest selectable day, DD/MM/YYYY. Used by the return leg so it
18
+ * cannot be set before the outbound date - the picker previously floored
19
+ * at today, so a return in the past was selectable and only caught by an
20
+ * alert after pressing search.
21
+ */
22
+ minDate?: string
15
23
  refs: UseFormRegister<any>
16
24
  onUpdate: UseFormSetValue<any>
17
25
  }
18
26
 
19
- const Calendar = ({ type, name, defaultValue, t, validation, refs, onUpdate }: Props): JSX.Element => {
27
+ const Calendar = ({ type, name, defaultValue, t, validation, minDate, refs, onUpdate }: Props): JSX.Element => {
20
28
  const [ show, setShow ] = useState<boolean>(false);
21
29
  const [ prepared, setPrepared ] = useState<string>(defaultValue ?? prepareDate(new Date()));
22
30
  const [ selected, setSelected ] = useState<string>(prepared);
@@ -43,6 +51,7 @@ const Calendar = ({ type, name, defaultValue, t, validation, refs, onUpdate }: P
43
51
  type={ type }
44
52
  value={ prepared }
45
53
  selected={ selected }
54
+ minDate={ minDate }
46
55
  t={ t }
47
56
  onClick={ onClick }
48
57
  />
package/Calendar/Days.tsx CHANGED
@@ -8,10 +8,11 @@ interface Props {
8
8
  type: 'picker' | 'dob' | 'date'
9
9
  value: string
10
10
  selected: string
11
+ minDate?: string
11
12
  onSelected: (prepared: string, disabled: boolean) => void
12
13
  }
13
14
 
14
- const Days = ({ type, value, selected, onSelected }: Props): JSX.Element => {
15
+ const Days = ({ type, value, selected, minDate, onSelected }: Props): JSX.Element => {
15
16
  const [ calendar, setCalendar ] = useState<CalendarData>(getCalendar(value));
16
17
 
17
18
  useEffect(() => {
@@ -22,6 +23,23 @@ const Days = ({ type, value, selected, onSelected }: Props): JSX.Element => {
22
23
 
23
24
  const limits = getLimits(type);
24
25
 
26
+ /**
27
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
28
+ *
29
+ * Raise the floor, never lower it: a caller-supplied minimum can only
30
+ * narrow what the type already allows, so a return date cannot be
31
+ * pushed before today just because the outbound was.
32
+ */
33
+ if (minDate) {
34
+ const floor = createDate(minDate);
35
+
36
+ floor.setHours(0, 0, 0, 0);
37
+
38
+ if (floor.getTime() > limits.time.min) {
39
+ limits.time.min = floor.getTime();
40
+ }
41
+ }
42
+
25
43
  const rows: JSX.Element[] = [];
26
44
 
27
45
  let currentDay: number = 1;
@@ -10,11 +10,12 @@ interface Props {
10
10
  type: 'picker' | 'dob' | 'date'
11
11
  value: string
12
12
  selected: string
13
+ minDate?: string
13
14
  t: TFunction<'general'>
14
15
  onClick: (prepared: string) => void
15
16
  }
16
17
 
17
- const Picker = ({ type, value, selected, t, onClick }: Props): (JSX.Element | null) => {
18
+ const Picker = ({ type, value, selected, minDate, t, onClick }: Props): (JSX.Element | null) => {
18
19
  const [ prepared, setPrepared ] = useState<string>(value);
19
20
 
20
21
  const onSelected = (prepared: string, disabled: boolean): void => {
@@ -37,7 +38,7 @@ const Picker = ({ type, value, selected, t, onClick }: Props): (JSX.Element | nu
37
38
 
38
39
  <Header t={ t } />
39
40
 
40
- <Days type={ type } value={ prepared } selected={ selected } onSelected={ onSelected } />
41
+ <Days type={ type } value={ prepared } selected={ selected } minDate={ minDate } onSelected={ onSelected } />
41
42
  </ContainerPicker>
42
43
  );
43
44
  };
@@ -27,15 +27,21 @@ const ChangeLanguage = ({ type, t, i18n, onClose }: Props): JSX.Element => {
27
27
 
28
28
  const available = data.preferences.languages.available;
29
29
 
30
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
31
+ // Just the language name. A "Change to" prefix on every row said nothing
32
+ // the panel's own title and the flag beside it were not already saying,
33
+ // and it made a list of options read as a list of sentences.
34
+ // `languages.change` is now unused; the key stays in the translation
35
+ // files rather than being stripped out of fifteen of them for nothing.
30
36
  const items = available.map((item, index) => (
31
37
  <ButtonChange key={ index } onClick={ () => onChange(item) }>
32
38
  <Flag language={ item } size={ 22 } />
33
- { `${ t('languages.change', { ns: 'common' }) } ${ t(`languages.${ item }`, { ns: 'common' }) }` }
39
+ { t(`languages.${ item }`, { ns: 'common' }) }
34
40
  </ButtonChange>
35
41
  ));
36
42
 
37
43
  return (
38
- <Container ref={ ref } className="change-language" $type={ type } $languages={ available.length }>
44
+ <Container ref={ ref } className="change-language" $type={ type }>
39
45
  <Title>{ t('languages.title', { ns: 'common' }) }</Title>
40
46
 
41
47
  <ContainerButtons>
@@ -1,26 +1,48 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * Hangs off the bottom of the trigger with `top: 100%`, instead of being
7
+ * pushed there by a computed offset.
8
+ *
9
+ * It used to be `bottom: calc((-1 * languages * 27px) - 50px - 8px)`, which
10
+ * guesses where the panel's own bottom edge should land by multiplying an
11
+ * assumed 27px row height by the language count. The rows are not 27px, so
12
+ * the error grew with every language a brand enabled: at thirteen it was
13
+ * far enough out that the panel opened UPWARD, over the navbar and the page
14
+ * text. `top: 100%` needs no arithmetic and cannot drift.
15
+ *
16
+ * Capped and scrollable, because a brand with twenty languages would
17
+ * otherwise run off the bottom of the screen.
18
+ */
3
19
  export const Container = styled.div<{
4
20
  $type: 'top' | 'bottom'
5
- $languages: number
6
21
  }>`
7
22
  position: absolute;
8
- bottom: calc((-1 * ${ props => props.$languages } * 27px) - 50px - 8px);
23
+ top: calc(100% + 8px);
9
24
  right: 0;
10
25
  z-index: 101;
11
26
  padding: 10px;
27
+ max-height: 70vh;
28
+ overflow-y: auto;
12
29
  background-color: ${ props => props.theme.background.normal };
13
30
  box-shadow: ${ props => props.theme.boxShadow };
14
31
  border-radius: ${ props => props.theme.borderRadius };
15
32
 
33
+ // the panel switcher sits at the foot of the sidebar, so its list has to
34
+ // open upward - there is nothing below it to open into
16
35
  ${ props => props.$type === 'bottom' && css`
36
+ top: auto;
17
37
  right: 10px;
18
- bottom: 50px;
38
+ bottom: 50px;
19
39
  ` }
20
40
  `;
21
41
 
42
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
43
+ // Left-aligned, with the rows below it. Centred short names left the flags
44
+ // on a ragged edge, which is what made the list hard to scan.
22
45
  export const Title = styled.h5`
23
- text-align: center;
24
46
  font-size: ${ props => props.theme.size.s };
25
47
  margin-bottom: 10px;
26
48
  `;
@@ -41,7 +63,8 @@ export const ButtonChange = styled.button`
41
63
  display: flex;
42
64
  padding: 5px 10px;
43
65
  align-items: center;
44
- justify-content: center;
66
+ justify-content: flex-start;
67
+ text-align: left;
45
68
  gap: 8px;
46
69
  font-size: ${ props => props.theme.size.xs };
47
70
  background: ${ props => props.theme.background.neutral };
@@ -51,4 +74,4 @@ export const ButtonChange = styled.button`
51
74
  &:hover {
52
75
  background-color: ${ props => props.theme.primary.neutral };
53
76
  }
54
- }`;
77
+ `;
package/Drive/Drive.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { MapContainer, TileLayer, Marker, Popup, Polyline } from 'react-leaflet';
3
- import { getCoordinates, getPosition, autobusMarker } from './utilities';
3
+ import { latLngBounds } from 'leaflet';
4
+ import { getCoordinates, getPosition, hasPosition, autobusMarker } from './utilities';
4
5
  import { Container, Loading, Disclaimer } from './styles';
5
6
  import { LocationData } from '@autobusal/providers/types/locations';
6
7
  import { useGetDriveData } from './services';
@@ -36,8 +37,12 @@ const Drive = ({ locations, t }: Props): JSX.Element => {
36
37
  return <Polyline key={ index } positions={ [from, to] } color="#3388FF" />;
37
38
  });
38
39
 
39
- // we add the stop markers
40
- const markers = locations.map((item, index) => (
40
+ // we add the stop markers, skipping stops with no coordinates - those
41
+ // would otherwise pin to 0,0 off the west coast of Africa and drag the
42
+ // fitted view across the Atlantic with them
43
+ const placed = locations.filter(item => hasPosition(item.stop?.location));
44
+
45
+ const markers = placed.map((item, index) => (
41
46
  <Marker key={ index } position={ getPosition(item.stop?.location) } icon={ autobusMarker }>
42
47
  <Popup>
43
48
  <strong>{ item.city.name }</strong> <br />
@@ -46,9 +51,34 @@ const Drive = ({ locations, t }: Props): JSX.Element => {
46
51
  </Marker>
47
52
  ));
48
53
 
54
+ /**
55
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
56
+ *
57
+ * Fit the view to the route instead of a fixed zoom 6 centred on the
58
+ * first stop. Zoom 6 showed most of the Balkans whatever the trip was, so
59
+ * a Tirana-Durres hop and a Tirana-Bucharest run drew the same map and
60
+ * neither filled it - the whole route was there but you had to pan to
61
+ * follow it.
62
+ *
63
+ * Bounds beat a computed zoom because they adapt to the aspect of the
64
+ * container as well as the span of the trip. A single placed stop has no
65
+ * extent to fit, so that case keeps a centred view at a readable zoom.
66
+ */
67
+ const bounds = placed.length > 1
68
+ ? latLngBounds(placed.map(item => getPosition(item.stop?.location)))
69
+ : undefined;
70
+
49
71
  return (
50
72
  <Container>
51
- <MapContainer center={ getPosition(locations[0].stop?.location) } minZoom={ 6 } maxZoom={ 13 } zoom={ 6 } scrollWheelZoom={ true }>
73
+ <MapContainer
74
+ bounds={ bounds }
75
+ boundsOptions={ { padding: [ 30, 30 ] } }
76
+ center={ bounds === undefined ? getPosition(placed[0]?.stop?.location) : undefined }
77
+ zoom={ bounds === undefined ? 11 : undefined }
78
+ minZoom={ 5 }
79
+ maxZoom={ 15 }
80
+ scrollWheelZoom={ true }
81
+ >
52
82
  <TileLayer attribution="&copy; <a href='http://osm.org/copyright'>OpenStreetMap</a> contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
53
83
 
54
84
  { markers }
package/Drive/services.ts CHANGED
@@ -4,7 +4,16 @@ import { GeoJsonData } from './types';
4
4
 
5
5
  export const useGetDriveData = (coordinates: number[][]): UseQueryResult<GeoJsonData> => (
6
6
  useQuery({
7
- queryKey: ['drive-data'],
7
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
8
+ // The key was a bare ['drive-data'] with the coordinates absent, so
9
+ // every route on the site shared one cached itinerary: open one route
10
+ // page and then another, and the second drew the first one's road line.
11
+ queryKey: ['drive-data', { coordinates }],
12
+ // With no key configured (or a route with fewer than two placed stops)
13
+ // this only buys a 401 and a console error on every route page view.
14
+ // Skipping it leaves the map showing stops and no road, which is what
15
+ // it already did - just quietly.
16
+ enabled: Boolean(import.meta.env.VITE_OPEN_ROUTE_SERVICE_KEY) && coordinates.length > 1,
8
17
  queryFn: async () => (
9
18
  await apiClient
10
19
  .post('https://api.openrouteservice.org/v2/directions/driving-car/geojson', {
@@ -5,23 +5,42 @@ import { LocationData } from '@autobusal/providers/types/locations';
5
5
 
6
6
  const STOP_UNDEFINED = ['0', '0'];
7
7
 
8
- export const getCoordinates = (locations: LocationData[]): number[][] => {
9
- const coordinates: number[][] = [];
10
-
11
- locations.forEach((item, index) => {
12
- const nextLocation = locations[index + 1] ?? item;
13
-
14
- const from = getPosition(item.stop?.location ?? STOP_UNDEFINED, 'coordinates') as number[];
15
- const to = getPosition(nextLocation.stop?.location ?? STOP_UNDEFINED, 'coordinates') as number[];
8
+ /**
9
+ * Whether a stop actually carries usable coordinates.
10
+ *
11
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
12
+ */
13
+ export const hasPosition = (position?: string[]): boolean => {
14
+ if (position === undefined || position.length < 2) {
15
+ return false;
16
+ }
16
17
 
17
- // we push both coordinates
18
- coordinates.push(from)
19
- coordinates.push(to)
20
- });
18
+ const [ first, second ] = position.map(Number);
21
19
 
22
- return coordinates;
20
+ return Number.isFinite(first) && Number.isFinite(second) && (first !== 0 || second !== 0);
23
21
  };
24
22
 
23
+ /**
24
+ * The route's stops as [lng, lat] pairs, in order, for the directions API.
25
+ *
26
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
27
+ *
28
+ * This used to push BOTH the current stop and the next one on every
29
+ * iteration, so a four-stop route went out as A,B,B,C,C,D,D,D - every
30
+ * interior point duplicated and the last one repeated against itself.
31
+ * Directions APIs reject consecutive identical coordinates, so the request
32
+ * failed and the map fell back to bare markers with no road line.
33
+ *
34
+ * Stops with no coordinates are dropped rather than sent as 0,0: a point in
35
+ * the Gulf of Guinea either fails the request or drags the itinerary
36
+ * through the Atlantic.
37
+ */
38
+ export const getCoordinates = (locations: LocationData[]): number[][] => (
39
+ locations
40
+ .filter(item => hasPosition(item.stop?.location))
41
+ .map(item => getPosition(item.stop?.location ?? STOP_UNDEFINED, 'coordinates') as number[])
42
+ );
43
+
25
44
  export const getPosition = (position: string[] | undefined, type: string = 'normal'): LatLngExpression => {
26
45
  if (position === undefined) {
27
46
  return [0, 0];
@@ -0,0 +1,35 @@
1
+ import { Label, Marker } from './styles';
2
+
3
+ interface Props {
4
+ children: React.ReactNode
5
+ }
6
+
7
+ /**
8
+ * Marks a form label as required.
9
+ *
10
+ * Ferjolt Ozuni - Date: 2026-08-01
11
+ *
12
+ * The purchase form validated required fields but never said which ones
13
+ * they were, so the first a buyer heard of it was an error message after
14
+ * pressing pay.
15
+ *
16
+ * The asterisk is aria-hidden: a screen reader announcing "star" tells
17
+ * nobody anything, and react-hook-form's own `required` on the input is
18
+ * what actually carries the semantics to assistive technology.
19
+ */
20
+ /**
21
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
22
+ *
23
+ * Wrapped in a single element rather than a fragment. The form rows are
24
+ * column flex containers, so a bare fragment made the label and the
25
+ * asterisk two separate flex items - and the marker dropped onto its own
26
+ * line under the label.
27
+ */
28
+ const Required = ({ children }: Props): JSX.Element => (
29
+ <Label>
30
+ { children }
31
+ <Marker aria-hidden="true">*</Marker>
32
+ </Label>
33
+ );
34
+
35
+ export default Required;
@@ -0,0 +1,14 @@
1
+ import styled from 'styled-components';
2
+
3
+ // keeps the label and its asterisk as ONE flex item - the form rows are
4
+ // column flex containers, so two items would stack vertically
5
+ export const Label = styled.span`
6
+ display: inline-flex;
7
+ align-items: baseline;
8
+ `;
9
+
10
+ export const Marker = styled.span`
11
+ margin-left: 3px;
12
+ font-weight: 700;
13
+ color: ${ props => props.theme.font.error };
14
+ `;
@@ -3,13 +3,28 @@ import { FeatureData } from '@autobusal/providers/types/routes';
3
3
 
4
4
  interface Props {
5
5
  item: FeatureData
6
+ /**
7
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
8
+ *
9
+ * Icon only, with the name as a hover tooltip. Used on the search results
10
+ * card, where eight amenities spelled out took more width than everything
11
+ * else in that row put together.
12
+ *
13
+ * Opt-in rather than the default, because this also renders on the browse
14
+ * and route-view pages, which have the room and where the labels earn it.
15
+ */
16
+ compact?: boolean
6
17
  }
7
18
 
8
- const RouteFeature = ({ item }: Props): JSX.Element => (
9
- <Container>
10
- <Image src={ item.image_url } title={ item.name } />
11
- <Name>{ item.name }</Name>
19
+ const RouteFeature = ({ item, compact = false }: Props): JSX.Element => (
20
+ <Container $compact={ compact } title={ item.name }>
21
+ { /* alt was missing entirely before. It matters more now: with the
22
+ label hidden the image IS the content, so without it a screen
23
+ reader announces nothing at all for each amenity. */ }
24
+ <Image src={ item.image_url } alt={ item.name } />
25
+
26
+ { !compact && <Name>{ item.name }</Name> }
12
27
  </Container>
13
28
  );
14
29
 
15
- export default RouteFeature;
30
+ export default RouteFeature;
@@ -1,12 +1,27 @@
1
- import styled from 'styled-components';
1
+ import styled, { css } from 'styled-components';
2
2
 
3
- export const Container = styled.div`
3
+ export const Container = styled.div<{ $compact?: boolean }>`
4
4
  display: flex;
5
5
  align-items: center;
6
6
  gap: 5px;
7
7
  padding: 3px 6px;
8
8
  border: 1px solid ${ props => props.theme.primary.normal };
9
9
  border-radius: 100px;
10
+
11
+ /**
12
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
13
+ *
14
+ * Icon-only chip. The help cursor is the affordance that something sits
15
+ * behind the hover - without it an unlabelled icon reads as decoration
16
+ * and nobody discovers the tooltip.
17
+ *
18
+ * (No backticks in this comment: it lives inside a styled-components
19
+ * template literal, where a backtick terminates the string.)
20
+ */
21
+ ${ props => props.$compact && css`
22
+ padding: 5px;
23
+ cursor: help;
24
+ ` }
10
25
  `;
11
26
 
12
27
  export const Image = styled.img`
@@ -16,4 +31,4 @@ export const Image = styled.img`
16
31
 
17
32
  export const Name = styled.span`
18
33
  font-size: ${ props => props.theme.size.xs };
19
- `;
34
+ `;
@@ -2,7 +2,7 @@ import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
3
  import { HiOutlineInformationCircle, HiOutlineClock, HiArrowCircleRight } from 'react-icons/hi';
4
4
  import More from './More/More';
5
- import { Container, About, Image, Logo, Information, Name, Code, ButtonMore, Locations, Location, City, Date, Arrow, Order, LinkOrder } from './styles';
5
+ import { Container, About, Image, Logo, Information, Name, Code, ButtonMore, Locations, Location, City, Iso, Date, Arrow, Order, LinkOrder } from './styles';
6
6
  import { RouteData } from '@autobusal/providers/types/routes';
7
7
 
8
8
  interface Props {
@@ -38,7 +38,7 @@ const RouteItem = ({ type, route, t }: Props): JSX.Element => {
38
38
 
39
39
  <Locations>
40
40
  <Location>
41
- <City>{ route.locations[0].city.name } ({ route.locations[0].city.country?.iso_code })</City>
41
+ <City>{ route.locations[0].city.name } <Iso>({ route.locations[0].city.country?.iso_code })</Iso></City>
42
42
 
43
43
  <Date>
44
44
  { t('route_item.departure') }:
@@ -51,7 +51,14 @@ const RouteItem = ({ type, route, t }: Props): JSX.Element => {
51
51
  </Arrow>
52
52
 
53
53
  <Location>
54
- <City>{ route.locations[lastLocation].city.name } ({ route.locations[1].city.country?.iso_code })</City>
54
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
55
+ The country read locations[1] while the city read
56
+ locations[lastLocation]. On a two-stop route those are the
57
+ same index so it looked fine, but on anything longer the
58
+ arrival carried the SECOND stop's country - Tirana-Veria-
59
+ Thessaloniki rendered "Thessaloniki (alb)", because
60
+ locations[1] is Elbasan. */ }
61
+ <City>{ route.locations[lastLocation].city.name } <Iso>({ route.locations[lastLocation].city.country?.iso_code })</Iso></City>
55
62
 
56
63
  <Date>
57
64
  { t('route_item.arrival') }:
@@ -109,6 +109,20 @@ export const City = styled.h5`
109
109
  font-weight: 700;
110
110
  `;
111
111
 
112
+ /**
113
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
114
+ *
115
+ * The country code arrives lower case from the database ("alb", "grc").
116
+ * An ISO country code is upper case by definition, so it is cased at
117
+ * render time. Same treatment as the search results card.
118
+ */
119
+ export const Iso = styled.span`
120
+ font-size: ${ props => props.theme.size.xs };
121
+ text-transform: uppercase;
122
+ letter-spacing: .5px;
123
+ color: ${ props => props.theme.font.faded };
124
+ `;
125
+
112
126
  export const Date = styled.div`
113
127
  display: flex;
114
128
  align-items: center;
@@ -1,4 +1,5 @@
1
1
  import { useGetSettings } from '@autobusal/providers/services';
2
+ import { useUserStore } from '@autobusal/providers/stores/user';
2
3
 
3
4
  /**
4
5
  * Paths that stay reachable while the site is closed.
@@ -35,10 +36,29 @@ const ALWAYS_OPEN = [ '/admin', '/account', '/auth' ];
35
36
  const useUnderConstruction = (): boolean => {
36
37
  const { data: settings } = useGetSettings();
37
38
 
39
+ const { data: user } = useUserStore();
40
+
38
41
  if (!settings?.construction) {
39
42
  return false;
40
43
  }
41
44
 
45
+ /**
46
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
47
+ *
48
+ * An admin sees the real site while it is closed - that is rather the
49
+ * point of closing it: somebody has to be able to look at the thing they
50
+ * are working on. Deliberately ADMIN only; an operator or agent signing
51
+ * in still gets the holding page, because the site is not open to them
52
+ * either.
53
+ *
54
+ * Presentation, not protection. obtapi still decides what any account may
55
+ * actually do, so a forged user in the client store would only reveal a
56
+ * site that is about to be public anyway.
57
+ */
58
+ if (user?.type === 'admin') {
59
+ return false;
60
+ }
61
+
42
62
  const path = typeof window === 'undefined' ? '/' : window.location.pathname;
43
63
 
44
64
  return !ALWAYS_OPEN.some(open => (
package/index.ts CHANGED
@@ -28,6 +28,7 @@ import Password from './Password/Password';
28
28
  import Policy from './Policy/Policy';
29
29
  import Report from './Report/Report';
30
30
  import RouteFeature from './RouteFeature/RouteFeature';
31
+ import Required from './Required/Required';
31
32
  import RouteItem from './RouteItem/RouteItem';
32
33
  import Row from './Table/Row';
33
34
  import Table from './Table/Table';
@@ -70,6 +71,7 @@ export {
70
71
  Policy,
71
72
  Report,
72
73
  RouteFeature,
74
+ Required,
73
75
  RouteItem,
74
76
  Row,
75
77
  Table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.15.1",
3
+ "version": "1.15.6",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"