@autobusal/routes-order 1.34.4 → 1.35.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 DELETED
@@ -1,866 +0,0 @@
1
- ## 1.34.4 - 2026-08-29
2
-
3
- - The date strip and the search-again panel WRITE THE SEARCH INTO THE URL, so a refresh keeps the chosen day and a shared link opens the results it was copied from (audit A3-02f).
4
-
5
- - A results URL carrying a real return date is read as a round trip whatever its type segment says - it used to sell a one-way silently (audit A3-07c).
6
-
7
- - An agent's checkout previews what the card will actually be charged: the seller's own commission is stated as its own line and the surcharge is computed on the NET, the same base obtapi charges on. An agent was shown 67.05 against a 60.67 charge (audit A4-07).
8
-
9
- - An invalid coupon says so, inline and announced - a code that did not exist reached no handler at all, so the commonest case (a typo) changed nothing on screen (audit A3-10c).
10
-
11
- - The details expander gains the ITINERARY and a line on cancellation and changes; the quick-pick chips format the duration instead of printing a bare "04:30" that reads as a departure time (audit A3-02h, A3-02j).
12
-
13
- ## 1.34.3 - 2026-08-29
14
-
15
- - Staff sellers (agent/subagent/operator/employee/admin) get an OPTIONAL passenger email box at checkout, so a counter-sold ticket reaches the passenger instead of only the seller and operator (audit A4-05, owner decision). obtapi already read {type}{i}_email/_notify_email; it now validates them too.
16
-
17
- ## 1.34.2 - 2026-08-29
18
-
19
- - With more than one card gateway available, each Credit Card button carries its processor's name - they used to render byte-identical (audit A0-06).
20
-
21
- # Changelog
22
-
23
- ## 1.34.0 (2026-08-29)
24
-
25
- - The bare /bus-lines form suggests an origin near the visitor and no longer pre-fills a destination; a URL that names one still wins.
26
-
27
- ## 1.33.10 (2026-08-29)
28
-
29
- - The return date falls back to the outbound when the URL carries no real one ('none' on a one-way link), so switching a collapsed search to Round Trip opens the calendar on the month being travelled and cannot offer a day before the departure.
30
- - The collapsed search panel sits above the step markers: its backdrop-filter creates a stacking context, so the date picker used to draw underneath them.
31
-
32
- ## 1.33.9 (2026-08-28)
33
-
34
- - Return-leg empty state fetches RETURN alternatives (swapped pair + return date) and its suggested-day links fill the return slot instead of rewriting the departure; nearby-city swaps map back to the URL's outbound pair (sweep #50).
35
- - Checkout money parsing survives the server's space-thousands format: currency is the last token, coupon amounts strip grouping spaces - totals over 1000 no longer corrupt the rendered total or the GA4 values (sweep #51).
36
- - useGetFlex takes the coupon-adjusted total so the quoted flex price matches what the server bills on a couponed booking (sweep #53).
37
- - Passenger age bands match the server rules: adult 12+, child 2-12, baby under 2 (sweep #48).
38
-
39
- ## 1.33.8 (2026-08-28)
40
-
41
- - usePostOrder replays the stored referral token as `referral`, so widget/link arrivals attribute their affiliate at checkout.
42
-
43
- ## 1.33.7 (2026-08-26)
44
-
45
- - Seat-selection add-on: checkout reads settings addons.seats - picking turns optional with the fee on the CTA and a live summary line; submission strips unpicked (zero) seat fields and sends addon_seats only when a hand-picked seat remains. Fixes the return-leg picked-seat list rebuilding from the departure's on re-pick.
46
-
47
- ## 1.33.6
48
-
49
- ### Fixed
50
-
51
- - **The return date strip asks the API for return-leg pricing** (new leg parameter) - it used to quote one-way fares over days the buyer pays the round-trip difference for. (Audit BOOK-02-b; needs obtapi >= 2.4.63.)
52
-
53
- ## 1.33.5
54
-
55
- ### Fixed
56
-
57
- - **The summary's TICKET line shows the gross fare.** With a coupon it used to show the discounted figure AND a discount line - "TICKET 40 / DISCOUNT -5 / TOTAL 40", arithmetic that doesn't add up on screen. (Audit BOOK-09-b.)
58
-
59
- ## 1.33.4
60
-
61
- ### Added
62
-
63
- - **Complimentary in the web checkout.** An operator/employee selling on their OWN operator's route is offered the zero-cost Complimentary option the mobile app already had - the route-aware flag comes from `/api/funds/allowed?route=...` and the ownership rule is re-checked server-side at payment time. (Audit decision BOOK-07; needs obtapi >= 2.4.37.)
64
-
65
- ## 1.33.3
66
-
67
- ### Fixed
68
-
69
- - **Checkout swallowed every booking error.** A 4xx from order creation - a seat taken while the form was open, the unpaid-hold limit, a route gone unavailable - left the buyer on the filled form with no message. It now surfaces the API's message. (Audit BOOK-13-c.)
70
-
71
- ## 1.33.2
72
-
73
- ### Fixed
74
-
75
- - **A signed-in customer with an incomplete profile is now asked for what is missing**, in the same billing fields a guest already fills, instead of reaching an API that had nothing to put in three NOT NULL columns. Only the gaps are asked for: a complete profile sees no change at all.
76
-
77
- This is not a rare state. A self-registered visitor has a name and nothing else, and the form that collects phone, address and city is reachable only by admin-created accounts — so the customer could not have completed it in advance even if asked to.
78
-
79
- - `Billing` takes an optional `only` list; undefined keeps the whole guest block exactly as it was. Field rules follow whichever profile the value will be stored on, so the form never accepts more than the API will keep.
80
-
81
- - Checkout treats "profile not loaded yet" as unknown rather than empty — the user store persists a redacted stub and re-fetches on mount, so the other reading would flash required fields at customers whose profile is complete.
82
-
83
- ## 1.33.1
84
-
85
- ### Added
86
-
87
- - **A pair with no future scheduled service says so**, with the date it last ran, instead of showing a facts block that quietly omits frequency and a departure list that is empty on every date the visitor tries. The links to journeys that do run stay prominent, so the page converts rather than dead-ends. No `noindex`: an honest page about a seasonal route is not thin content, and a route that stopped in December will want its ranking back in June.
88
-
89
- ## 1.33.0
90
-
91
- ### Added
92
-
93
- - **The pair page is a page about the journey, not just a search box over it.** `/routes/search/facts` now carries how often the pair is served (the union of days a traveller can leave on, plus departures per week), how many of the scheduled services run non-stop, how many travel overnight, the cities in between, what is on board, the two countries, and the dearest fare alongside the cheapest - all read off the timetable, all riding the existing 6h cache, none of it estimated.
94
- - Every list-shaped fact carries an `every` flag beside it, and the copy is chosen from that flag. Six of the ten Tirana-Thessaloniki coaches are non-stop and they carry different equipment, so "every bus calls at Korca" and "some buses also call at Korca" are different claims; obtapi says which one the data supports rather than leaving the wording to guess.
95
- - **A visible FAQ** (`Faq/`), built from `Facts/questions` - the same array the `FAQPage` structured data is emitted from, so the answer Google quotes is the string the page renders, character for character, in all fifteen languages. Native `<details>`, no state: the answer is in the DOM whether it is open or shut, which is what a prerendered snapshot and a crawler that does not click both need. Questions with no data behind them are not asked.
96
- - **`BusTrip` gained an `itinerary`** - but only where every service on the pair calls at every stop listed. An itinerary is a statement about *the* trip, and on a pair where most coaches run non-stop there is no single itinerary to state. Departure and arrival stops carry a `PostalAddress` where obtapi knows the country.
97
- - **A "travelling back?" link**, from the new `links.reverse`. Pairs are directional, so the return leg is a different page with its own timetable - and it belonged to neither "popular from" nor "popular to", being a pair from the destination back to the origin. Null, and absent, when nobody sells it.
98
-
99
- ### Fixed
100
-
101
- - **"3h 0m" in the facts grid, "3h" in the answer below it.** The facts block carried its own copy of the duration formatter without the whole-hour rule the FAQ answers use, so a three-hour coach was described two ways on one page. One exported formatter now.
102
- - **The departure count called itself the wrong thing.** `facts.departures` counts the distinct coaches in the timetable, and a single daily one of them makes seven departures a week - so "10 scheduled departures" beside the new "every day, 70 scheduled departures a week" read as a contradiction rather than as two facts. The label and the FAQ answer now say *scheduled services* in all fifteen languages; the number they print is unchanged.
103
- - **"There are 1 scheduled departures"** - the sentence a thin pair got from the FAQ schema for as long as the block has existed, and a thin pair is most of them. A separate key rather than an i18next plural rule; the plural categories of the eight Slavic locales here are not something one English-shaped rule gets right.
104
-
105
- ## 1.32.1
106
-
107
- ### Added
108
-
109
- - **A saved-passenger chooser and a "remember this passenger" checkbox**, per passenger rather than per order, because "remember me but not the person I am buying for" is the ordinary case and one control per booking cannot say it. The list is fetched once for the whole checkout, not once per traveller.
110
-
111
- Picking applies the whole person, not the filled parts: a passport left from a previous choice is cleared, because the customer has said who is sitting in that seat. Only fields the route actually collects are written - react-hook-form submits values set for names it never registered, so writing the rest would post a WhatsApp number to a route that never asked for one.
112
-
113
- A journey that needs a field the saved profile lacks says so in the form's own words. `missing` absent means *not measured* and is not treated as "nothing missing".
114
-
115
- ## 1.32.0
116
-
117
- ### Added
118
-
119
- - **"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.
120
- - 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.
121
- - 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.
122
-
123
- ## 1.31.5
124
-
125
- ### Added
126
-
127
- - **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.
128
-
129
- ## 1.31.4
130
-
131
- ### Fixed
132
-
133
- - **The checkout form's fields have accessible names.** Passenger and billing inputs had no `id`/`for` pairing, no `aria-label` and no wrapping label - programmatically anonymous on the one form that decides whether a sale completes. Ids are `useId`-prefixed, which is what stops `adult1_*`, `adult2_*` and `child1_*` colliding; a hardcoded id would have traded one accessibility bug for another.
134
- - Coupon code and the add-on phone fields get an `aria-label`, having only ever had a placeholder - and a placeholder disappears the moment someone types.
135
-
136
- ## 1.31.3
137
-
138
- ### Fixed
139
-
140
- - **The results page's facts/schedule block now renders on the very first screen**, not only once a date has been picked. `/bus-lines/:from/:to` - the dateless URL the sitemap lists and the prerender crawl snapshots - was always on step 1, so `<Sections>` (price/duration/departures + the per-route schedule table + the pair's JSON-LD) never mounted for a single search engine or non-JS crawler. It now also renders on step 1; the bare `/bus-lines` search form (no pair in the URL) is unaffected since it has nothing to fetch.
141
-
142
- ## 1.31.2
143
-
144
- ### Changed
145
-
146
- - **The route card and booking summary show the operator's `display_name`**, not `company` - an operator that set a brand name (`@autobusal/providers` 1.34.0) now appears under that name in search results and the booking summary, same as everywhere else it's now shown.
147
-
148
- ## 1.31.1
149
-
150
- ### Changed
151
-
152
- - **One payment method means one button.** The picker no longer renders when
153
- there is nothing to pick, and the button reads "Pay Now" rather than "Make
154
- Payment" - a heading over a single option the buyer had to press before
155
- pressing the real one was a click that only ever had one answer, on the
156
- last screen before paying.
157
-
158
- ## 1.31.0
159
-
160
- ### Changed
161
-
162
- - **The checkout Order Summary stacks the legs vertically and numbers them**
163
- — "Trip 1" above the outbound, "Trip 2" above the return.
164
-
165
- They sat side by side from 768px up, which was written when the summary
166
- spanned the page. It has lived in the checkout *sidebar* since 1.11.0 — a
167
- narrow column — so two legs abreast left each about half the width a single
168
- line like "Terminali Lindor Interurban dhe Nderkombetar i Autobusave (TEG)"
169
- needs, and nothing said which was which. Vertical at every width now, in the
170
- order they are travelled.
171
-
172
- **The number is only rendered on a round trip.** A one-way has nothing to
173
- enumerate, and a lone "Trip 1" would send the reader looking for a Trip 2
174
- that was never bought. `Checkout/Sidebar` is the one component that knows
175
- whether a second leg exists, so it is the one that decides — `About` just
176
- takes an optional `number`.
177
-
178
- "Trip" rather than "Segment": segment is airline jargon, and this is the
179
- last screen before somebody pays for a bus ticket.
180
-
181
- ## 1.30.1
182
-
183
- ### Fixed
184
-
185
- - **Step 3 could never list a single return leg.** `useGetReturns` spread the
186
- search form straight into the query string, so the return date went over as
187
- `_return` — the form's name for it, because `return` is a reserved word in
188
- JS — while the API validates `return`. Every request answered
189
- `422 The return field is required`, and "Select a Return Schedule" was empty
190
- for every round trip ever searched. The order builder in the same file has
191
- always done this mapping (`order.return = step1._return`); only this call
192
- was missed.
193
-
194
- Paired with an obtapi fix — `Routes\Search\Returns::find()` returned nothing
195
- on its success path — so the step was broken twice over and neither failure
196
- could be seen past the other.
197
-
198
- ## 1.30.0
199
-
200
- ### Added
201
-
202
- - **The SMS booking-confirmation add-on at checkout**, with its own number
203
- field prefilled from the passenger phone already collected — somebody
204
- booking for a relative wants the text to reach the traveller. The
205
- description says the free route alerts are not what is being sold here.
206
- - **A line under the passenger phone field** saying what the number is used
207
- for: reaching you about this trip, and service messages if the departure
208
- changes — never marketing. The number was always collected so a driver
209
- could ring it; texting it is a different use of the same data, and saying
210
- so at the point of collection is what makes it consent rather than an
211
- assumption.
212
- ## 1.29.0
213
-
214
- ### Removed
215
-
216
- - **The reviews block on the route-pair page.** A review wall sat in the
217
- middle of a page whose job is to compare departures. What a reader needs
218
- there about reputation is one number per operator, which the result cards
219
- already carry; the reviews themselves belong on the operator's own page,
220
- where somebody who wants to read them has gone looking for them. The `pair`
221
- mode of the reviews API is untouched — nothing calls it from here now.
222
- - The "Reviews" entry in the in-page nav, with it.
223
-
224
- ## 1.28.0
225
-
226
- ### Added
227
-
228
- - **Overnight trips now say so.** A result card that arrives on a later day
229
- prints the calendar day under BOTH ends and a `+1` against the arrival
230
- time; the pair-page timetable carries the same marker. "22:30 → 06:00" is
231
- the one thing on a result card that can be read as the exact opposite of
232
- what it means, and the duration beside it does not fix that because nobody
233
- reads a duration to work out a date.
234
- - Nothing is added to a same-day card. A date under every arrival would make
235
- the overnight ones stop standing out, which is the only reason this exists.
236
-
237
- ### Changed
238
-
239
- - The day comes from the timetable's own midnight markers, computed
240
- server-side, never from "the arrival looks earlier than the departure" —
241
- which is wrong for exactly the cross-border journeys whose two ends keep
242
- different clocks.
243
-
244
- ## 1.27.0
245
-
246
- **`reloadDocument` is gone — the results follow the URL.**
247
-
248
- 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.
249
-
250
- `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.
251
-
252
- 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.
253
-
254
- Verified client-side: navigating from Tirana → Thessaloniki (10 trips) to Durres → Tirana updated the header and the results with the page never reloading.
255
-
256
- ## 1.26.0
257
-
258
- **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.
259
-
260
- - `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.
261
- - `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.
262
- - 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.
263
- - 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.
264
-
265
- ## 1.25.0
266
-
267
- **Reviews on the route-pair page.** A new section between the timetable and the internal links, with its own nav anchor.
268
-
269
- Renders nothing below the display threshold, so a pair without enough reviews does not gain an empty box.
270
-
271
- ## 1.24.0
272
-
273
- **The booking wizard has real URLs.** The step now lives in the URL and `current` is derived from it, rather than the reverse.
274
-
275
- Before: the wizard held the step in component state and never touched the address bar, so the URL still said "search" while the buyer was on checkout. Nothing in the flow was shareable, a refresh dropped you back to the search, and Back did not step back through the wizard — it left it entirely, because no step had ever created a history entry.
276
-
277
- - A **search param** (`?step=checkout` / `?step=return`), not a path segment. `/bus-lines/:from/:to` is a crawlable page in the sitemap, and child routes would put near-identical URLs in front of a crawler for what is UI state. The search spec is already in the path on the 9-segment route, so between the two the whole position is in the URL.
278
- - **Guards, not a lookup.** A step is honoured only when the state it needs exists: a cold `?step=checkout` has no chosen route, so it falls back to results and clears the stale param rather than rendering half a screen.
279
-
280
- **Bug fixed on the way.** `Checkout` chose its back target with `step1._return ? 3 : 2`. On the 9-segment URL a one-way trip carries the literal segment `none` as its return date — a truthy string — so **every one-way booking's Back button aimed at the return-leg step**, which that booking does not have. Now keyed on `step1.type`, and guarded again in `onBack` so a step nobody can honour never reaches the URL.
281
-
282
- Verified: results → checkout writes the param; browser Back and Forward move between them; the in-page Back returns to results; a cold checkout URL falls back; and eight back/forward transitions produced no blank frame and no console error.
283
-
284
- ## 1.23.5
285
-
286
- **The operator cell centres its contents at every width.** LinkCompany already carried an auto horizontal margin, so the logo was centred in the cell whatever the screen - but the rating and the route code sat at flex-start, under the left edge of a box whose logo was in the middle of it. On a 768px card that was a ~300px gap between the logo and the code below it.
287
-
288
- Measured after: logo and chip centres agree to within 1px (sub-pixel rounding) at 768px and 1400px, with no cell overflow and no sideways page scroll.
289
-
290
- ## 1.23.4
291
-
292
- **The route code is a chip rather than a line of grey text.** Rounded, on the neutral background, bold and letter-spaced, with a route glyph (MdOutlineAltRoute) leading it and the word "Route" as its tooltip.
293
-
294
- A route symbol rather than a bus or a ticket: the code identifies which LINE this is - the same job a flight number does - and a bus icon would only repeat what the whole card already says. The glyph is a size larger than the text beside it, because at this scale the icon carries the meaning and the letters are just the value.
295
-
296
- Albanian `routes_order.step2.route.name` corrected from "Rruga" (a street) to "Linja" (a line). The key's only consumer is now this tooltip, so it was safe to fix.
297
-
298
- ## 1.23.3
299
-
300
- **The arrival heading was painting over the price.** Measured: the arrival cell is 165px wide and its content needed **215px**, so it ran 50px into the fare beside it.
301
-
302
- Two causes, both fixed — widths alone would only have moved the width at which it broke:
303
-
304
- - **No spaces to wrap at.** JSX drops the whitespace around a newline, so city, country and time were concatenated into one unbreakable token. Real spaces added, plus overflow-wrap: break-word above 640px as a backstop for a long city name on its own line.
305
- - **The operator cell was taking room it did not need.** 185px to 120px with a smaller logo, and the one-row gap from 15px to 10px. That returns 85px to the two location columns, which go 165px to 208px.
306
-
307
- Verified no overflow on any card at 1024 / 1100 / 1280 / 1440 / 1920px. 1024 is the tightest - it is where the card first lays out in a single row.
308
-
309
- **The route code moved out of the collapsed details and under the logo**, where it sits beside the operator it identifies. That frees a slot in the details row for things that actually need explaining - customs on a cross-border run, for one.
310
-
311
- The route information link is now **Route Info** rather than "Read Operator Information".
312
-
313
- ## 1.23.2
314
-
315
- - Flexible Ticket detail is now behind a small **Read More** link rather than the title. Its own control, so the thing you click to *read* is never next to the thing you click to *buy*. Closed by default; one line — "without it, this ticket cannot be cancelled or changed" — stays outside the collapse, because that is the material fact and hiding it would be a dark pattern rather than a tidy layout.
316
- - **Date strip: dropped the joining comma.** Above 1024px the day number carried a `::before` comma so the cell would read "Mon, 03 Aug" on one line. The cells are narrow enough that the two parts wrap anyway, so the comma led the second line and every desktop cell rendered as "Mon" over ", 03 Aug". Punctuation that only works when text happens not to wrap will keep breaking.
317
-
318
- ## 1.23.1
319
-
320
- **Flexible Ticket as a title, a price and an Add button, with the detail collapsed.**
321
-
322
- A button rather than a checkbox, because this is not the same kind of choice as the notification add-ons beside it — those are a cheap tick, this one changes what the ticket *is*. The toggle and the Add button are separate controls on purpose: reading the terms must never be a way to accidentally buy them.
323
-
324
- One line stays **outside** the collapse — "without it, this ticket cannot be cancelled or changed". Everything else is detail somebody can choose to read, but that is the material fact, and hiding what a buyer gets for nothing behind a toggle is the shape of a dark pattern rather than a tidy layout.
325
-
326
- ## 1.23.0
327
-
328
- **Flexible Ticket at checkout.**
329
-
330
- - New `useGetFlex(departurePriceId, returnPriceId, adults, children)`. Its own request rather than a field on the search result, because the answer depends on **both legs at once**: an operator who will not amend one half removes the add-on from the whole booking, and the price is computed on the combined fare.
331
- - `Addons` renders the option below passenger details, where it already sat. It shows the **default first** — "without it, this ticket cannot be cancelled or changed" — then only the capabilities this booking actually includes, then that the fee itself is not refunded, then that statutory rights when the *operator* cancels are untouched. Nothing renders at all when the offer is unavailable: a checkbox that unlocks nothing is worse than no checkbox.
332
- - `AddonsSelection` gains `flex`; the order payload gains `addon_flex`.
333
- - `Found/Route` reads `routes_order.step2.route.information.*` after the `policies` rename.
334
-
335
- Copy is **Flexible Ticket** / **Biletë Fleksibël**, never insurance — the distinction decides how the line is taxed and whether selling it needs a licence.
336
-
337
- All notable changes to this package are documented here. This project follows
338
- [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
339
-
340
- > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
341
- > rather than reconstructed after the fact.
342
-
343
- ## 1.22.0
344
-
345
- Structured data on route-pair pages: `BusTrip` with its operators and
346
- fares-from, and a `FAQPage` answering the five things people actually search
347
- for — how long, how much, how many a day, when the first and last leave, and
348
- who runs it.
349
-
350
- One rule governs all of it: **never state anything the page does not show.**
351
- Every value is the same one rendered in the facts block or the timetable a few
352
- hundred pixels below. A rich result built on a claim a visitor cannot find is
353
- the mismatch that gets a site's snippets pulled, and it is also just a lie told
354
- to a machine.
355
-
356
- The rating hangs off the **provider**, not the trip. An operator's rating is
357
- earned across everything they run; attaching it to one city pair would claim
358
- travellers rated this journey when they rated the company. Operators below the
359
- review display threshold carry no rating in the markup, exactly as they carry
360
- none on the page.
361
-
362
- ## 1.21.1
363
-
364
- No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
365
- seven-day layout, which only appears from 1024px — applied from 640px it made
366
- a tablet showing five days scroll sideways for a row that fitted comfortably,
367
- because the strip was reserving width for two cells it was not rendering.
368
-
369
- ## 1.21.0
370
-
371
- The same-day notice no longer hedges about timezones. obtapi now holds a zone
372
- per country, so "has this coach already left" is judged against the departure
373
- stop's own clock — the warning is about the real remaining risk, which is
374
- whether a traveller can reach the terminal in time.
375
-
376
- ## 1.20.0
377
-
378
- The date strip: three days on a phone, five on a tablet, seven on a desktop —
379
- and the label becomes "04 AUG" below desktop rather than "Tue 04".
380
-
381
- Seven fitted on a phone once the labels were trimmed, but only at ~36px a
382
- cell: readable, not comfortable to hit, with the price wrapping to three lines
383
- under it. Each step up in width now buys two more days instead of squeezing
384
- the same seven into less room.
385
-
386
- The weekday is what gives way, not the month. With three days showing, a date
387
- needs to say which date it is more than which weekday — "04 AUG" is
388
- unambiguous alone, while "Tue 04" leaves somebody counting forward to work out
389
- whether that is this week or next. On a full seven-day strip the weekday earns
390
- its place, because the row reads as a week; on three days it does not.
391
-
392
- ## 1.19.0
393
-
394
- Same-day travel, and the last of the phone layout.
395
-
396
- **Today is bookable**, so today's results carry a countdown — "Leaves in 43
397
- min", turning red under the hour. Computed server-side: a countdown built from
398
- the visitor's own clock is wrong for anybody whose device is off or in another
399
- timezone, and that is exactly the claim that must not be wrong.
400
-
401
- **A same-day caution above the results.** A coach that has already gone is
402
- filtered out server-side, but "already gone" is judged against our clock and
403
- the system holds no timezone for a city — so on a cross-border route the
404
- stored departure time and our idea of now can be an hour apart. The notice asks
405
- the traveller to check against their own local time.
406
-
407
- **Five days on a phone, seven above it.** Seven fitted after the labels were
408
- trimmed, but only at ~36px a cell. The five are centred on the selected day and
409
- clamped at both ends, so shrinking never hides the day being looked at.
410
-
411
- **Fare and button share a row**, tax note beneath — reordered in CSS, because
412
- the source order (fare, note, button) is the right one to read aloud and the
413
- right one for a crawler: the note explains the number it follows.
414
-
415
- ## 1.18.0
416
-
417
- The results page on a phone, rebuilt for density.
418
-
419
- **The three picks are one strip.** Cheapest, fastest and recommended stacked
420
- into three full-width bands before a single result — the thing that summarises
421
- the list was pushing the list off the screen. Side by side they are comparable
422
- at a glance, which is what they are for. The operator's name drops below 640px:
423
- at ~110px a card it would ellipsis to three characters.
424
-
425
- **All five sort options on one row.** They were wrapping onto three lines,
426
- taking more height than the first result they reorder. Sized to their own text
427
- rather than equal shares — equal shares gave all five 59px, which four did not
428
- need and "Recommended" did, so it alone was truncated to "Recom..." while its
429
- neighbours sat in whitespace.
430
-
431
- **The seven-day strip no longer scrolls sideways.** A 900px floor made half the
432
- week invisible on a phone, including whichever day was cheapest — and a week is
433
- a shape you read at a glance, not one you drag. The month is dropped from each
434
- label (it is identical across a seven-day window) and the week arrows are
435
- squeezed; the day prices wrap rather than painting over each other.
436
-
437
- **Details collapse.** Features, route, transit and policies were four stacked
438
- bands on every result. They are one wrapped row behind a toggle now, hidden
439
- with CSS rather than unmounted so the prerendered snapshot a crawler reads is
440
- unchanged.
441
-
442
- **The trip stacks vertically** with the arrow turned to point down. Side by
443
- side was tried first and does not survive the content: "Thessaloniki (GRC)
444
- 16:30" measures 215px in a 96px column and painted 50px outside the card — the
445
- page never scrolled sideways, so nothing looked broken until it was measured.
446
-
447
- ## 1.17.0
448
-
449
- Internal linking blocks: "Popular routes from Tirana" / "Popular routes to
450
- Thessaloniki", as real router links, because the entire value is that a
451
- crawler follows them.
452
-
453
- The sitemap already announces every sellable pair, but a sitemap is a hint —
454
- a page nothing links to is still orphaned. These put every pair one click from
455
- another.
456
-
457
- Ordered by paid bookings, never filtered by them: the pairs with no orders yet
458
- are exactly the orphans this exists to reach. The block also survives a pair
459
- with no facts, which is precisely where a reader most needs a way onward.
460
-
461
- ## 1.16.0
462
-
463
- The crawlable timetable, and a section layout around the wizard.
464
-
465
- Every service on the pair as a real `<table>` — departs, arrives, duration,
466
- stops, operator with its rating, price — sorted by departure time. This is the
467
- part of Tier 3 that actually ranks: dense, unique to the pair, and made of the
468
- words somebody types, without a sentence of written copy. No interactivity, so
469
- a prerendered snapshot shows a non-JS crawler exactly what a reader sees.
470
-
471
- A sticky in-page nav (Trips · Facts · Timetable) so somebody who landed from a
472
- search engine can reach the timetable without scrolling past a booking form
473
- they have not decided to use. The wizard itself is untouched — this is a
474
- layout around it, not a change to the funnel.
475
-
476
- Facts and the timetable share ONE request. Two components each fetching the
477
- same response would have doubled what the prerender crawl costs on every pair.
478
-
479
- No "type" or "class" column, which the roadmap asked for: every route in this
480
- system is typed `international` and the bus names are inventory identifiers
481
- like "A100". A column that says nothing on every row reads as padding.
482
-
483
- ## 1.15.0
484
-
485
- Distance is now measured along the route's own stops rather than as one line
486
- from origin to destination, and the facts payload carries two numbers instead
487
- of one: `road`, real driving distance from a routing service, and `approx`,
488
- the straight lines summed stop by stop.
489
-
490
- Real road distance prints plainly. The approximation prints with a caveat,
491
- because it understates every journey — 309 km against 401 km of actual tarmac
492
- on Tirana–Thessaloniki via Korça, a 23% shortfall. A straight line is the
493
- shortest path there is, so no number of extra points can ever close that gap.
494
-
495
- ## 1.14.0
496
-
497
- The facts block on a route-pair page: fares from, journey time, first and last
498
- departure, how many run, distance and who operates it.
499
-
500
- Read off the timetable rather than a dated search, so it does not change with
501
- the date in the URL — a "from EUR 12" that moved depending on which day a
502
- crawler arrived would look unstable to a search engine and dishonest to a
503
- reader who came back.
504
-
505
- Distance is labelled "straight line" because that is what it is: we hold city
506
- coordinates, not routing data, so the figure is always shorter than the road.
507
-
508
- Renders nothing at all for a pair with no facts. An empty facts block is worse
509
- than none — it is the thin content this tier exists to remove.
510
-
511
- ## 1.13.0
512
-
513
- The operator's rating on each result card. The operator's rather than the
514
- route's: a single route rarely has enough published reviews to clear the
515
- display threshold, and a card with a rating beats one without.
516
-
517
- ## [1.12.0] - 2026-08-01
518
-
519
- ### Removed
520
-
521
- - **The Booking.com hotel banner.** It was unmounted from `Found` earlier on
522
- 2026-08-01 for sitting between the date strip and the trips - advertising a
523
- hotel to somebody still deciding whether they can travel at all - and the
524
- component is now deleted rather than kept dormant. It was never an
525
- integration, only a hand-built `searchresults.xu.html` link built from
526
- `city.booking_id`; leaving the component, the column and its admin field in
527
- place made it look like one. The `routes_order.step2.booking` strings and
528
- the `s-ec.bstatic.com` CSP entry go with it.
529
-
530
- ## [1.11.1] - 2026-08-01
531
-
532
- ### Changed
533
-
534
- - **Coupon and payment method moved into the sidebar**, coupon directly
535
- above the totals it changes and the method picker directly above the
536
- button that acts on it. Both sat several sections away in the main
537
- column, so applying a coupon changed a number that was off screen.
538
- - **The results heading names the trip** - "Online Bus tickets from Tirana
539
- Albania to Thessaloniki Greece" instead of "Select a Departure Schedule".
540
- Countries are dropped when both ends share one, since "from Tirana
541
- Albania to Korca Albania" is noise. Read from the city list rather than
542
- from the results, so the heading is right even when the search finds
543
- nothing - which is exactly when knowing what was searched for matters.
544
- - **Order summary**: the operator's logo under the route name, departure
545
- and arrival icons sitting on the connecting line, and the departure date
546
- and time in bold - the two things a buyer checks before paying.
547
-
548
- ### Fixed
549
-
550
- - The coupon and payment blocks switched layout at a VIEWPORT breakpoint,
551
- so in the ~290px sidebar they used the wide arrangement and the coupon
552
- input came out 86px wide. They now wrap on their own width.
553
-
554
- ## [1.11.0] - 2026-08-01
555
-
556
- ### Changed
557
-
558
- - **Passenger details and checkout are one screen.** Split across two
559
- steps, the buyer typed passenger details with no sight of the price, then
560
- saw the price on a screen where they could no longer check what they had
561
- typed - and ticking an add-on changed a total that had already scrolled
562
- away. The wizard is four steps now, not five.
563
- - **A sticky sidebar** carries the trip, the ticket price, each add-on, any
564
- discount, the total due, and the button that commits to it. The button
565
- follows the chosen method, so it reads "Reserve" for a reservation rather
566
- than claiming to take a payment.
567
- - **The trip summary states both ends of each leg**, city and country in
568
- full, origin above destination. It previously showed one end only - the
569
- departure point outbound, the arrival point on the return - so a one-way
570
- buyer reached payment having never been shown where the bus drops them.
571
- - **Required fields are marked.** The form validated them but never said
572
- which they were, so the first a buyer heard of it was an error message
573
- after pressing pay.
574
-
575
- `usePostOrder` now takes the passenger details in its mutation variables
576
- rather than as a hook argument: on one screen they are only known at the
577
- moment pay is pressed, so a closure captured at render time would have sent
578
- stale state. The two forms stay separate react-hook-form instances - they
579
- validate different things, and billing is not rendered at all for a
580
- signed-in buyer - and paying validates them in order, stopping at the first
581
- failure so the buyer lands on the thing that needs fixing.
582
-
583
- Verified by completing a real reservation end to end: order 61 / ticket 75
584
- carried every entered value, seat included.
585
-
586
- ## [1.10.0] - 2026-08-01
587
-
588
- ### Added
589
-
590
- - **A search that finds nothing now answers instead of apologising.** In
591
- place of the bare "we couldn't find any tickets", the page offers nearby
592
- departure cities, nearby arrival cities and other dates that have seats.
593
- Every suggestion is a search obtapi has already confirmed returns
594
- results on the requested date, quoted with its fare, so a click is never
595
- a second dead end. Nearby cities are ordered by real distance from the
596
- city that was asked for - the passenger's problem is getting to the bus,
597
- not saving two euros.
598
- - Fetched only once the departure search has come back empty, so a search
599
- that succeeded never pays for it.
600
-
601
- Suggestion links do a full page load on purpose: RoutesOrder holds the
602
- search in component state and Step1 only starts a search when it has not
603
- already redirected, so a client-side navigation would change the URL and
604
- leave the empty result set on screen.
605
-
606
- ## [1.9.6] - 2026-08-01
607
-
608
- ### Added
609
-
610
- - **The search form stays on screen while choosing a route.** Adjusting a
611
- date, a city or the passenger count used to mean "Back to Search", which
612
- threw the results away along with any filters. Shown only on the
613
- route-selection screens: past that point the passenger has committed to a
614
- specific trip, and re-running the search under them would discard details
615
- they had already typed.
616
-
617
- ## [1.9.5] - 2026-08-01
618
-
619
- ### Fixed
620
-
621
- - **The column header row now lines up with the result cards** - 0px drift
622
- on all five columns, and content-independent. It had 4px horizontal
623
- padding against the card's 15px and no column gap against the card's
624
- 15px, which pushed every label right of the data it named (Duration was
625
- out by 34px).
626
- - **The card's two location columns are now equal width.** `flex: 1` with
627
- the default `min-width: auto` meant they sized to their content, so the
628
- arrival column (long city plus long stop name) took 215px while the
629
- departure column got 155px. Beyond breaking any header alignment, this
630
- let one long stop name steal width from the other side of the card.
631
-
632
- ## [1.9.4] - 2026-08-01
633
-
634
- ### Changed
635
-
636
- - **Fare decimals ride as superscript** and the currency is set smaller.
637
- On a fare board the cents are almost never what anyone is comparing, and
638
- at full size they took a third of the width of the widest thing on the
639
- card. Split from obtapi's already-formatted string rather than
640
- re-formatted from `price.value`, so the label's own currency and number
641
- formatting stay the single source of truth - an unexpected format falls
642
- back to rendering the string whole.
643
- - **The taxes/passenger line is smaller and tucked against the price**,
644
- which reads as one unit and frees the room the Select button needed.
645
- - **Amenities moved into the details row** as its first column, so route,
646
- transit and policies sit beside them - four columns instead of a separate
647
- full-width band under every card.
648
- - **Amenities are icon-only on this page**, with the name as a hover
649
- tooltip (`compact` on `RouteFeature`). Eight amenities spelled out took
650
- more width than everything else in that row put together.
651
-
652
- ## [1.9.3] - 2026-08-01
653
-
654
- ### Changed
655
-
656
- - **Trip length reads as a duration, not a clock time.** "08:30" sat
657
- directly between two real departure times with nothing to say it meant
658
- eight and a half hours rather than half past eight. It now renders as
659
- `07h 30m` behind an hourglass, and an exact number of hours drops the
660
- minutes (`08h`, not `08h 00m`). The unit labels come from the translation
661
- files - "h"/"m" are a Latin-script convention, so Greek reads `07ω 30λ`,
662
- Bulgarian and Macedonian `07ч 30м`, Albanian `07o 30m`.
663
- obtapi's `-` sentinel (no computable duration) still passes straight
664
- through rather than becoming a confident "0h 0m".
665
- - **Stop name down to 12px** on the result cards.
666
-
667
- ## [1.9.2] - 2026-08-01
668
-
669
- ### Changed
670
-
671
- - **Stop name stepped down to 14px** on the result cards. At the same 16px
672
- as body copy it competed with the city name above it; the city should be
673
- read first and the stop only when it is needed.
674
- - **Country code renders upper case and smaller.** It arrives from the
675
- database lower case ("alb", "grc") - an ISO country code is upper case by
676
- definition, so it is cased at render time rather than left to the data,
677
- where every consumer would have to remember.
678
-
679
- ## [1.9.1] - 2026-08-01
680
-
681
- ### Changed
682
-
683
- - **Filters became a sidebar** at 1280px and up - always visible, sticky
684
- while the list scrolls, no click needed to discover them. Below 1280px
685
- they stay the collapsible panel above the list. The breakpoint is 1280
686
- rather than 1024 because the result cards switch to their wide row layout
687
- at 1024px on VIEWPORT width, not container width, so taking a column out
688
- of a 1024px window would leave the cards using a layout their remaining
689
- space cannot carry.
690
- - **Facet counts are a filled pill**, separate from the from-price. They
691
- used to read as one muted string ("5 · 38.00 EUR") where the two numbers
692
- ran together and it was not obvious which was which.
693
- - **Facet labels wrap instead of truncating.** In a sidebar column an
694
- ellipsis reduced two different stops at the same city to the same
695
- "Terminali Lindor Inte..." - the one distinction the filter exists to
696
- make.
697
- - **The result-count / sort bar sits on the card surface** rather than
698
- floating on the page background.
699
-
700
- ### Fixed
701
-
702
- - Filter panel grid tracks used `1fr`, whose implicit min-width is `auto`,
703
- so a long stop name pushed the whole panel wider than its column and
704
- pushed the counts and prices out of view entirely. Now `minmax(0, 1fr)`.
705
-
706
- ## [1.9.0] - 2026-08-01
707
-
708
- ### Added
709
-
710
- - **Sorting on the results list** - recommended, cheapest, fastest,
711
- earliest, latest. Preferred-stop pinning stays the outer partition and
712
- each side sorts within itself, so choosing "cheapest" can no longer
713
- silently un-pin a stop the user saved. Every sort carries a tiebreak on
714
- the other axis, so three trips tied on journey time are led by the
715
- cheapest of them rather than by whichever happened to come first.
716
- - **"Recommended" is defined and merit-first**: price rank plus duration
717
- rank across the current result set, with the operator's paid subscription
718
- priority breaking ties only between trips already equal on both. Paid
719
- placement cannot overtake a cheaper or faster competitor. This is a
720
- deliberate commercial decision (Ferjolt, 2026-08-01) and it is the answer
721
- if anyone asks what the word means on this page. It replaces the previous
722
- default, which was obtapi's departure-time order - that view is still one
723
- click away as "earliest".
724
- - **Quick-pick cards** for cheapest / fastest / recommended, each naming
725
- the winning fare, journey time and operator, and applying that ordering
726
- on click. Computed from the filtered list, so a card never advertises a
727
- trip the user's own filters have hidden.
728
- - **Faceted filters with live counts and a from-price per value** -
729
- departure-time bucket, operator, direct-only, departure stop, arrival
730
- stop, on-board amenities, and a maximum price. Counts recompute against
731
- the *other* active filters, and values that would return nothing render
732
- disabled rather than vanishing mid-interaction.
733
- - **Per-day prices on the date strip**, from obtapi's `from_price` /
734
- `from_price_display`. The cheapest day is badged only when the cheap days
735
- are a genuine minority - badging six days out of seven trains people to
736
- ignore the badge.
737
- - **Result count and price range** above the list, which also makes it
738
- visible when a filter is hiding results.
739
- - **Collapsed duplicate departures.** Trips from the same operator between
740
- the same two stops at the same price and duration render as one card with
741
- a departure-time picker instead of a wall of near-identical rows - the
742
- shape hourly Albanian intercity service produces. Whatever time is picked
743
- is what gets booked.
744
- - **Price clarity line** under every fare. It states the party size rather
745
- than "per adult", because `price.value` is the total for the whole
746
- passenger mix, not a per-person fare.
747
-
748
- The refinement lives in the URL, so a filtered result set survives a reload
749
- and can be shared. External-provider offers are never dropped by a facet
750
- they cannot report (stops, amenities) - a filter that deletes paid partner
751
- inventory is a revenue bug, not a UI detail.
752
-
753
- ### Fixed
754
-
755
- - **`useGetDates` cache key ignored the search.** It was
756
- `['step2-dates', { number }]`, so every city pair and passenger mix shared
757
- one cache entry and the strip could show a previous trip's days. Harmless
758
- while the strip held only booleans; with per-day prices on it, it would
759
- have quoted one route's fares under another route's dates.
760
-
761
- ## [1.7.0] - 2026-07-30
762
-
763
- ### Added
764
-
765
- - **Per-pair `<title>`/description for `/bus-lines/:from/:to`.** Every
766
- route-pair search page previously shared one static generic
767
- title/description (`routes_order.title`/`meta.description`) regardless
768
- of which cities were being searched - hundreds of near-duplicate-content
769
- pages, invisible to search engines as distinct pages, even though
770
- obtapi's `Route::linkPurchase()` and the sitemap already point directly
771
- at specific pairs. Now resolves `:from`/`:to` against
772
- `routes-search`'s own city list (same cached query `<RoutesSearch>`
773
- already uses - no extra fetch) and renders real
774
- `routes_order.title_pair`/`meta.description_pair` templates (all 15
775
- languages) when both resolve, falling back to the generic copy for the
776
- bare `/bus-lines` search form.
777
-
778
- ## [1.6.2] - 2026-07-30
779
-
780
- ### Fixed
781
-
782
- - **The full 9-segment `/bus-lines/:from/:to/:departure/:_return/:type/:adults/:children/:babies`
783
- URL only auto-searched if a `?type=submit` query param was also present**
784
- (`Step1/Step1.tsx`) - set only when the in-app search form navigated here
785
- itself. Every OTHER way of reaching this URL - obtapi's
786
- `Route::linkPurchase()` (which generates exactly this path with no query
787
- param), a shared link, a search engine crawling it - rendered nothing but
788
- the empty search form despite the URL already carrying a complete search
789
- spec. Now also auto-searches whenever `:departure` is present in the path,
790
- which only the full 9-segment route ever populates.
791
-
792
- ## [1.6.1] - 2026-07-27
793
-
794
- ### Added
795
-
796
- - **External-provider "Book on {provider}" redirect (Mode A: search +
797
- redirect).** `Found/Route/Route.tsx` now detects `data.external?.mode ===
798
- 'redirect'` (obtapi's `Libraries\External\Search\Merge`) and swaps the
799
- usual "Select Route" button for a `t('routes_order.step2.route.book_on')`
800
- button that opens the click-out endpoint
801
- (`{VITE_API_OBT}/api/external/go/{offer_id}`) in a new tab instead of
802
- calling `onSave` - external offers use a synthetic id the local checkout
803
- pipeline can't resolve, so "select" is never wired for them. The operator
804
- logo and route-code links are also repointed to the same click-out URL
805
- (not the raw provider deep link) so every outbound path is logged for the
806
- search->click funnel, and the local-only "Read Operator Policy" button is
807
- hidden for external rows (no local policy content exists for them).
808
- Requires `@autobusal/providers` ^1.3.7.
809
-
810
- ## [1.6.0] - 2026-07-25
811
-
812
- ### Added
813
-
814
- - **Checkout addon selection (Step5).** Buyers can now add WhatsApp and/or
815
- Telegram notification addons at checkout, if the label has them enabled
816
- (`SettingsData.addons` from `@autobusal/providers`). WhatsApp collects a
817
- phone number inline; Telegram is a "connect after purchase" flow (a bot
818
- can't message a bare phone number cold). Selected addons render as line
819
- items in the order summary and are folded into the grand total shown
820
- before payment - pricing/availability is re-verified server-side, this is
821
- only what the buyer requests. `usePostOrder` now takes an `AddonsSelection`
822
- and posts `addon_whatsapp`/`addon_whatsapp_number`/`addon_telegram`.
823
-
824
- ## [1.5.0] - 2026-07-25
825
-
826
- ### Added
827
-
828
- - **Per-route passenger details in Step4.** The passenger form now renders
829
- sex, passport, phone, and the new WhatsApp/Telegram contact fields ONLY
830
- when the route's `trip.passenger_fields` requires them (then required);
831
- name and date of birth stay always-mandatory. A return trip uses the
832
- union of both legs' configs. Routes without a config keep the pre-feature
833
- form (sex + passport). Request field suffixes: `_wa`, `_tg`.
834
-
835
- ## [1.4.2] - 2026-07-19
836
-
837
- ### Added
838
-
839
- - `Found/Route/Route.tsx`: added a neutral inline SVG data-URI `LOGO_FALLBACK`.
840
- The operator logo `<img>` now falls back to it when `logo_url` is empty
841
- (`data.operator.logo_url || LOGO_FALLBACK`) and via an `onError` handler
842
- (which nulls `onerror` first to avoid a loop), so a missing or broken operator
843
- logo no longer renders the browser's broken-image icon in route results.
844
-
845
- ### Fixed
846
-
847
- - `Found/Orders/Orders.tsx`: the not-found branch now triggers on
848
- `data === undefined || data.length === 0` instead of only `data?.length == 0`.
849
- An `undefined` result means the search errored (e.g. same origin and
850
- destination), and it now shows the not-found message instead of an empty
851
- shell.
852
- - `Found/Booking/Booking.tsx`: the Booking.com logo `<img>` now has an
853
- `alt="Booking.com"` attribute, giving the image an accessible name.
854
- - `Found/Route/Route.tsx`: the operator logo `<img>` now has an `alt` attribute
855
- set to the operator company name.
856
-
857
- ### Security
858
-
859
- - `Found/Booking/Booking.tsx`: the `LinkBooking` external link
860
- (`target="_blank"`) now includes `rel="noopener noreferrer"`, closing the
861
- reverse-tabnabbing / `window.opener` leak.
862
- - `Found/Route/Route.tsx`: the operator `LinkCompany` and route `LinkRoute`
863
- external links (`target="_blank"`) now include `rel="noopener noreferrer"` for
864
- the same tabnabbing protection.
865
-
866
- Authored by Ferjolt Ozuni. Consolidated from the magus and alvavel whitelabel patch sets into canonical @autobusal source (eliminates per-repo patch-package divergence).