@autobusal/common 1.15.0 → 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 +91 -0
- package/Calendar/Calendar.tsx +10 -1
- package/Calendar/Days.tsx +19 -1
- package/Calendar/Picker.tsx +3 -2
- package/ChangeLanguage/ChangeLanguage.tsx +8 -2
- package/ChangeLanguage/styles.ts +29 -6
- package/Drive/Drive.tsx +34 -4
- package/Drive/services.ts +10 -1
- package/Drive/utilities.ts +32 -13
- package/Required/Required.tsx +35 -0
- package/Required/styles.ts +14 -0
- package/RouteFeature/RouteFeature.tsx +20 -5
- package/RouteFeature/styles.ts +18 -3
- package/RouteItem/RouteItem.tsx +10 -3
- package/RouteItem/styles.ts +14 -0
- package/UnderConstruction/useUnderConstruction.ts +37 -8
- package/index.ts +2 -0
- package/package.json +1 -1
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
|
package/Calendar/Calendar.tsx
CHANGED
|
@@ -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;
|
package/Calendar/Picker.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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 }
|
|
44
|
+
<Container ref={ ref } className="change-language" $type={ type }>
|
|
39
45
|
<Title>{ t('languages.title', { ns: 'common' }) }</Title>
|
|
40
46
|
|
|
41
47
|
<ContainerButtons>
|
package/ChangeLanguage/styles.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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="© <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
|
-
|
|
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', {
|
package/Drive/utilities.ts
CHANGED
|
@@ -5,23 +5,42 @@ import { LocationData } from '@autobusal/providers/types/locations';
|
|
|
5
5
|
|
|
6
6
|
const STOP_UNDEFINED = ['0', '0'];
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
coordinates.push(from)
|
|
19
|
-
coordinates.push(to)
|
|
20
|
-
});
|
|
18
|
+
const [ first, second ] = position.map(Number);
|
|
21
19
|
|
|
22
|
-
return
|
|
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
|
-
|
|
11
|
-
|
|
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;
|
package/RouteFeature/styles.ts
CHANGED
|
@@ -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
|
+
`;
|
package/RouteItem/RouteItem.tsx
CHANGED
|
@@ -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
|
-
|
|
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') }:
|
package/RouteItem/styles.ts
CHANGED
|
@@ -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,5 +1,5 @@
|
|
|
1
|
-
import { useLocation } from 'react-router-dom';
|
|
2
1
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
2
|
+
import { useUserStore } from '@autobusal/providers/stores/user';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Paths that stay reachable while the site is closed.
|
|
@@ -7,18 +7,28 @@ import { useGetSettings } from '@autobusal/providers/services';
|
|
|
7
7
|
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
8
8
|
*
|
|
9
9
|
* Without this the switch is one-way: the holding page replaces the whole
|
|
10
|
-
* router, so /admin
|
|
11
|
-
* back OFF
|
|
10
|
+
* router, so /admin went behind it too and the only way to turn maintenance
|
|
11
|
+
* back OFF was a database edit. Found by actually switching it on and then
|
|
12
12
|
* trying to switch it off again.
|
|
13
13
|
*
|
|
14
14
|
* Staff routes are the exception, not the public site - somebody has to be
|
|
15
15
|
* able to log in and finish the work the site is closed for. Everything a
|
|
16
|
-
* visitor can reach still gets the holding page,
|
|
16
|
+
* visitor can reach still gets the holding page, deep links included.
|
|
17
17
|
*/
|
|
18
18
|
const ALWAYS_OPEN = [ '/admin', '/account', '/auth' ];
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Whether the holding page should replace the app
|
|
21
|
+
* Whether the holding page should replace the app right now.
|
|
22
|
+
*
|
|
23
|
+
* Reads the path from `window.location` rather than react-router's
|
|
24
|
+
* useLocation: this runs ABOVE <RouterProvider> (it decides whether to
|
|
25
|
+
* render the router at all), so there is no router context to read from -
|
|
26
|
+
* calling useLocation here throws and blanks the entire page, which is
|
|
27
|
+
* exactly what happened the first time.
|
|
28
|
+
*
|
|
29
|
+
* The consequence is that it does not re-evaluate on client-side
|
|
30
|
+
* navigation, which is fine: the holding page renders no links, so the only
|
|
31
|
+
* way to leave it is a real page load, and that re-runs this.
|
|
22
32
|
*
|
|
23
33
|
* obtapi sends `construction` only while the brand is actually under
|
|
24
34
|
* construction, so its presence is the switch.
|
|
@@ -26,14 +36,33 @@ const ALWAYS_OPEN = [ '/admin', '/account', '/auth' ];
|
|
|
26
36
|
const useUnderConstruction = (): boolean => {
|
|
27
37
|
const { data: settings } = useGetSettings();
|
|
28
38
|
|
|
29
|
-
const
|
|
39
|
+
const { data: user } = useUserStore();
|
|
30
40
|
|
|
31
41
|
if (!settings?.construction) {
|
|
32
42
|
return false;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
|
|
62
|
+
const path = typeof window === 'undefined' ? '/' : window.location.pathname;
|
|
63
|
+
|
|
64
|
+
return !ALWAYS_OPEN.some(open => (
|
|
65
|
+
path === open || path.startsWith(`${ open }/`)
|
|
37
66
|
));
|
|
38
67
|
};
|
|
39
68
|
|
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,
|