@autobusal/routes-order 1.9.0 → 1.9.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +673 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. package/Step5/Step5.tsx +0 -185
package/CHANGELOG.md CHANGED
@@ -1,11 +1,684 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.1 (2026-08-29)
4
+
5
+ - 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.
6
+ - 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.
7
+
8
+ ## 1.33.9 (2026-08-28)
9
+
10
+ - 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).
11
+ - 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).
12
+ - useGetFlex takes the coupon-adjusted total so the quoted flex price matches what the server bills on a couponed booking (sweep #53).
13
+ - Passenger age bands match the server rules: adult 12+, child 2-12, baby under 2 (sweep #48).
14
+
15
+ ## 1.33.8 (2026-08-28)
16
+
17
+ - usePostOrder replays the stored referral token as `referral`, so widget/link arrivals attribute their affiliate at checkout.
18
+
19
+ ## 1.33.7 (2026-08-26)
20
+
21
+ - 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.
22
+
23
+ ## 1.33.6
24
+
25
+ ### Fixed
26
+
27
+ - **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.)
28
+
29
+ ## 1.33.5
30
+
31
+ ### Fixed
32
+
33
+ - **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.)
34
+
35
+ ## 1.33.4
36
+
37
+ ### Added
38
+
39
+ - **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.)
40
+
41
+ ## 1.33.3
42
+
43
+ ### Fixed
44
+
45
+ - **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.)
46
+
47
+ ## 1.33.2
48
+
49
+ ### Fixed
50
+
51
+ - **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.
52
+
53
+ 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.
54
+
55
+ - `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.
56
+
57
+ - 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.
58
+
59
+ ## 1.33.1
60
+
61
+ ### Added
62
+
63
+ - **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.
64
+
65
+ ## 1.33.0
66
+
67
+ ### Added
68
+
69
+ - **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.
70
+ - 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.
71
+ - **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.
72
+ - **`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.
73
+ - **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.
74
+
75
+ ### Fixed
76
+
77
+ - **"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.
78
+ - **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.
79
+ - **"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.
80
+
81
+ ## 1.32.1
82
+
83
+ ### Added
84
+
85
+ - **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.
86
+
87
+ 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.
88
+
89
+ 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".
90
+
91
+ ## 1.32.0
92
+
93
+ ### Added
94
+
95
+ - **"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.
96
+ - 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.
97
+ - 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.
98
+
99
+ ## 1.31.5
100
+
101
+ ### Added
102
+
103
+ - **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.
104
+
105
+ ## 1.31.4
106
+
107
+ ### Fixed
108
+
109
+ - **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.
110
+ - 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.
111
+
112
+ ## 1.31.3
113
+
114
+ ### Fixed
115
+
116
+ - **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.
117
+
118
+ ## 1.31.2
119
+
120
+ ### Changed
121
+
122
+ - **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.
123
+
124
+ ## 1.31.1
125
+
126
+ ### Changed
127
+
128
+ - **One payment method means one button.** The picker no longer renders when
129
+ there is nothing to pick, and the button reads "Pay Now" rather than "Make
130
+ Payment" - a heading over a single option the buyer had to press before
131
+ pressing the real one was a click that only ever had one answer, on the
132
+ last screen before paying.
133
+
134
+ ## 1.31.0
135
+
136
+ ### Changed
137
+
138
+ - **The checkout Order Summary stacks the legs vertically and numbers them**
139
+ — "Trip 1" above the outbound, "Trip 2" above the return.
140
+
141
+ They sat side by side from 768px up, which was written when the summary
142
+ spanned the page. It has lived in the checkout *sidebar* since 1.11.0 — a
143
+ narrow column — so two legs abreast left each about half the width a single
144
+ line like "Terminali Lindor Interurban dhe Nderkombetar i Autobusave (TEG)"
145
+ needs, and nothing said which was which. Vertical at every width now, in the
146
+ order they are travelled.
147
+
148
+ **The number is only rendered on a round trip.** A one-way has nothing to
149
+ enumerate, and a lone "Trip 1" would send the reader looking for a Trip 2
150
+ that was never bought. `Checkout/Sidebar` is the one component that knows
151
+ whether a second leg exists, so it is the one that decides — `About` just
152
+ takes an optional `number`.
153
+
154
+ "Trip" rather than "Segment": segment is airline jargon, and this is the
155
+ last screen before somebody pays for a bus ticket.
156
+
157
+ ## 1.30.1
158
+
159
+ ### Fixed
160
+
161
+ - **Step 3 could never list a single return leg.** `useGetReturns` spread the
162
+ search form straight into the query string, so the return date went over as
163
+ `_return` — the form's name for it, because `return` is a reserved word in
164
+ JS — while the API validates `return`. Every request answered
165
+ `422 The return field is required`, and "Select a Return Schedule" was empty
166
+ for every round trip ever searched. The order builder in the same file has
167
+ always done this mapping (`order.return = step1._return`); only this call
168
+ was missed.
169
+
170
+ Paired with an obtapi fix — `Routes\Search\Returns::find()` returned nothing
171
+ on its success path — so the step was broken twice over and neither failure
172
+ could be seen past the other.
173
+
174
+ ## 1.30.0
175
+
176
+ ### Added
177
+
178
+ - **The SMS booking-confirmation add-on at checkout**, with its own number
179
+ field prefilled from the passenger phone already collected — somebody
180
+ booking for a relative wants the text to reach the traveller. The
181
+ description says the free route alerts are not what is being sold here.
182
+ - **A line under the passenger phone field** saying what the number is used
183
+ for: reaching you about this trip, and service messages if the departure
184
+ changes — never marketing. The number was always collected so a driver
185
+ could ring it; texting it is a different use of the same data, and saying
186
+ so at the point of collection is what makes it consent rather than an
187
+ assumption.
188
+ ## 1.29.0
189
+
190
+ ### Removed
191
+
192
+ - **The reviews block on the route-pair page.** A review wall sat in the
193
+ middle of a page whose job is to compare departures. What a reader needs
194
+ there about reputation is one number per operator, which the result cards
195
+ already carry; the reviews themselves belong on the operator's own page,
196
+ where somebody who wants to read them has gone looking for them. The `pair`
197
+ mode of the reviews API is untouched — nothing calls it from here now.
198
+ - The "Reviews" entry in the in-page nav, with it.
199
+
200
+ ## 1.28.0
201
+
202
+ ### Added
203
+
204
+ - **Overnight trips now say so.** A result card that arrives on a later day
205
+ prints the calendar day under BOTH ends and a `+1` against the arrival
206
+ time; the pair-page timetable carries the same marker. "22:30 → 06:00" is
207
+ the one thing on a result card that can be read as the exact opposite of
208
+ what it means, and the duration beside it does not fix that because nobody
209
+ reads a duration to work out a date.
210
+ - Nothing is added to a same-day card. A date under every arrival would make
211
+ the overnight ones stop standing out, which is the only reason this exists.
212
+
213
+ ### Changed
214
+
215
+ - The day comes from the timetable's own midnight markers, computed
216
+ server-side, never from "the arrival looks earlier than the departure" —
217
+ which is wrong for exactly the cross-border journeys whose two ends keep
218
+ different clocks.
219
+
220
+ ## 1.27.0
221
+
222
+ **`reloadDocument` is gone — the results follow the URL.**
223
+
224
+ 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.
225
+
226
+ `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.
227
+
228
+ 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.
229
+
230
+ Verified client-side: navigating from Tirana → Thessaloniki (10 trips) to Durres → Tirana updated the header and the results with the page never reloading.
231
+
232
+ ## 1.26.0
233
+
234
+ **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.
235
+
236
+ - `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.
237
+ - `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.
238
+ - 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.
239
+ - 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.
240
+
241
+ ## 1.25.0
242
+
243
+ **Reviews on the route-pair page.** A new section between the timetable and the internal links, with its own nav anchor.
244
+
245
+ Renders nothing below the display threshold, so a pair without enough reviews does not gain an empty box.
246
+
247
+ ## 1.24.0
248
+
249
+ **The booking wizard has real URLs.** The step now lives in the URL and `current` is derived from it, rather than the reverse.
250
+
251
+ 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.
252
+
253
+ - 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.
254
+ - **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.
255
+
256
+ **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.
257
+
258
+ 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.
259
+
260
+ ## 1.23.5
261
+
262
+ **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.
263
+
264
+ 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.
265
+
266
+ ## 1.23.4
267
+
268
+ **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.
269
+
270
+ 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.
271
+
272
+ 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.
273
+
274
+ ## 1.23.3
275
+
276
+ **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.
277
+
278
+ Two causes, both fixed — widths alone would only have moved the width at which it broke:
279
+
280
+ - **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.
281
+ - **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.
282
+
283
+ 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.
284
+
285
+ **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.
286
+
287
+ The route information link is now **Route Info** rather than "Read Operator Information".
288
+
289
+ ## 1.23.2
290
+
291
+ - 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.
292
+ - **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.
293
+
294
+ ## 1.23.1
295
+
296
+ **Flexible Ticket as a title, a price and an Add button, with the detail collapsed.**
297
+
298
+ 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.
299
+
300
+ 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.
301
+
302
+ ## 1.23.0
303
+
304
+ **Flexible Ticket at checkout.**
305
+
306
+ - 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.
307
+ - `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.
308
+ - `AddonsSelection` gains `flex`; the order payload gains `addon_flex`.
309
+ - `Found/Route` reads `routes_order.step2.route.information.*` after the `policies` rename.
310
+
311
+ Copy is **Flexible Ticket** / **Biletë Fleksibël**, never insurance — the distinction decides how the line is taxed and whether selling it needs a licence.
312
+
3
313
  All notable changes to this package are documented here. This project follows
4
314
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
315
 
6
316
  > Note: 1.8.0 was published without a changelog entry. The gap is left as-is
7
317
  > rather than reconstructed after the fact.
8
318
 
319
+ ## 1.22.0
320
+
321
+ Structured data on route-pair pages: `BusTrip` with its operators and
322
+ fares-from, and a `FAQPage` answering the five things people actually search
323
+ for — how long, how much, how many a day, when the first and last leave, and
324
+ who runs it.
325
+
326
+ One rule governs all of it: **never state anything the page does not show.**
327
+ Every value is the same one rendered in the facts block or the timetable a few
328
+ hundred pixels below. A rich result built on a claim a visitor cannot find is
329
+ the mismatch that gets a site's snippets pulled, and it is also just a lie told
330
+ to a machine.
331
+
332
+ The rating hangs off the **provider**, not the trip. An operator's rating is
333
+ earned across everything they run; attaching it to one city pair would claim
334
+ travellers rated this journey when they rated the company. Operators below the
335
+ review display threshold carry no rating in the markup, exactly as they carry
336
+ none on the page.
337
+
338
+ ## 1.21.1
339
+
340
+ No horizontal scroll on a tablet's date strip. The 900px floor belongs to the
341
+ seven-day layout, which only appears from 1024px — applied from 640px it made
342
+ a tablet showing five days scroll sideways for a row that fitted comfortably,
343
+ because the strip was reserving width for two cells it was not rendering.
344
+
345
+ ## 1.21.0
346
+
347
+ The same-day notice no longer hedges about timezones. obtapi now holds a zone
348
+ per country, so "has this coach already left" is judged against the departure
349
+ stop's own clock — the warning is about the real remaining risk, which is
350
+ whether a traveller can reach the terminal in time.
351
+
352
+ ## 1.20.0
353
+
354
+ The date strip: three days on a phone, five on a tablet, seven on a desktop —
355
+ and the label becomes "04 AUG" below desktop rather than "Tue 04".
356
+
357
+ Seven fitted on a phone once the labels were trimmed, but only at ~36px a
358
+ cell: readable, not comfortable to hit, with the price wrapping to three lines
359
+ under it. Each step up in width now buys two more days instead of squeezing
360
+ the same seven into less room.
361
+
362
+ The weekday is what gives way, not the month. With three days showing, a date
363
+ needs to say which date it is more than which weekday — "04 AUG" is
364
+ unambiguous alone, while "Tue 04" leaves somebody counting forward to work out
365
+ whether that is this week or next. On a full seven-day strip the weekday earns
366
+ its place, because the row reads as a week; on three days it does not.
367
+
368
+ ## 1.19.0
369
+
370
+ Same-day travel, and the last of the phone layout.
371
+
372
+ **Today is bookable**, so today's results carry a countdown — "Leaves in 43
373
+ min", turning red under the hour. Computed server-side: a countdown built from
374
+ the visitor's own clock is wrong for anybody whose device is off or in another
375
+ timezone, and that is exactly the claim that must not be wrong.
376
+
377
+ **A same-day caution above the results.** A coach that has already gone is
378
+ filtered out server-side, but "already gone" is judged against our clock and
379
+ the system holds no timezone for a city — so on a cross-border route the
380
+ stored departure time and our idea of now can be an hour apart. The notice asks
381
+ the traveller to check against their own local time.
382
+
383
+ **Five days on a phone, seven above it.** Seven fitted after the labels were
384
+ trimmed, but only at ~36px a cell. The five are centred on the selected day and
385
+ clamped at both ends, so shrinking never hides the day being looked at.
386
+
387
+ **Fare and button share a row**, tax note beneath — reordered in CSS, because
388
+ the source order (fare, note, button) is the right one to read aloud and the
389
+ right one for a crawler: the note explains the number it follows.
390
+
391
+ ## 1.18.0
392
+
393
+ The results page on a phone, rebuilt for density.
394
+
395
+ **The three picks are one strip.** Cheapest, fastest and recommended stacked
396
+ into three full-width bands before a single result — the thing that summarises
397
+ the list was pushing the list off the screen. Side by side they are comparable
398
+ at a glance, which is what they are for. The operator's name drops below 640px:
399
+ at ~110px a card it would ellipsis to three characters.
400
+
401
+ **All five sort options on one row.** They were wrapping onto three lines,
402
+ taking more height than the first result they reorder. Sized to their own text
403
+ rather than equal shares — equal shares gave all five 59px, which four did not
404
+ need and "Recommended" did, so it alone was truncated to "Recom..." while its
405
+ neighbours sat in whitespace.
406
+
407
+ **The seven-day strip no longer scrolls sideways.** A 900px floor made half the
408
+ week invisible on a phone, including whichever day was cheapest — and a week is
409
+ a shape you read at a glance, not one you drag. The month is dropped from each
410
+ label (it is identical across a seven-day window) and the week arrows are
411
+ squeezed; the day prices wrap rather than painting over each other.
412
+
413
+ **Details collapse.** Features, route, transit and policies were four stacked
414
+ bands on every result. They are one wrapped row behind a toggle now, hidden
415
+ with CSS rather than unmounted so the prerendered snapshot a crawler reads is
416
+ unchanged.
417
+
418
+ **The trip stacks vertically** with the arrow turned to point down. Side by
419
+ side was tried first and does not survive the content: "Thessaloniki (GRC)
420
+ 16:30" measures 215px in a 96px column and painted 50px outside the card — the
421
+ page never scrolled sideways, so nothing looked broken until it was measured.
422
+
423
+ ## 1.17.0
424
+
425
+ Internal linking blocks: "Popular routes from Tirana" / "Popular routes to
426
+ Thessaloniki", as real router links, because the entire value is that a
427
+ crawler follows them.
428
+
429
+ The sitemap already announces every sellable pair, but a sitemap is a hint —
430
+ a page nothing links to is still orphaned. These put every pair one click from
431
+ another.
432
+
433
+ Ordered by paid bookings, never filtered by them: the pairs with no orders yet
434
+ are exactly the orphans this exists to reach. The block also survives a pair
435
+ with no facts, which is precisely where a reader most needs a way onward.
436
+
437
+ ## 1.16.0
438
+
439
+ The crawlable timetable, and a section layout around the wizard.
440
+
441
+ Every service on the pair as a real `<table>` — departs, arrives, duration,
442
+ stops, operator with its rating, price — sorted by departure time. This is the
443
+ part of Tier 3 that actually ranks: dense, unique to the pair, and made of the
444
+ words somebody types, without a sentence of written copy. No interactivity, so
445
+ a prerendered snapshot shows a non-JS crawler exactly what a reader sees.
446
+
447
+ A sticky in-page nav (Trips · Facts · Timetable) so somebody who landed from a
448
+ search engine can reach the timetable without scrolling past a booking form
449
+ they have not decided to use. The wizard itself is untouched — this is a
450
+ layout around it, not a change to the funnel.
451
+
452
+ Facts and the timetable share ONE request. Two components each fetching the
453
+ same response would have doubled what the prerender crawl costs on every pair.
454
+
455
+ No "type" or "class" column, which the roadmap asked for: every route in this
456
+ system is typed `international` and the bus names are inventory identifiers
457
+ like "A100". A column that says nothing on every row reads as padding.
458
+
459
+ ## 1.15.0
460
+
461
+ Distance is now measured along the route's own stops rather than as one line
462
+ from origin to destination, and the facts payload carries two numbers instead
463
+ of one: `road`, real driving distance from a routing service, and `approx`,
464
+ the straight lines summed stop by stop.
465
+
466
+ Real road distance prints plainly. The approximation prints with a caveat,
467
+ because it understates every journey — 309 km against 401 km of actual tarmac
468
+ on Tirana–Thessaloniki via Korça, a 23% shortfall. A straight line is the
469
+ shortest path there is, so no number of extra points can ever close that gap.
470
+
471
+ ## 1.14.0
472
+
473
+ The facts block on a route-pair page: fares from, journey time, first and last
474
+ departure, how many run, distance and who operates it.
475
+
476
+ Read off the timetable rather than a dated search, so it does not change with
477
+ the date in the URL — a "from EUR 12" that moved depending on which day a
478
+ crawler arrived would look unstable to a search engine and dishonest to a
479
+ reader who came back.
480
+
481
+ Distance is labelled "straight line" because that is what it is: we hold city
482
+ coordinates, not routing data, so the figure is always shorter than the road.
483
+
484
+ Renders nothing at all for a pair with no facts. An empty facts block is worse
485
+ than none — it is the thin content this tier exists to remove.
486
+
487
+ ## 1.13.0
488
+
489
+ The operator's rating on each result card. The operator's rather than the
490
+ route's: a single route rarely has enough published reviews to clear the
491
+ display threshold, and a card with a rating beats one without.
492
+
493
+ ## [1.12.0] - 2026-08-01
494
+
495
+ ### Removed
496
+
497
+ - **The Booking.com hotel banner.** It was unmounted from `Found` earlier on
498
+ 2026-08-01 for sitting between the date strip and the trips - advertising a
499
+ hotel to somebody still deciding whether they can travel at all - and the
500
+ component is now deleted rather than kept dormant. It was never an
501
+ integration, only a hand-built `searchresults.xu.html` link built from
502
+ `city.booking_id`; leaving the component, the column and its admin field in
503
+ place made it look like one. The `routes_order.step2.booking` strings and
504
+ the `s-ec.bstatic.com` CSP entry go with it.
505
+
506
+ ## [1.11.1] - 2026-08-01
507
+
508
+ ### Changed
509
+
510
+ - **Coupon and payment method moved into the sidebar**, coupon directly
511
+ above the totals it changes and the method picker directly above the
512
+ button that acts on it. Both sat several sections away in the main
513
+ column, so applying a coupon changed a number that was off screen.
514
+ - **The results heading names the trip** - "Online Bus tickets from Tirana
515
+ Albania to Thessaloniki Greece" instead of "Select a Departure Schedule".
516
+ Countries are dropped when both ends share one, since "from Tirana
517
+ Albania to Korca Albania" is noise. Read from the city list rather than
518
+ from the results, so the heading is right even when the search finds
519
+ nothing - which is exactly when knowing what was searched for matters.
520
+ - **Order summary**: the operator's logo under the route name, departure
521
+ and arrival icons sitting on the connecting line, and the departure date
522
+ and time in bold - the two things a buyer checks before paying.
523
+
524
+ ### Fixed
525
+
526
+ - The coupon and payment blocks switched layout at a VIEWPORT breakpoint,
527
+ so in the ~290px sidebar they used the wide arrangement and the coupon
528
+ input came out 86px wide. They now wrap on their own width.
529
+
530
+ ## [1.11.0] - 2026-08-01
531
+
532
+ ### Changed
533
+
534
+ - **Passenger details and checkout are one screen.** Split across two
535
+ steps, the buyer typed passenger details with no sight of the price, then
536
+ saw the price on a screen where they could no longer check what they had
537
+ typed - and ticking an add-on changed a total that had already scrolled
538
+ away. The wizard is four steps now, not five.
539
+ - **A sticky sidebar** carries the trip, the ticket price, each add-on, any
540
+ discount, the total due, and the button that commits to it. The button
541
+ follows the chosen method, so it reads "Reserve" for a reservation rather
542
+ than claiming to take a payment.
543
+ - **The trip summary states both ends of each leg**, city and country in
544
+ full, origin above destination. It previously showed one end only - the
545
+ departure point outbound, the arrival point on the return - so a one-way
546
+ buyer reached payment having never been shown where the bus drops them.
547
+ - **Required fields are marked.** The form validated them but never said
548
+ which they were, so the first a buyer heard of it was an error message
549
+ after pressing pay.
550
+
551
+ `usePostOrder` now takes the passenger details in its mutation variables
552
+ rather than as a hook argument: on one screen they are only known at the
553
+ moment pay is pressed, so a closure captured at render time would have sent
554
+ stale state. The two forms stay separate react-hook-form instances - they
555
+ validate different things, and billing is not rendered at all for a
556
+ signed-in buyer - and paying validates them in order, stopping at the first
557
+ failure so the buyer lands on the thing that needs fixing.
558
+
559
+ Verified by completing a real reservation end to end: order 61 / ticket 75
560
+ carried every entered value, seat included.
561
+
562
+ ## [1.10.0] - 2026-08-01
563
+
564
+ ### Added
565
+
566
+ - **A search that finds nothing now answers instead of apologising.** In
567
+ place of the bare "we couldn't find any tickets", the page offers nearby
568
+ departure cities, nearby arrival cities and other dates that have seats.
569
+ Every suggestion is a search obtapi has already confirmed returns
570
+ results on the requested date, quoted with its fare, so a click is never
571
+ a second dead end. Nearby cities are ordered by real distance from the
572
+ city that was asked for - the passenger's problem is getting to the bus,
573
+ not saving two euros.
574
+ - Fetched only once the departure search has come back empty, so a search
575
+ that succeeded never pays for it.
576
+
577
+ Suggestion links do a full page load on purpose: RoutesOrder holds the
578
+ search in component state and Step1 only starts a search when it has not
579
+ already redirected, so a client-side navigation would change the URL and
580
+ leave the empty result set on screen.
581
+
582
+ ## [1.9.6] - 2026-08-01
583
+
584
+ ### Added
585
+
586
+ - **The search form stays on screen while choosing a route.** Adjusting a
587
+ date, a city or the passenger count used to mean "Back to Search", which
588
+ threw the results away along with any filters. Shown only on the
589
+ route-selection screens: past that point the passenger has committed to a
590
+ specific trip, and re-running the search under them would discard details
591
+ they had already typed.
592
+
593
+ ## [1.9.5] - 2026-08-01
594
+
595
+ ### Fixed
596
+
597
+ - **The column header row now lines up with the result cards** - 0px drift
598
+ on all five columns, and content-independent. It had 4px horizontal
599
+ padding against the card's 15px and no column gap against the card's
600
+ 15px, which pushed every label right of the data it named (Duration was
601
+ out by 34px).
602
+ - **The card's two location columns are now equal width.** `flex: 1` with
603
+ the default `min-width: auto` meant they sized to their content, so the
604
+ arrival column (long city plus long stop name) took 215px while the
605
+ departure column got 155px. Beyond breaking any header alignment, this
606
+ let one long stop name steal width from the other side of the card.
607
+
608
+ ## [1.9.4] - 2026-08-01
609
+
610
+ ### Changed
611
+
612
+ - **Fare decimals ride as superscript** and the currency is set smaller.
613
+ On a fare board the cents are almost never what anyone is comparing, and
614
+ at full size they took a third of the width of the widest thing on the
615
+ card. Split from obtapi's already-formatted string rather than
616
+ re-formatted from `price.value`, so the label's own currency and number
617
+ formatting stay the single source of truth - an unexpected format falls
618
+ back to rendering the string whole.
619
+ - **The taxes/passenger line is smaller and tucked against the price**,
620
+ which reads as one unit and frees the room the Select button needed.
621
+ - **Amenities moved into the details row** as its first column, so route,
622
+ transit and policies sit beside them - four columns instead of a separate
623
+ full-width band under every card.
624
+ - **Amenities are icon-only on this page**, with the name as a hover
625
+ tooltip (`compact` on `RouteFeature`). Eight amenities spelled out took
626
+ more width than everything else in that row put together.
627
+
628
+ ## [1.9.3] - 2026-08-01
629
+
630
+ ### Changed
631
+
632
+ - **Trip length reads as a duration, not a clock time.** "08:30" sat
633
+ directly between two real departure times with nothing to say it meant
634
+ eight and a half hours rather than half past eight. It now renders as
635
+ `07h 30m` behind an hourglass, and an exact number of hours drops the
636
+ minutes (`08h`, not `08h 00m`). The unit labels come from the translation
637
+ files - "h"/"m" are a Latin-script convention, so Greek reads `07ω 30λ`,
638
+ Bulgarian and Macedonian `07ч 30м`, Albanian `07o 30m`.
639
+ obtapi's `-` sentinel (no computable duration) still passes straight
640
+ through rather than becoming a confident "0h 0m".
641
+ - **Stop name down to 12px** on the result cards.
642
+
643
+ ## [1.9.2] - 2026-08-01
644
+
645
+ ### Changed
646
+
647
+ - **Stop name stepped down to 14px** on the result cards. At the same 16px
648
+ as body copy it competed with the city name above it; the city should be
649
+ read first and the stop only when it is needed.
650
+ - **Country code renders upper case and smaller.** It arrives from the
651
+ database lower case ("alb", "grc") - an ISO country code is upper case by
652
+ definition, so it is cased at render time rather than left to the data,
653
+ where every consumer would have to remember.
654
+
655
+ ## [1.9.1] - 2026-08-01
656
+
657
+ ### Changed
658
+
659
+ - **Filters became a sidebar** at 1280px and up - always visible, sticky
660
+ while the list scrolls, no click needed to discover them. Below 1280px
661
+ they stay the collapsible panel above the list. The breakpoint is 1280
662
+ rather than 1024 because the result cards switch to their wide row layout
663
+ at 1024px on VIEWPORT width, not container width, so taking a column out
664
+ of a 1024px window would leave the cards using a layout their remaining
665
+ space cannot carry.
666
+ - **Facet counts are a filled pill**, separate from the from-price. They
667
+ used to read as one muted string ("5 · 38.00 EUR") where the two numbers
668
+ ran together and it was not obvious which was which.
669
+ - **Facet labels wrap instead of truncating.** In a sidebar column an
670
+ ellipsis reduced two different stops at the same city to the same
671
+ "Terminali Lindor Inte..." - the one distinction the filter exists to
672
+ make.
673
+ - **The result-count / sort bar sits on the card surface** rather than
674
+ floating on the page background.
675
+
676
+ ### Fixed
677
+
678
+ - Filter panel grid tracks used `1fr`, whose implicit min-width is `auto`,
679
+ so a long stop name pushed the whole panel wider than its column and
680
+ pushed the counts and prices out of view entirely. Now `minmax(0, 1fr)`.
681
+
9
682
  ## [1.9.0] - 2026-08-01
10
683
 
11
684
  ### Added