@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 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.
@@ -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={ setAddonFlex }
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 && (
@@ -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 } reloadDocument to={ link(step1.from, step1.to, item.day) }>
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> }
@@ -44,17 +44,16 @@ export const Options = styled.div`
44
44
  `;
45
45
 
46
46
  /**
47
- * Edited: Ferjolt Ozuni - Date: 2026-08-01
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
48
48
  *
49
- * These links carry `reloadDocument` at the call site. RoutesOrder holds
50
- * the search in component state and Step1 only kicks off a search when it
51
- * has not already redirected, so a client-side navigation changes the URL
52
- * and nothing else - the old, empty result set just stays on screen.
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
- * A full load re-enters the flow from the URL exactly as arriving from a
55
- * search engine does. Teaching RoutesOrder to resync its state from
56
- * changing params would be the tidier fix, but that is the main booking
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
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"