@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 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 { Options, Columns, Column, Item, Title, Action } from './styles';
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
- <Autocomplete
134
- name="from"
135
- defaultValue={ from }
136
- data={ cities }
137
- validation="required"
138
- t={ t }
139
- refs={ register }
140
- onUpdate={ (_, value) => { setValue('from', value); onDeparture(String(value)); } }
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
- <Autocomplete
150
- name="to"
151
- defaultValue={ to }
152
- data={ cities }
153
- validation="required"
154
- t={ t }
155
- refs={ register }
156
- onUpdate={ setValue }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-search",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
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
+ `;