@autobusal/routes-order 1.37.4 → 1.37.5

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.
@@ -170,17 +170,40 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
170
170
  });
171
171
  }
172
172
 
173
- // Asked only where the answer is a real count. `direct` is meaningless
174
- // without a total to read it against, and `departures` is that total.
173
+ /**
174
+ * DIRECT MEANS NO CHANGE OF BUS. That is the question being asked.
175
+ *
176
+ * Edited: Claude - Date: 2026-09-16
177
+ *
178
+ * This was answered out of `facts.direct` alone, which counts services
179
+ * with no intermediate STOPS - so it said "No. Every scheduled service
180
+ * calls at stops along the way" on 201 of the 202 published pairs, under
181
+ * a timetable showing a single coach running the whole route. Every bus
182
+ * in the Balkans calls somewhere, so the answer was structurally always
183
+ * no. A traveller reads that as "you have to change buses", and so does
184
+ * an assistant: ChatGPT quoted the contradiction back to us against the
185
+ * Thessaloniki-Tirana page.
186
+ *
187
+ * Nothing in Libraries\Routes\Search ever joins two routes into one
188
+ * journey, so every service that can be booked here is one bus end to
189
+ * end, and yes is not a generous reading - it is the fact.
190
+ *
191
+ * The stop count keeps its own word, NON-STOP, and its own sentence. It
192
+ * is the rarer and more valuable fact, still counted out of the total
193
+ * rather than claimed for the whole pair, but it no longer decides
194
+ * whether the pair has a direct bus at all.
195
+ */
175
196
  if (facts.departures > 0 && !dead) {
176
197
  all.push({
177
198
  id: 'direct',
178
199
  q: t('routes_order.schema.direct_q', cities),
179
- a: facts.direct === 0
180
- ? t('routes_order.schema.direct_a_none', cities)
181
- : (facts.direct === facts.departures
182
- ? t('routes_order.schema.direct_a_all', cities)
183
- : t('routes_order.schema.direct_a', { ...cities, count: facts.direct, total: facts.departures }))
200
+ a: facts.direct === facts.departures
201
+ ? t('routes_order.schema.direct_a_nonstop_all', cities)
202
+ : (facts.direct > 0
203
+ ? t('routes_order.schema.direct_a_nonstop_some', { ...cities, count: facts.direct, total: facts.departures })
204
+ : (facts.departures === 1
205
+ ? t('routes_order.schema.direct_a_through_single', cities)
206
+ : t('routes_order.schema.direct_a_through', { ...cities, total: facts.departures })))
184
207
  });
185
208
  }
186
209
 
@@ -190,9 +213,17 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
190
213
  all.push({
191
214
  id: 'overnight',
192
215
  q: t('routes_order.schema.overnight_q', cities),
216
+ // Edited: Claude - Date: 2026-09-16
217
+ // "1 of the 2 scheduled services ... travel overnight" was published
218
+ // on 40 pairs, in the visible FAQ and in the JSON-LD both. Its own
219
+ // key rather than an i18next plural, for the reason spelled out
220
+ // above departures_a_single: the eight Slavic locales here do not
221
+ // share English's one rule.
193
222
  a: facts.overnight === facts.departures
194
223
  ? t('routes_order.schema.overnight_a_all', cities)
195
- : t('routes_order.schema.overnight_a', { ...cities, count: facts.overnight, total: facts.departures })
224
+ : (facts.overnight === 1
225
+ ? t('routes_order.schema.overnight_a_single', { ...cities, total: facts.departures })
226
+ : t('routes_order.schema.overnight_a', { ...cities, count: facts.overnight, total: facts.departures }))
196
227
  });
197
228
  }
198
229
 
@@ -8,9 +8,20 @@ import { formatDuration, splitPrice } from '../refine';
8
8
  import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
9
9
  import { useGetSettings } from '@autobusal/providers/services';
10
10
  import { createDate, getDayParts } from '@autobusal/utilities';
11
- import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
11
+ import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, SeatsLeft, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay, Standard, Stops, StopsItems, StopRow, StopTime, StopDay, StopBorder } from './styles';
12
12
  import { FoundData } from '@autobusal/providers/types/routes';
13
13
 
14
+ /**
15
+ * At or below this many seats, the count is emphasised.
16
+ *
17
+ * Edited: Claude - Date: 2026-09-16
18
+ *
19
+ * A coach carries roughly fifty, so single figures are the point at which
20
+ * the number changes what somebody does about it rather than merely
21
+ * describing the bus.
22
+ */
23
+ const LOW_SEATS = 5;
24
+
14
25
  // neutral placeholder so a missing/broken operator logo doesn't render the
15
26
  // browser's broken-image icon in the route results
16
27
  const LOGO_FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='60' viewBox='0 0 120 60'%3E%3Crect width='120' height='60' rx='6' fill='%23e5e7eb'/%3E%3Cpath d='M42 22h36v14a2 2 0 0 1-2 2h-2a3 3 0 0 1-6 0H52a3 3 0 0 1-6 0h-2a2 2 0 0 1-2-2z' fill='%239ca3af'/%3E%3C/svg%3E";
@@ -78,6 +89,21 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
78
89
  ? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
79
90
  : undefined;
80
91
 
92
+ /**
93
+ * Seats still on sale on this coach.
94
+ *
95
+ * Edited: Claude - Date: 2026-09-16
96
+ *
97
+ * Typed rather than truthy-checked: 0 is a real answer from obtapi and it
98
+ * means a full bus, which never reaches a result list - and a `!` test
99
+ * would have hidden the field on exactly the coach it mattered most on if
100
+ * one ever did. An external-provider offer (Mode A) carries no trip of
101
+ * ours to count, so it simply shows nothing.
102
+ */
103
+ const seats = typeof data.trip?.available_seats === 'number' && data.trip.available_seats > 0
104
+ ? data.trip.available_seats
105
+ : null;
106
+
81
107
  // Edited: Ferjolt Ozuni - Date: 2026-08-03
82
108
  // Only today's results carry this - obtapi sends null on every other date,
83
109
  // so there is no "in 26 days" badge to suppress here.
@@ -296,6 +322,19 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
296
322
  { details ? <HiChevronUp /> : <HiChevronDown /> }
297
323
  { t('routes_order.step2.route.details') }
298
324
  </ButtonDetails>
325
+
326
+ { /* Claude - 2026-09-16 (Ferjolt: "show somewhere on each search
327
+ result the number of available/bookable seats per route").
328
+
329
+ Last in source, and ordered by class rather than by position -
330
+ see Price in ./styles. It sits inside the fare cell because
331
+ how many are left is a fact about this fare on this coach, and
332
+ it is read at the same moment as the price. */ }
333
+ { seats !== null && (
334
+ <SeatsLeft className="seats-left" $low={ seats <= LOW_SEATS }>
335
+ { t('routes_order.step2.route.seats_left', { count: seats }) }
336
+ </SeatsLeft>
337
+ ) }
299
338
  </Price>
300
339
  </Trip>
301
340
 
@@ -510,6 +510,17 @@ export const Price = styled.div`
510
510
  flex: 0 0 auto;
511
511
  }
512
512
 
513
+ /*
514
+ * Claude - 2026-09-16: last line of the fare cell on a phone, on a row of
515
+ * its own under the note and the toggle. By class for the same reason as
516
+ * the two above - a dynamic-price drop renders an extra child and shifts
517
+ * every nth-child index in this block by one.
518
+ */
519
+ & > .seats-left {
520
+ order: 5;
521
+ flex: 1 1 100%;
522
+ }
523
+
513
524
  @media (min-width: 768px) {
514
525
  flex: 0 0 165px;
515
526
  flex-direction: column;
@@ -530,7 +541,8 @@ export const Price = styled.div`
530
541
  * just now inside the price cell rather than under the whole card.
531
542
  */
532
543
  & > .price-notice,
533
- & > .details-toggle {
544
+ & > .details-toggle,
545
+ & > .seats-left {
534
546
  order: initial;
535
547
  flex-basis: auto;
536
548
  }
@@ -970,3 +982,33 @@ export const Day = styled.span`
970
982
  white-space: nowrap;
971
983
  color: ${ props => props.theme.primary.normal };
972
984
  `;
985
+
986
+ /**
987
+ * How many seats are still on sale, under the fare.
988
+ *
989
+ * Edited: Claude - Date: 2026-09-16
990
+ *
991
+ * Ferjolt asked for the seat count on every result. obtapi has always sent
992
+ * it (trip.available_seats) and nothing on the card ever showed it, so the
993
+ * only way to find out how full a coach was, was to try to book it.
994
+ *
995
+ * The string is "Seats left: 49", a label and a number, rather than
996
+ * "49 seats left" - this codebase carries no i18next plural rules, and a
997
+ * counted noun that has to agree in fifteen languages is how "1 stops"
998
+ * happened in the timetable. A colon sidesteps the agreement entirely and
999
+ * translates cleanly into all of them.
1000
+ *
1001
+ * Low stock is emphasised rather than coloured red on its own: it is a real
1002
+ * fact about the bus, not a countdown we invented to hurry anyone.
1003
+ */
1004
+ export const SeatsLeft = styled.span<{ $low: boolean }>`
1005
+ font-size: ${ props => props.theme.size.xxs };
1006
+ line-height: 1.25;
1007
+ text-align: left;
1008
+ font-weight: ${ props => (props.$low ? 700 : 400) };
1009
+ color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
1010
+
1011
+ @media (min-width: 768px) {
1012
+ text-align: center;
1013
+ }
1014
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.37.4",
3
+ "version": "1.37.5",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",