@autobusal/routes-order 1.25.0 → 1.27.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 +21 -0
- package/Checkout/Checkout.tsx +41 -2
- package/Found/Empty/Empty.tsx +1 -2
- package/Found/Empty/styles.ts +8 -9
- package/Found/Found.tsx +14 -1
- package/RoutesOrder.tsx +54 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.27.0
|
|
4
|
+
|
|
5
|
+
**`reloadDocument` is gone — the results follow the URL.**
|
|
6
|
+
|
|
7
|
+
The search lives on `Step1`, which is **not mounted** once results are on screen. So a link that changed the address bar was read by nobody: the URL said one journey and the results underneath still showed another. That is why the empty-search suggestions threw away the whole application on every click.
|
|
8
|
+
|
|
9
|
+
`RoutesOrder` now watches the search spec the URL describes and re-runs when it changes. Keyed on **the spec**, not on a "have we searched yet" flag — a flag can only answer once, a key re-answers whenever the question changes and stays quiet while it does not. It cannot loop either: writing state does not alter the URL the key derives from. The first search is still Step1's, so it does not run twice.
|
|
10
|
+
|
|
11
|
+
Choosing a different search also clears the previously chosen coach. Leaving it set would let the step guards resolve to a checkout for a route the buyer never picked.
|
|
12
|
+
|
|
13
|
+
Verified client-side: navigating from Tirana → Thessaloniki (10 trips) to Durres → Tirana updated the header and the results with the page never reloading.
|
|
14
|
+
|
|
15
|
+
## 1.26.0
|
|
16
|
+
|
|
17
|
+
**Funnel events on every step**: the result list, the coach chosen, checkout reached, the Flexible Ticket ticked or unticked, and the value stashed for the purchase event.
|
|
18
|
+
|
|
19
|
+
- `view_item_list` is keyed on the **result set**, not on render — this component re-renders on every filter tick and sort change, and announcing again each time would report a dozen searches for one.
|
|
20
|
+
- `begin_checkout` lives in `Checkout` rather than `RoutesOrder`, because that screen already derives the currency from its own formatted total (the public settings payload deliberately carries none) and it mounts exactly once per arrival.
|
|
21
|
+
- The **add-on attach rate** is the one input the Flexible Ticket pricing decision has been waiting on — it cannot be banded by window without knowing how often each window is actually bought.
|
|
22
|
+
- One `item()` mapping shared by every event, keyed on the **route code** rather than the numeric id, so a report can be reconciled against something a human recognises.
|
|
23
|
+
|
|
3
24
|
## 1.25.0
|
|
4
25
|
|
|
5
26
|
**Reviews on the route-pair page.** A new section between the timetable and the internal links, with its own nav anchor.
|
package/Checkout/Checkout.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
1
|
+
import { useCallback, useState, useEffect } from 'react';
|
|
2
2
|
import { TFunction } from 'i18next';
|
|
3
3
|
import { useForm } from 'react-hook-form';
|
|
4
4
|
import { useNavigate } from 'react-router-dom';
|
|
@@ -25,6 +25,8 @@ import { TotalData } from '../types';
|
|
|
25
25
|
import { useUserStore } from '@autobusal/providers/stores/user';
|
|
26
26
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
27
27
|
import { usePostOrder, useGetFlex } from '../services';
|
|
28
|
+
import { addon as commerceAddon, beginCheckout, stash } from '@autobusal/providers/Setup/commerce';
|
|
29
|
+
import { item as commerceItem } from '../commerce';
|
|
28
30
|
|
|
29
31
|
interface Props {
|
|
30
32
|
values?: PersonData
|
|
@@ -129,6 +131,21 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
129
131
|
value: total.value + addonsValue
|
|
130
132
|
};
|
|
131
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
136
|
+
*
|
|
137
|
+
* Here rather than in RoutesOrder because this screen already derives the
|
|
138
|
+
* currency from its own formatted total - the public settings payload
|
|
139
|
+
* carries none - and because it mounts exactly once per arrival at
|
|
140
|
+
* checkout, which is the cadence this event wants.
|
|
141
|
+
*/
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const items = [commerceItem(step2), ...(step3 ? [commerceItem(step3)] : [])];
|
|
144
|
+
|
|
145
|
+
beginCheckout(total.value, items, currency);
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
132
149
|
const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, coupon, action, {
|
|
133
150
|
whatsapp: addonWhatsapp,
|
|
134
151
|
whatsappNumber: addonWhatsappNumber,
|
|
@@ -200,6 +217,21 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
200
217
|
|
|
201
218
|
const hash = step1.type === 'return' ? data.return : data.departure;
|
|
202
219
|
|
|
220
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
221
|
+
//
|
|
222
|
+
// Remembered here because the confirmation page cannot work it out.
|
|
223
|
+
// A card payment leaves the site for the bank and returns on a URL
|
|
224
|
+
// carrying nothing but this hash, and most buyers are guests with no
|
|
225
|
+
// account the order could be read back from. Keyed on the SAME hash
|
|
226
|
+
// the confirmation is reached with, so it cannot be attributed to a
|
|
227
|
+
// different order.
|
|
228
|
+
stash(
|
|
229
|
+
String(hash),
|
|
230
|
+
grandTotal.value,
|
|
231
|
+
[commerceItem(step2), ...(step3 ? [commerceItem(step3)] : [])],
|
|
232
|
+
currency
|
|
233
|
+
);
|
|
234
|
+
|
|
203
235
|
navigate(`/orders/process/${ action }/${ hash }`);
|
|
204
236
|
}
|
|
205
237
|
});
|
|
@@ -244,7 +276,14 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
244
276
|
onChangeWhatsapp={ setAddonWhatsapp }
|
|
245
277
|
onChangeWhatsappNumber={ setAddonWhatsappNumber }
|
|
246
278
|
onChangeTelegram={ setAddonTelegram }
|
|
247
|
-
onChangeFlex={
|
|
279
|
+
onChangeFlex={ (value) => {
|
|
280
|
+
setAddonFlex(value);
|
|
281
|
+
|
|
282
|
+
// The attach rate this records is the one input the Flexible
|
|
283
|
+
// Ticket pricing decision is waiting on - it cannot be banded
|
|
284
|
+
// by window without knowing how often each is actually bought.
|
|
285
|
+
commerceAddon(value, 'Flexible Ticket', FlexData?.price ?? 0, currency);
|
|
286
|
+
} }
|
|
248
287
|
/>
|
|
249
288
|
|
|
250
289
|
{ UserData === undefined && (
|
package/Found/Empty/Empty.tsx
CHANGED
|
@@ -44,7 +44,6 @@ const Empty = ({ step1, data, loading, t }: Props): JSX.Element => {
|
|
|
44
44
|
const city = (item: NearbyCity, side: 'from' | 'to'): JSX.Element => (
|
|
45
45
|
<Option
|
|
46
46
|
key={ item.city.id }
|
|
47
|
-
reloadDocument
|
|
48
47
|
to={ side === 'from'
|
|
49
48
|
? link(item.city.slug, step1.to, step1.departure)
|
|
50
49
|
: link(step1.from, item.city.slug, step1.departure) }
|
|
@@ -56,7 +55,7 @@ const Empty = ({ step1, data, loading, t }: Props): JSX.Element => {
|
|
|
56
55
|
);
|
|
57
56
|
|
|
58
57
|
const day = (item: FoundDay): JSX.Element => (
|
|
59
|
-
<Option key={ item.id }
|
|
58
|
+
<Option key={ item.id } to={ link(step1.from, step1.to, item.day) }>
|
|
60
59
|
<OptionName>{ item.day }</OptionName>
|
|
61
60
|
<OptionMeta>{ t('routes_order.step2.empty.available') }</OptionMeta>
|
|
62
61
|
{ item.from_price_display && <OptionPrice>{ item.from_price_display }</OptionPrice> }
|
package/Found/Empty/styles.ts
CHANGED
|
@@ -44,17 +44,16 @@ export const Options = styled.div`
|
|
|
44
44
|
`;
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Edited: Ferjolt Ozuni - Date: 2026-08-
|
|
47
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
* the search
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* Plain client-side links now. They used to force a full document reload,
|
|
50
|
+
* because the search lived on a Step1 that is not mounted once results are
|
|
51
|
+
* on screen - so a navigation changed the address bar and nothing else, and
|
|
52
|
+
* the old empty result set simply stayed there.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* flow and this is a dead-end page: not worth the risk for the gain.
|
|
54
|
+
* RoutesOrder now follows the URL's search spec, which is the tidier fix
|
|
55
|
+
* the note here previously judged not worth the risk. It became worth it
|
|
56
|
+
* when the same root cause turned up a third time.
|
|
58
57
|
*/
|
|
59
58
|
export const Option = styled(Link)`
|
|
60
59
|
display: flex;
|
package/Found/Found.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
1
|
+
import { useMemo, useState, useEffect } from 'react';
|
|
2
2
|
import { useSearchParams } from 'react-router-dom';
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
4
|
import Dates from './Dates/Dates';
|
|
@@ -13,6 +13,8 @@ import { Results, Sidebar, Listing } from '../styles';
|
|
|
13
13
|
import { useGetAlternatives } from '../services';
|
|
14
14
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
15
15
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
16
|
+
import { viewItemList } from '@autobusal/providers/Setup/commerce';
|
|
17
|
+
import { item as commerceItem } from '../commerce';
|
|
16
18
|
import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
|
|
17
19
|
|
|
18
20
|
interface Props {
|
|
@@ -31,6 +33,17 @@ interface Props {
|
|
|
31
33
|
const OWNED = [ 'sort', 'operators', 'times', 'direct', 'from_stops', 'to_stops', 'features', 'max_price' ];
|
|
32
34
|
|
|
33
35
|
const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave }: Props): JSX.Element => {
|
|
36
|
+
/**
|
|
37
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
38
|
+
*
|
|
39
|
+
* Keyed on the RESULT SET, not on every render. This component re-renders
|
|
40
|
+
* on each filter tick and sort change, and announcing the list again every
|
|
41
|
+
* time would report a dozen searches for one.
|
|
42
|
+
*/
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
viewItemList((data ?? []).map(commerceItem), currencyOf(data ?? []));
|
|
45
|
+
}, [data]);
|
|
46
|
+
|
|
34
47
|
const [ searchParams, setSearchParams ] = useSearchParams();
|
|
35
48
|
const [ open, setOpen ] = useState<boolean>(false);
|
|
36
49
|
|
package/RoutesOrder.tsx
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
2
|
import { useParams, useSearchParams } from 'react-router-dom';
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
4
|
import { AiOutlineSearch, AiOutlineCaretDown, AiOutlineCaretUp } from 'react-icons/ai';
|
|
5
5
|
import { Meta } from '@autobusal/common';
|
|
6
6
|
import { useSystemTheme } from '@autobusal/hooks';
|
|
7
7
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
8
|
+
import { selectItem } from '@autobusal/providers/Setup/commerce';
|
|
9
|
+
import { item as commerceItem } from './commerce';
|
|
10
|
+
import { currencyOf } from './Found/refine';
|
|
8
11
|
import RoutesSearch from '@autobusal/routes-search';
|
|
9
12
|
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
10
13
|
import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
|
|
11
14
|
import Sections from './Sections/Sections';
|
|
12
15
|
import Step1 from './Step1/Step1';
|
|
16
|
+
import prepare from './Step1/prepare';
|
|
13
17
|
import Step2 from './Step2';
|
|
14
18
|
import Step3 from './Step3';
|
|
15
19
|
import Checkout from './Checkout/Checkout';
|
|
@@ -122,6 +126,53 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
122
126
|
}, { replace });
|
|
123
127
|
};
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Follow the URL when it describes a different search.
|
|
131
|
+
*
|
|
132
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
133
|
+
*
|
|
134
|
+
* THE SEARCH LIVES ON STEP 1, WHICH IS NOT MOUNTED once results are on
|
|
135
|
+
* screen - `showStep1` is false from step 2 onwards. So a link that
|
|
136
|
+
* changed the address bar was read by nobody: the URL said one journey
|
|
137
|
+
* and the results underneath still showed another. That is why the
|
|
138
|
+
* empty-search suggestions carried `reloadDocument`, throwing away the
|
|
139
|
+
* whole application to do what a route change should have done by itself.
|
|
140
|
+
*
|
|
141
|
+
* Keyed on the SPEC the URL describes rather than on a "have we searched
|
|
142
|
+
* yet" flag. A flag can only ever answer once; a key re-answers whenever
|
|
143
|
+
* the question changes and stays quiet while it does not, which is the
|
|
144
|
+
* behaviour that was actually wanted. It also cannot loop: writing state
|
|
145
|
+
* does not alter the URL the key is derived from.
|
|
146
|
+
*
|
|
147
|
+
* The FIRST search is still Step1's - it owns the empty state a visitor
|
|
148
|
+
* arrives at, and duplicating that here would run it twice.
|
|
149
|
+
*/
|
|
150
|
+
const urlSpec = useMemo(
|
|
151
|
+
() => prepare(undefined, params, searchParams, settings.preferences.defaultLocations),
|
|
152
|
+
[params, searchParams, settings.preferences.defaultLocations]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Only a URL that actually carries a complete search - the bare
|
|
156
|
+
// /bus-lines form describes nothing to follow.
|
|
157
|
+
const urlKey = params.departure !== undefined ? JSON.stringify(urlSpec) : null;
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (urlKey === null || step1 === undefined || urlKey === JSON.stringify(step1)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setStep1(urlSpec);
|
|
165
|
+
|
|
166
|
+
// the previous journey's chosen coach is not on offer for a different
|
|
167
|
+
// search, and leaving it set would let the guards resolve to a checkout
|
|
168
|
+
// for a route the buyer never picked
|
|
169
|
+
setStep2(undefined);
|
|
170
|
+
setStep3(undefined);
|
|
171
|
+
|
|
172
|
+
goto(null, true);
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [urlKey]);
|
|
175
|
+
|
|
125
176
|
const onSearchDate = (type: ('departure' | '_return'), day: string): void => {
|
|
126
177
|
if (step1 !== undefined) {
|
|
127
178
|
setStep1({
|
|
@@ -142,6 +193,8 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
142
193
|
const onStep2 = (data: FoundData): void => {
|
|
143
194
|
setStep2(data);
|
|
144
195
|
|
|
196
|
+
selectItem(commerceItem(data), currencyOf([data]));
|
|
197
|
+
|
|
145
198
|
goto(step1?.type === 'return' ? 'return' : 'checkout');
|
|
146
199
|
};
|
|
147
200
|
|
package/commerce.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { CommerceItem } from '@autobusal/providers/Setup/commerce';
|
|
2
|
+
import { FoundData } from '@autobusal/providers/types/routes';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A coach, as the analytics layer sees it.
|
|
6
|
+
*
|
|
7
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
8
|
+
*
|
|
9
|
+
* One mapping used by every event, so a route cannot be identified one way
|
|
10
|
+
* in `select_item` and another in `purchase` - which is exactly how a funnel
|
|
11
|
+
* ends up unable to join its own steps together.
|
|
12
|
+
*
|
|
13
|
+
* The ROUTE CODE is the id rather than the numeric key: it is what appears
|
|
14
|
+
* on the ticket, in the timetable and on the operator's own paperwork, so a
|
|
15
|
+
* report built on it can be reconciled against something a human recognises.
|
|
16
|
+
*/
|
|
17
|
+
export const item = (route: FoundData): CommerceItem => ({
|
|
18
|
+
item_id: String(route.code ?? route.id),
|
|
19
|
+
item_name: `${ route.locations?.from?.city?.name ?? '' } - ${ route.locations?.to?.city?.name ?? '' }`.trim(),
|
|
20
|
+
item_brand: route.operator?.company,
|
|
21
|
+
price: Number(route.price?.value ?? 0),
|
|
22
|
+
quantity: 1
|
|
23
|
+
});
|
|
24
|
+
|