@autobusal/routes-order 1.31.5 → 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 +8 -0
- package/Found/Found.tsx +36 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/package.json +1 -1
- package/services.ts +41 -1
- package/types.ts +31 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 1.31.5
|
|
4
12
|
|
|
5
13
|
### Added
|
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
|
+
`;
|
package/package.json
CHANGED
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
|
+
}
|