@autobusal/routes-search 1.2.0 → 1.3.1
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/Picker.tsx +119 -0
- package/RoutesSearch.tsx +56 -20
- package/package.json +1 -1
- package/styles.ts +96 -1
package/Picker.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useState, useRef } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { FaMapMarkerAlt, FaChevronLeft } from 'react-icons/fa';
|
|
5
|
+
import { apiClient } from '@autobusal/providers';
|
|
6
|
+
import { useOutside } from '@autobusal/hooks';
|
|
7
|
+
import { PickerContainer, PickerButton, Tooltip, TooltipTitle, TooltipList, TooltipItem, TooltipBack } from './styles';
|
|
8
|
+
|
|
9
|
+
interface Country {
|
|
10
|
+
id: number
|
|
11
|
+
name: string
|
|
12
|
+
cities: { slug: string, name: string }[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/**
|
|
17
|
+
* The departure slug, when this is the destination picker - the list then
|
|
18
|
+
* only offers cities actually reachable from it.
|
|
19
|
+
*/
|
|
20
|
+
from?: string
|
|
21
|
+
t: TFunction<'common'>
|
|
22
|
+
onPick: (slug: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The map-pin browser on the From/To fields.
|
|
27
|
+
*
|
|
28
|
+
* Ferjolt Ozuni - Date: 2026-08-07
|
|
29
|
+
*
|
|
30
|
+
* Autocomplete answers people who know where they are going; this answers
|
|
31
|
+
* the ones who do not. Click the pin, pick a country, pick a city - two
|
|
32
|
+
* steps over /api/cities/selectable, which only ever lists cities a ticket
|
|
33
|
+
* can actually be sold for. On the destination field the departure slug
|
|
34
|
+
* rides along, so the countries and cities shown are exactly the ones
|
|
35
|
+
* reachable from the chosen origin - the picker cannot compose a pair the
|
|
36
|
+
* search would answer with nothing.
|
|
37
|
+
*
|
|
38
|
+
* Fetched lazily on first open (keyed by origin), not with the form: most
|
|
39
|
+
* searches never open this.
|
|
40
|
+
*/
|
|
41
|
+
const Picker = ({ from, t, onPick }: Props): JSX.Element => {
|
|
42
|
+
const [ open, setOpen ] = useState<boolean>(false);
|
|
43
|
+
|
|
44
|
+
const [ country, setCountry ] = useState<Country | null>(null);
|
|
45
|
+
|
|
46
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
useOutside(ref, () => setOpen(false));
|
|
49
|
+
|
|
50
|
+
const { data } = useQuery<Country[]>({
|
|
51
|
+
queryKey: ['cities-selectable', { from: from ?? null }],
|
|
52
|
+
enabled: open,
|
|
53
|
+
queryFn: async () => (
|
|
54
|
+
await apiClient
|
|
55
|
+
.get('/api/cities/selectable', { params: from ? { from } : {} })
|
|
56
|
+
.then(response => response?.data ?? [])
|
|
57
|
+
)
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const onToggle = (): void => {
|
|
61
|
+
setCountry(null);
|
|
62
|
+
setOpen(!open);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onCity = (slug: string): void => {
|
|
66
|
+
onPick(slug);
|
|
67
|
+
setOpen(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<PickerContainer ref={ ref }>
|
|
72
|
+
<PickerButton
|
|
73
|
+
type="button"
|
|
74
|
+
aria-label={ t('routes_search.picker.open', { ns: 'common' }) }
|
|
75
|
+
title={ t('routes_search.picker.open', { ns: 'common' }) }
|
|
76
|
+
onClick={ onToggle }
|
|
77
|
+
>
|
|
78
|
+
<FaMapMarkerAlt />
|
|
79
|
+
</PickerButton>
|
|
80
|
+
|
|
81
|
+
{ open && (
|
|
82
|
+
<Tooltip>
|
|
83
|
+
{ !country ? (
|
|
84
|
+
<>
|
|
85
|
+
<TooltipTitle>{ t('routes_search.picker.countries', { ns: 'common' }) }</TooltipTitle>
|
|
86
|
+
|
|
87
|
+
<TooltipList>
|
|
88
|
+
{ (data ?? []).map(item => (
|
|
89
|
+
<TooltipItem key={ item.id } type="button" onClick={ () => setCountry(item) }>
|
|
90
|
+
{ item.name }
|
|
91
|
+
</TooltipItem>
|
|
92
|
+
)) }
|
|
93
|
+
</TooltipList>
|
|
94
|
+
</>
|
|
95
|
+
) : (
|
|
96
|
+
<>
|
|
97
|
+
<TooltipBack type="button" onClick={ () => setCountry(null) }>
|
|
98
|
+
<FaChevronLeft />
|
|
99
|
+
{ country.name }
|
|
100
|
+
</TooltipBack>
|
|
101
|
+
|
|
102
|
+
<TooltipTitle>{ t('routes_search.picker.cities', { ns: 'common' }) }</TooltipTitle>
|
|
103
|
+
|
|
104
|
+
<TooltipList>
|
|
105
|
+
{ country.cities.map(city => (
|
|
106
|
+
<TooltipItem key={ city.slug } type="button" onClick={ () => onCity(city.slug) }>
|
|
107
|
+
{ city.name }
|
|
108
|
+
</TooltipItem>
|
|
109
|
+
)) }
|
|
110
|
+
</TooltipList>
|
|
111
|
+
</>
|
|
112
|
+
) }
|
|
113
|
+
</Tooltip>
|
|
114
|
+
) }
|
|
115
|
+
</PickerContainer>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default Picker;
|
package/RoutesSearch.tsx
CHANGED
|
@@ -6,7 +6,8 @@ import { AiOutlineSearch } from 'react-icons/ai';
|
|
|
6
6
|
import { Button, Autocomplete, Calendar, Passengers, setDepartureZone } from '@autobusal/common';
|
|
7
7
|
import { Display, createDate } from '@autobusal/utilities';
|
|
8
8
|
import { getCities } from './utilities';
|
|
9
|
-
import
|
|
9
|
+
import Picker from './Picker';
|
|
10
|
+
import { Options, Columns, Column, Item, Title, Action, PickerField } from './styles';
|
|
10
11
|
import { RoutesSearchForm } from './types';
|
|
11
12
|
import { useGetSuggestions } from './services';
|
|
12
13
|
|
|
@@ -34,6 +35,23 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
34
35
|
|
|
35
36
|
const { register, handleSubmit, formState: { errors }, setValue } = useForm<RoutesSearchForm>();
|
|
36
37
|
|
|
38
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-07
|
|
39
|
+
// What the map-pin picker chose, per field. Autocomplete is uncontrolled,
|
|
40
|
+
// so a pick remounts it (the `key` below) with the picked slug as its
|
|
41
|
+
// defaultValue - the same remount-to-reseed trick the admin label form
|
|
42
|
+
// uses. Null until the picker is used, so typing stays untouched.
|
|
43
|
+
const [ picked, setPicked ] = useState<{ from: string | null, to: string | null }>({ from: null, to: null });
|
|
44
|
+
|
|
45
|
+
const onPick = (field: 'from' | 'to', slug: string): void => {
|
|
46
|
+
setValue(field, slug);
|
|
47
|
+
|
|
48
|
+
setPicked(previous => ({ ...previous, [field]: slug }));
|
|
49
|
+
|
|
50
|
+
if (field === 'from') {
|
|
51
|
+
onDeparture(slug);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
37
55
|
const { data: CitiesData } = useGetSuggestions();
|
|
38
56
|
|
|
39
57
|
/**
|
|
@@ -130,15 +148,21 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
130
148
|
<Item>
|
|
131
149
|
<Title>{ t('routes_search.from', { ns: 'common' }) }</Title>
|
|
132
150
|
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
151
|
+
<PickerField>
|
|
152
|
+
<Autocomplete
|
|
153
|
+
key={ `from-${ picked.from ?? '' }` }
|
|
154
|
+
name="from"
|
|
155
|
+
label={ t('routes_search.from', { ns: 'common' }) }
|
|
156
|
+
defaultValue={ picked.from ?? from }
|
|
157
|
+
data={ cities }
|
|
158
|
+
validation="required"
|
|
159
|
+
t={ t }
|
|
160
|
+
refs={ register }
|
|
161
|
+
onUpdate={ (_, value) => { setValue('from', value); onDeparture(String(value)); } }
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
<Picker t={ t } onPick={ (slug) => onPick('from', slug) } />
|
|
165
|
+
</PickerField>
|
|
142
166
|
|
|
143
167
|
{ Display(errors.from) }
|
|
144
168
|
</Item>
|
|
@@ -146,15 +170,24 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
146
170
|
<Item>
|
|
147
171
|
<Title>{ t('routes_search.to', { ns: 'common' }) }</Title>
|
|
148
172
|
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
<PickerField>
|
|
174
|
+
<Autocomplete
|
|
175
|
+
key={ `to-${ picked.to ?? '' }` }
|
|
176
|
+
name="to"
|
|
177
|
+
label={ t('routes_search.to', { ns: 'common' }) }
|
|
178
|
+
defaultValue={ picked.to ?? to }
|
|
179
|
+
data={ cities }
|
|
180
|
+
validation="required"
|
|
181
|
+
t={ t }
|
|
182
|
+
refs={ register }
|
|
183
|
+
onUpdate={ setValue }
|
|
184
|
+
/>
|
|
185
|
+
|
|
186
|
+
{ /* the departure the picker itself chose, or the one the form
|
|
187
|
+
opened with - reachability filters against what is actually
|
|
188
|
+
in the From field */ }
|
|
189
|
+
<Picker from={ (picked.from ?? from)?.split(':')[0] } t={ t } onPick={ (slug) => onPick('to', slug) } />
|
|
190
|
+
</PickerField>
|
|
158
191
|
|
|
159
192
|
{ Display(errors.to) }
|
|
160
193
|
</Item>
|
|
@@ -167,6 +200,7 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
167
200
|
<Calendar
|
|
168
201
|
type="picker"
|
|
169
202
|
name="departure"
|
|
203
|
+
label={ t('routes_search.departure', { ns: 'common' }) }
|
|
170
204
|
defaultValue={ departure }
|
|
171
205
|
t={ t }
|
|
172
206
|
validation="required"
|
|
@@ -194,6 +228,7 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
194
228
|
<Calendar
|
|
195
229
|
type="picker"
|
|
196
230
|
name="_return"
|
|
231
|
+
label={ t('routes_search.return', { ns: 'common' }) }
|
|
197
232
|
defaultValue={ _return }
|
|
198
233
|
minDate={ departureDate }
|
|
199
234
|
t={ t }
|
|
@@ -211,7 +246,8 @@ const RoutesSearch = ({ type, from, to, departure, _return, adultsNo, childrenNo
|
|
|
211
246
|
<Item>
|
|
212
247
|
<Title>{ t('routes_search.passengers', { ns: 'common' }) }</Title>
|
|
213
248
|
|
|
214
|
-
<Passengers
|
|
249
|
+
<Passengers
|
|
250
|
+
label={ t('routes_search.passengers', { ns: 'common' }) }
|
|
215
251
|
defaultAdults={ adultsNo }
|
|
216
252
|
defaultChildren={ childrenNo }
|
|
217
253
|
defaultBabies={ babiesNo }
|
package/package.json
CHANGED
package/styles.ts
CHANGED
|
@@ -46,4 +46,99 @@ export const Action = styled.div`
|
|
|
46
46
|
@media (min-width: 640px) {
|
|
47
47
|
justify-content: right;
|
|
48
48
|
}
|
|
49
|
-
`;
|
|
49
|
+
`;
|
|
50
|
+
/**
|
|
51
|
+
* Ferjolt Ozuni - Date: 2026-08-07
|
|
52
|
+
* The map-pin picker riding on the From/To fields - see Picker.tsx.
|
|
53
|
+
* The container wraps the field so the pin can sit inside its right edge
|
|
54
|
+
* and the tooltip can anchor beneath it.
|
|
55
|
+
*/
|
|
56
|
+
export const PickerContainer = styled.div`
|
|
57
|
+
position: absolute;
|
|
58
|
+
top: 0;
|
|
59
|
+
right: 0;
|
|
60
|
+
height: 38px;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
export const PickerField = styled.div`
|
|
66
|
+
position: relative;
|
|
67
|
+
|
|
68
|
+
input {
|
|
69
|
+
padding-right: 36px;
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
export const PickerButton = styled.button`
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
justify-content: center;
|
|
77
|
+
width: 36px;
|
|
78
|
+
height: 100%;
|
|
79
|
+
border: none;
|
|
80
|
+
background: transparent;
|
|
81
|
+
color: ${ props => props.theme.font.faded };
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
|
|
84
|
+
&:hover {
|
|
85
|
+
color: ${ props => props.theme.primary.normal };
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
export const Tooltip = styled.div`
|
|
90
|
+
position: absolute;
|
|
91
|
+
top: calc(100% + 6px);
|
|
92
|
+
right: 0;
|
|
93
|
+
z-index: 30;
|
|
94
|
+
width: 260px;
|
|
95
|
+
max-height: 320px;
|
|
96
|
+
overflow-y: auto;
|
|
97
|
+
padding: 12px;
|
|
98
|
+
background: ${ props => props.theme.background.box };
|
|
99
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
100
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
export const TooltipTitle = styled.div`
|
|
104
|
+
margin-bottom: 8px;
|
|
105
|
+
font-size: ${ props => props.theme.size.s };
|
|
106
|
+
font-weight: 700;
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
export const TooltipList = styled.div`
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
export const TooltipItem = styled.button`
|
|
115
|
+
padding: 8px 6px;
|
|
116
|
+
border: none;
|
|
117
|
+
background: transparent;
|
|
118
|
+
color: ${ props => props.theme.font.normal };
|
|
119
|
+
font-size: ${ props => props.theme.size.s };
|
|
120
|
+
text-align: left;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
123
|
+
|
|
124
|
+
&:hover {
|
|
125
|
+
background: ${ props => props.theme.background.neutral };
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
export const TooltipBack = styled.button`
|
|
130
|
+
display: flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
gap: 6px;
|
|
133
|
+
margin-bottom: 6px;
|
|
134
|
+
padding: 4px 0;
|
|
135
|
+
border: none;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: ${ props => props.theme.font.faded };
|
|
138
|
+
font-size: ${ props => props.theme.size.xs };
|
|
139
|
+
cursor: pointer;
|
|
140
|
+
|
|
141
|
+
&:hover {
|
|
142
|
+
color: ${ props => props.theme.primary.normal };
|
|
143
|
+
}
|
|
144
|
+
`;
|