@autobusal/routes-order 1.31.4 → 1.32.0

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.32.0
4
+
5
+ ### Added
6
+
7
+ - **"Watch this fare" on the results page.** An inline control at the foot of the results asks for an email address and arms a price alert on the pair and date already searched (`POST /api/alerts/price/email`). It renders from `<Found>`, which is the one component mounted in every outcome a search can have - trips listed, every trip hidden by a filter, and nothing found at all; "no buses for this search" is exactly when somebody wants telling if that changes, so a control living inside the listing would have been missing where it matters most. On the return leg the pair is reversed and the return date used, the same swap `useGetDates` makes.
8
+ - Every answer the endpoint can give is said out loud rather than swallowed: the confirmation quotes `baseline_price_display` so the customer knows which number is being watched, a 404 says plainly that nobody prices this pair, a 422 names the address, and a 429 asks for a minute. `retry: false` - none of those three get better by being asked again.
9
+ - The email field has a real accessible name (`<label htmlFor>` over a `useId`-prefixed id), matching what the checkout and `@autobusal/auth` were fixed to do. No threshold field: "tell me if it drops" needs no number, and a second input is how a one-line control becomes a form nobody fills in. No `token` field either - obtapi validates it as `prohibited` here, it belongs to the device half of the same feature.
10
+
11
+ ## 1.31.5
12
+
13
+ ### Added
14
+
15
+ - **BusTrip carries the pair's AggregateRating** when `/routes/search/facts` reports one (`FactsData.rating`). The aggregate is computed server-side over the same published-review rules the reviews listing uses, and rides the facts cache - the pair page keeps its one-fetch design. Absent rating, the key is not emitted at all.
16
+
3
17
  ## 1.31.4
4
18
 
5
19
  ### Fixed
package/Facts/types.ts CHANGED
@@ -17,6 +17,13 @@ export interface FactsData {
17
17
  // how many coaches serve the pair in the TIMETABLE, not on a given date
18
18
  departures: number
19
19
  operators: string[]
20
+ // Edited: Claude - Date: 2026-08-20
21
+ // The pair's own review score - the published reviews of every route in
22
+ // this very timetable, aggregated by obtapi with the same threshold rule
23
+ // as everything else (null until there is enough to say something honest).
24
+ // For structured data; note ScheduleRow.rating below is the OPERATOR's
25
+ // company-wide number, a different thing.
26
+ rating: { average: number, count: number } | null
20
27
  // Two numbers that must never be conflated.
21
28
  //
22
29
  // `road` is real driving distance from a routing service and is null unless
package/Found/Found.tsx CHANGED
@@ -9,6 +9,7 @@ import Picks from './Picks/Picks';
9
9
  import Filters from './Filters/Filters';
10
10
  import Summary from './Summary/Summary';
11
11
  import SameDay from './SameDay/SameDay';
12
+ import Watch from './Watch/Watch';
12
13
  import { Results, Sidebar, Listing } from '../styles';
13
14
  import { useGetAlternatives } from '../services';
14
15
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
@@ -99,6 +100,21 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
99
100
  // refine, and they'd only add noise above an empty state
100
101
  const showControls = !loading && items.length > 1;
101
102
 
103
+ /**
104
+ * The journey the "watch this fare" control below is about.
105
+ *
106
+ * Edited: Claude - Date: 2026-08-20
107
+ *
108
+ * On the return leg the pair is the reverse of the one that was searched
109
+ * and the date is the return date - the same swap useGetDates has always
110
+ * made for the date strip, and for the same reason: step 3 is a search
111
+ * from the destination back, and watching the outbound fare there would
112
+ * watch a journey the visitor is no longer looking at.
113
+ */
114
+ const watched = type === '_return'
115
+ ? { from: step1.to, to: step1.from, departure: step1._return ?? '' }
116
+ : { from: step1.from, to: step1.to, departure: step1.departure };
117
+
102
118
  return (
103
119
  <>
104
120
  <Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
@@ -173,6 +189,26 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
173
189
  />
174
190
  </Listing>
175
191
  </Results>
192
+
193
+ { /* Edited: Claude - Date: 2026-08-20
194
+ At the FOOT of the results, and outside <Results> so it spans the
195
+ full width rather than sitting under the filter column.
196
+
197
+ This is the one spot on the screen that is reached in every
198
+ outcome a search can have - a list of prices none of which was
199
+ the one the reader wanted, a filter that hid them all, and a
200
+ search that found nothing at all. That last case is the one that
201
+ decided the placement: "no buses for this search" is precisely
202
+ when somebody wants telling if that changes, and a control living
203
+ inside the listing would have been absent exactly there.
204
+
205
+ Held back while the search is still running - offering to watch a
206
+ fare before we know whether there is one to watch, under a
207
+ spinner, is noise. A pair nobody prices answers 404 and says so.
208
+ */ }
209
+ { !loading && watched.departure !== '' && (
210
+ <Watch from={ watched.from } to={ watched.to } departure={ watched.departure } t={ t } />
211
+ ) }
176
212
  </>
177
213
  );
178
214
  };
@@ -0,0 +1,187 @@
1
+ import { useState, useId } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useForm } from 'react-hook-form';
4
+ import { AiOutlineBell, AiOutlineCheckCircle } from 'react-icons/ai';
5
+ import { Button } from '@autobusal/common';
6
+ import { Validate, Display } from '@autobusal/utilities';
7
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
8
+ import { Container, Toggle, Panel, Lead, Journey, Fields, Field, Action, Privacy, Failure, Done } from './styles';
9
+ import { PriceAlertForm, PriceAlertData } from '../../types';
10
+ import { usePostPriceAlert } from '../../services';
11
+
12
+ interface Props {
13
+ from: string
14
+ to: string
15
+ departure: string
16
+ t: TFunction<'common'>
17
+ }
18
+
19
+ /**
20
+ * "Watch this fare" - tell me by email if the price for this journey drops.
21
+ *
22
+ * Edited: Claude - Date: 2026-08-20
23
+ *
24
+ * WHERE IT SITS is the whole design. This renders at the END of the results,
25
+ * from <Found>, which is the one component on this screen that is mounted in
26
+ * every state it can be in: trips listed, trips all hidden by a filter, and
27
+ * nothing found at all. A control placed inside the listing would have
28
+ * disappeared in exactly the state that most needs it - "no buses for this
29
+ * search" is when somebody most wants telling if that changes - and one
30
+ * placed above the trips would have argued with the results for attention
31
+ * on the page whose entire job is showing them.
32
+ *
33
+ * The pair and the date are the search that has already been run, so the
34
+ * only thing asked for is an address. Collapsed until pressed: this is for
35
+ * the minority who did not find their price, and an open form under every
36
+ * result set is a second thing to read past.
37
+ *
38
+ * THRESHOLD IS DELIBERATELY NOT COLLECTED. The endpoint accepts one, but
39
+ * "tell me if it goes down" needs no number from the traveller, and a
40
+ * second field is how a one-line control becomes a form nobody fills in.
41
+ */
42
+ const Watch = ({ from, to, departure, t }: Props): JSX.Element => {
43
+ const [ open, setOpen ] = useState<boolean>(false);
44
+ const [ done, setDone ] = useState<PriceAlertData | undefined>(undefined);
45
+ const [ failure, setFailure ] = useState<string | undefined>(undefined);
46
+
47
+ /**
48
+ * Edited: Claude - Date: 2026-08-20
49
+ * Same pairing @autobusal/auth and the checkout were just fixed to use: a
50
+ * real <label for> over an id nothing else can own. The results page also
51
+ * carries the footer's newsletter signup, so a bare id="email" would have
52
+ * been the second one on the page.
53
+ */
54
+ const uid = useId();
55
+
56
+ const id = (field: string): string => `${ uid }${ field }`;
57
+
58
+ const { register, handleSubmit, formState: { errors } } = useForm<PriceAlertForm>();
59
+
60
+ const { mutate: WatchFare, isPending } = usePostPriceAlert();
61
+
62
+ // the same cached city list the search form's autocomplete and the step
63
+ // heading already read, so naming the journey costs no extra request
64
+ const { data: cities } = useGetSuggestions();
65
+
66
+ const fromCity = cities.find(city => city.slug === from);
67
+ const toCity = cities.find(city => city.slug === to);
68
+
69
+ const names = {
70
+ from: fromCity?.name ?? from,
71
+ to: toCity?.name ?? to
72
+ };
73
+
74
+ /**
75
+ * Every answer this endpoint can give, turned into something readable.
76
+ *
77
+ * 404 is not an error the visitor made: nobody prices this pair, so there
78
+ * is genuinely no fare to watch, and saying that plainly is the only
79
+ * honest reply. 429 is the write-tier throttle (20/min). 422 also raises
80
+ * the global toast from apiClient's interceptor - it is repeated inline
81
+ * because a toast above the fold is easy to miss from the foot of a long
82
+ * result list, which is exactly where this control lives.
83
+ */
84
+ const message = (status?: number): string => {
85
+ if (status === 404) {
86
+ return t('routes_order.step2.watch.errors.not_found');
87
+ }
88
+
89
+ if (status === 422) {
90
+ return t('routes_order.step2.watch.errors.invalid');
91
+ }
92
+
93
+ if (status === 429) {
94
+ return t('routes_order.step2.watch.errors.throttled');
95
+ }
96
+
97
+ return t('routes_order.step2.watch.errors.failed');
98
+ };
99
+
100
+ const onSubmit = (data: PriceAlertForm): void => {
101
+ setFailure(undefined);
102
+
103
+ WatchFare({
104
+ email: data.email,
105
+ from,
106
+ to,
107
+ departure
108
+ }, {
109
+ onSuccess: (data) => {
110
+ setDone(data);
111
+ },
112
+ onError: (error) => {
113
+ setFailure(message(error.response?.status));
114
+ }
115
+ });
116
+ };
117
+
118
+ if (done !== undefined) {
119
+ return (
120
+ <Container className="box">
121
+ <Done>
122
+ <AiOutlineCheckCircle />
123
+
124
+ <span>
125
+ { t('routes_order.step2.watch.done', {
126
+ price: done.baseline_price_display,
127
+ from: names.from,
128
+ to: names.to
129
+ }) }
130
+ </span>
131
+ </Done>
132
+ </Container>
133
+ );
134
+ }
135
+
136
+ return (
137
+ <Container className="box">
138
+ <Toggle type="button" aria-expanded={ open } onClick={ () => setOpen(!open) }>
139
+ <AiOutlineBell />
140
+
141
+ <span>{ t('routes_order.step2.watch.toggle') }</span>
142
+ </Toggle>
143
+
144
+ { open && (
145
+ <Panel>
146
+ <Lead>{ t('routes_order.step2.watch.lead') }</Lead>
147
+
148
+ <Journey>
149
+ { t('routes_order.step2.watch.journey', {
150
+ from: names.from,
151
+ to: names.to,
152
+ date: departure
153
+ }) }
154
+ </Journey>
155
+
156
+ <form onSubmit={ handleSubmit(onSubmit) }>
157
+ <Fields>
158
+ <Field>
159
+ <label htmlFor={ id('email') }>{ t('routes_order.step2.watch.email') }</label>
160
+
161
+ <input type="email" id={ id('email') } { ...register('email', Validate('required|email|max_length:255', t)) } />
162
+ </Field>
163
+
164
+ <Action>
165
+ <Button
166
+ type="submit"
167
+ size="medium"
168
+ loading={ isPending }
169
+ text={ t('routes_order.step2.watch.submit') }
170
+ noMargin
171
+ />
172
+ </Action>
173
+ </Fields>
174
+
175
+ { Display(errors.email) }
176
+
177
+ { failure !== undefined && <Failure role="alert">{ failure }</Failure> }
178
+
179
+ <Privacy>{ t('routes_order.step2.watch.privacy') }</Privacy>
180
+ </form>
181
+ </Panel>
182
+ ) }
183
+ </Container>
184
+ );
185
+ };
186
+
187
+ export default Watch;
@@ -0,0 +1,118 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Claude - Date: 2026-08-20
5
+ *
6
+ * A strip under the results, not a panel over them. This sits at the end of
7
+ * the list - where somebody has read every price and none of them was the
8
+ * one they wanted - so it has to be findable without ever competing with
9
+ * the trips themselves for attention.
10
+ */
11
+ export const Container = styled.div`
12
+ margin-top: 15px;
13
+
14
+ &.box {
15
+ padding: 12px 15px;
16
+ }
17
+ `;
18
+
19
+ export const Toggle = styled.button`
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ width: 100%;
24
+ text-align: left;
25
+ font-size: ${ props => props.theme.size.s };
26
+ font-weight: 700;
27
+ color: ${ props => props.theme.font.normal };
28
+
29
+ & > svg {
30
+ flex: 0 0 auto;
31
+ font-size: ${ props => props.theme.size.l };
32
+ color: ${ props => props.theme.primary.normal };
33
+ }
34
+
35
+ &:hover {
36
+ color: ${ props => props.theme.primary.normal };
37
+ }
38
+ `;
39
+
40
+ export const Panel = styled.div`
41
+ margin-top: 12px;
42
+ `;
43
+
44
+ export const Lead = styled.p`
45
+ margin: 0 0 4px;
46
+ font-size: ${ props => props.theme.size.s };
47
+ `;
48
+
49
+ export const Journey = styled.p`
50
+ margin: 0 0 12px;
51
+ font-size: ${ props => props.theme.size.xs };
52
+ color: ${ props => props.theme.font.faded };
53
+ `;
54
+
55
+ /**
56
+ * The field and its action on one line from the tablet up, stacked on a
57
+ * phone - the same shape the coupon box uses, so the two read as siblings.
58
+ */
59
+ export const Fields = styled.div`
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 10px;
63
+
64
+ @media (min-width: 640px) {
65
+ flex-direction: row;
66
+ align-items: flex-end;
67
+ }
68
+ `;
69
+
70
+ export const Field = styled.div`
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 6px;
74
+ flex: 1;
75
+ min-width: 0;
76
+ font-weight: 700;
77
+ font-size: calc(${ props => props.theme.size.m } - 1px);
78
+ `;
79
+
80
+ export const Action = styled.div`
81
+ flex: 0 0 auto;
82
+ `;
83
+
84
+ export const Privacy = styled.p`
85
+ margin: 10px 0 0;
86
+ font-size: ${ props => props.theme.size.xs };
87
+ color: ${ props => props.theme.font.faded };
88
+ `;
89
+
90
+ /**
91
+ * Every answer the server can give, said out loud.
92
+ *
93
+ * Edited: Claude - Date: 2026-08-20
94
+ *
95
+ * The contact form swallowed submissions for months because a failure had
96
+ * nowhere to render. A 404 (nobody prices this pair), a 429 (throttled) and
97
+ * an unreachable API all end up here rather than in the console.
98
+ */
99
+ export const Failure = styled.p`
100
+ margin: 10px 0 0;
101
+ font-size: ${ props => props.theme.size.s };
102
+ color: ${ props => props.theme.font.error };
103
+ `;
104
+
105
+ export const Done = styled.div`
106
+ display: flex;
107
+ gap: 8px;
108
+ align-items: flex-start;
109
+ font-size: ${ props => props.theme.size.s };
110
+ line-height: 1.4;
111
+
112
+ & > svg {
113
+ flex: 0 0 auto;
114
+ margin-top: 2px;
115
+ font-size: ${ props => props.theme.size.l };
116
+ color: ${ props => props.theme.primary.normal };
117
+ }
118
+ `;
@@ -27,6 +27,19 @@ interface Props {
27
27
  * claiming travellers rated this journey when they rated the company. When
28
28
  * an operator has no published rating - below the display threshold - it is
29
29
  * simply absent, exactly as it is on the page.
30
+ *
31
+ * Edited: Claude - Date: 2026-08-20
32
+ *
33
+ * The trip now ALSO carries its own AggregateRating - and that is not the
34
+ * claim the paragraph above forbids, because `facts.rating` is a different
35
+ * number: obtapi aggregates the published reviews of exactly the routes in
36
+ * this timetable (Facts::rating, the same set Reviews\BrowseController's
37
+ * `pair` mode reads), not a company-wide score stretched over one leg.
38
+ * Travellers who booked these very coaches wrote those reviews, and every
39
+ * card on the page links to the operator profile where they are read in
40
+ * full. Below the display threshold obtapi sends null and nothing is
41
+ * emitted here - an AggregateRating of nothing is a guideline violation,
42
+ * worse than none.
30
43
  */
31
44
  const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
32
45
  const schedule = facts.schedule ?? [];
@@ -62,6 +75,19 @@ const Schema = ({ facts, t }: Props): (JSX.Element | null) => {
62
75
  departureBusStop: { '@type': 'BusStop', name: facts.from },
63
76
  arrivalBusStop: { '@type': 'BusStop', name: facts.to },
64
77
 
78
+ // Edited: Claude - Date: 2026-08-20
79
+ // The pair's own score - see the header comment. Null below the
80
+ // display threshold, and then the property simply does not exist.
81
+ ...(facts.rating ? {
82
+ aggregateRating: {
83
+ '@type': 'AggregateRating',
84
+ ratingValue: facts.rating.average,
85
+ reviewCount: facts.rating.count,
86
+ bestRating: 5,
87
+ worstRating: 1
88
+ }
89
+ } : {}),
90
+
65
91
  ...(providers.length > 0 ? {
66
92
  provider: providers.map(item => organisation(item.name, item.rating))
67
93
  } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.31.4",
3
+ "version": "1.32.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/services.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query';
2
+ import { AxiosError } from 'axios';
2
3
  import { apiClient } from '@autobusal/providers';
3
4
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
4
5
  import { FoundData, FlexOffer } from '@autobusal/providers/types/routes';
5
6
  import { CouponData } from '@autobusal/providers/types/orders';
6
7
  import { BillingData, PersonData } from '@autobusal/providers/types/persons';
7
- import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection, AlternativesData } from './types';
8
+ import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection, AlternativesData, PriceAlertRequest, PriceAlertData } from './types';
8
9
 
9
10
  export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundData[]> => (
10
11
  useQuery({
@@ -273,3 +274,42 @@ export const useGetFlex = (
273
274
  )
274
275
  })
275
276
  );
277
+
278
+ /**
279
+ * Watch a fare: email me if the price for this pair and date drops.
280
+ *
281
+ * Edited: Claude - Date: 2026-08-20
282
+ *
283
+ * Typed against AxiosError rather than Error, and that is the point of the
284
+ * signature: the caller has four different things to say depending on the
285
+ * status (404 nobody prices this pair, 422 bad address, 429 throttled,
286
+ * anything else unreachable), and with a bare `Error` the status would be
287
+ * an unchecked cast at the one place it matters.
288
+ *
289
+ * NO `token` FIELD, ever. obtapi validates it as `prohibited` on this
290
+ * endpoint - it identifies a mobile device on the push half of the same
291
+ * feature - so sending one turns a working request into a 422.
292
+ *
293
+ * `Content-Language` is not set here: apiClient's request interceptor
294
+ * already sends the live UI language on every call, and the server reads
295
+ * the alert's language off it. That is the honest answer to "which language
296
+ * should these emails be in" - the one they are reading the site in right
297
+ * now - and one nobody can get wrong in a form.
298
+ */
299
+ export const usePostPriceAlert = (): UseMutationResult<PriceAlertData, AxiosError, PriceAlertRequest, unknown> => (
300
+ useMutation({
301
+ mutationKey: ['watch-price-alert'],
302
+ mutationFn: async (data: PriceAlertRequest) => (
303
+ await apiClient
304
+ .post('/api/alerts/price/email', data)
305
+ .then(response => (
306
+ response.data
307
+ ))
308
+ ),
309
+
310
+ // the two answers that are not "try again" - an unpriced pair and a
311
+ // rejected address - are both final, and retrying a 429 is the one
312
+ // thing guaranteed to keep it a 429
313
+ retry: false
314
+ })
315
+ );
package/types.ts CHANGED
@@ -86,4 +86,34 @@ export interface AddonsSelection {
86
86
  // price are the server's to decide (Libraries\Orders\Flex), and a client
87
87
  // that sent either would be ignored.
88
88
  flex: boolean
89
- }
89
+ }
90
+ /**
91
+ * "Watch this fare" - see Found/Watch/Watch.tsx.
92
+ *
93
+ * Edited: Claude - Date: 2026-08-20
94
+ *
95
+ * The form is one field because the pair and the date are the search that
96
+ * has already been run. `token` is NOT here and must never be: obtapi's
97
+ * Alerts\PriceController::email() validates it as `prohibited`, since that
98
+ * field identifies a mobile device on the push half of the same feature and
99
+ * an alert notifies exactly one way.
100
+ */
101
+ export interface PriceAlertForm {
102
+ email: string
103
+ }
104
+
105
+ export interface PriceAlertRequest extends PriceAlertForm {
106
+ from: string
107
+ to: string
108
+ /** d/m/Y, the format the search form already holds a date in */
109
+ departure: string
110
+ }
111
+
112
+ export interface PriceAlertData {
113
+ id: number
114
+
115
+ // the fare the alert was armed against, already formatted in the label's
116
+ // currency by the server - the customer is told the number being watched
117
+ // rather than being left to guess which of the prices on screen it was
118
+ baseline_price_display: string
119
+ }