@autobusal/routes-order 1.37.4 → 1.37.6

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.
@@ -0,0 +1,282 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Container = styled.section`
4
+ margin-top: 25px;
5
+ `;
6
+
7
+ export const Heading = styled.h2`
8
+ margin: 0 0 8px;
9
+ `;
10
+
11
+ /**
12
+ * One sentence saying what the table underneath is.
13
+ *
14
+ * Edited: Claude - Date: 2026-09-16
15
+ *
16
+ * Written for the reader who arrived asking whether there is a bus on a
17
+ * particular day - and, just as much, for the assistant answering that
18
+ * question on their behalf. A table of numbers with no sentence around it
19
+ * is perfectly readable to a person looking at it and gives a machine
20
+ * quoting the page nothing to quote.
21
+ */
22
+ export const Lead = styled.p`
23
+ margin: 0 0 15px;
24
+ color: ${ props => props.theme.font.faded };
25
+ line-height: 1.6;
26
+ `;
27
+
28
+ /**
29
+ * Sideways scrolling is confined to the table, never the page.
30
+ */
31
+ export const Wrap = styled.div`
32
+ overflow-x: auto;
33
+ `;
34
+
35
+ /**
36
+ * A real table, for the same reason the timetable above is one: this is
37
+ * tabular data, and the structure that makes it readable is the structure
38
+ * that lets a crawler understand the columns.
39
+ *
40
+ * Edited: Claude - Date: 2026-09-16
41
+ *
42
+ * Grouped by date, with the date as a row heading spanning the width -
43
+ * thirty separate tables would repeat the column headers thirty times, and
44
+ * a flat list of two hundred rows with the date in a seventh column is
45
+ * unreadable at exactly the moment somebody is scanning for one day.
46
+ *
47
+ * NO INTERACTIVITY ANYWHERE IN IT. A prerendered snapshot shows a
48
+ * non-JavaScript crawler precisely what a reader sees.
49
+ */
50
+ export const Table = styled.table`
51
+ width: 100%;
52
+ min-width: 620px;
53
+ border-collapse: collapse;
54
+
55
+ th, td {
56
+ padding: 10px 12px;
57
+ text-align: left;
58
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
59
+ white-space: nowrap;
60
+ }
61
+
62
+ thead th {
63
+ font-size: ${ props => props.theme.size.s };
64
+ color: ${ props => props.theme.font.faded };
65
+ font-weight: 400;
66
+ }
67
+
68
+ /*
69
+ * The date, as a heading row. Sticky-looking rather than sticky: a
70
+ * position: sticky heading inside a table fights the sticky section nav
71
+ * at the top of the page, and the two overlapping is worse than neither.
72
+ */
73
+ tbody th {
74
+ padding: 16px 12px 8px;
75
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
76
+ font-size: ${ props => props.theme.size.s };
77
+ font-weight: 700;
78
+ background: ${ props => props.theme.background.normal };
79
+ }
80
+
81
+ /* a day nothing runs on - stated, never omitted */
82
+ tbody td.none {
83
+ color: ${ props => props.theme.font.faded };
84
+ }
85
+
86
+ tbody tr:last-child td {
87
+ border-bottom: none;
88
+ }
89
+
90
+ /*
91
+ * The same reasoning as the timetable's own narrow-screen rule: six
92
+ * columns need 620px and the phone reading this has 375, so the columns
93
+ * that fall off the right edge would be the ones that matter most.
94
+ *
95
+ * Duration is the one that goes, and it is the right one: it is DERIVED
96
+ * from the departure and arrival already on the row, so a reader loses
97
+ * arithmetic rather than a fact. Everything else stays, including the
98
+ * seat count, which is the whole reason this section exists.
99
+ */
100
+ @media (max-width: 767px) {
101
+ min-width: 0;
102
+ table-layout: fixed;
103
+
104
+ th:nth-child(3), td:nth-child(3) {
105
+ display: none;
106
+ }
107
+
108
+ th, td {
109
+ padding: 8px 4px;
110
+ font-size: ${ props => props.theme.size.xs };
111
+ }
112
+
113
+ /* the times, the fare and the link take what they need; the operator
114
+ takes the remainder and wraps inside it */
115
+ th:nth-child(1), td:nth-child(1),
116
+ th:nth-child(2), td:nth-child(2) {
117
+ width: 17%;
118
+ }
119
+
120
+ th:nth-child(5), td:nth-child(5) {
121
+ width: 23%;
122
+ }
123
+
124
+ th:nth-child(6), td:nth-child(6) {
125
+ width: 17%;
126
+ }
127
+
128
+ th:nth-child(4), td:nth-child(4),
129
+ td.none {
130
+ white-space: normal;
131
+ word-break: break-word;
132
+ }
133
+ }
134
+ `;
135
+
136
+ /**
137
+ * A clock time that also carries the machine-readable instant.
138
+ *
139
+ * Edited: Claude - Date: 2026-09-16
140
+ *
141
+ * A <time datetime> rather than a <strong>, and the attribute is the very
142
+ * string the BusTrip markup emits - so the page does not merely happen to
143
+ * agree with its own structured data, it renders it.
144
+ */
145
+ export const Time = styled.time`
146
+ font-weight: 700;
147
+ `;
148
+
149
+ export const Faded = styled.span`
150
+ color: ${ props => props.theme.font.faded };
151
+ `;
152
+
153
+ /**
154
+ * The fare, with the seat count under it - the arrangement the result
155
+ * cards already use, because how many are left is a fact about this fare on
156
+ * this coach and is read at the same moment as the price.
157
+ */
158
+ export const Fare = styled.div`
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 2px;
162
+ `;
163
+
164
+ export const Price = styled.strong`
165
+ font-weight: 700;
166
+ white-space: nowrap;
167
+ `;
168
+
169
+ /**
170
+ * How many seats are still on sale.
171
+ *
172
+ * Edited: Claude - Date: 2026-09-16
173
+ *
174
+ * The same string and the same threshold as the result cards - see
175
+ * SeatsLeft in ../Found/Route/styles and LOW_SEATS in ./service, where the
176
+ * reasoning lives. The component is not shared because the container is
177
+ * not: there it
178
+ * is centred under a fare in a card, here it is left-aligned in a table
179
+ * cell, and a shared component carrying both would be two components in a
180
+ * trench coat.
181
+ *
182
+ * What IS shared is the only thing that matters - the wording and the
183
+ * number - because a coach that reads "nearly full" on the search results
184
+ * and "plenty of room" here is a bug, and one that nobody would notice
185
+ * until a customer did.
186
+ */
187
+ export const SeatsLeft = styled.span<{ $low: boolean }>`
188
+ font-size: ${ props => props.theme.size.xxs };
189
+ line-height: 1.25;
190
+ white-space: nowrap;
191
+ font-weight: ${ props => (props.$low ? 700 : 400) };
192
+ color: ${ props => (props.$low ? props.theme.font.error : props.theme.font.faded) };
193
+ `;
194
+
195
+ /**
196
+ * The booking link for one departure.
197
+ *
198
+ * Edited: Claude - Date: 2026-09-16
199
+ *
200
+ * A plain anchor, not a router Link. The URL is built by obtapi and arrives
201
+ * absolute, carrying the date, the direction and its tracking parameters;
202
+ * picking it apart into a router path would mean re-deriving on the client
203
+ * something the server already decided, and getting it subtly wrong on a
204
+ * whitelabel domain. Same tab, because it is our own site and the reader is
205
+ * continuing, not leaving.
206
+ */
207
+ export const Book = styled.a`
208
+ display: inline-block;
209
+ padding: 6px 12px;
210
+ border-radius: ${ props => props.theme.borderRadius };
211
+ background: ${ props => props.theme.primary.neutral };
212
+ color: ${ props => props.theme.font.normal };
213
+ font-size: ${ props => props.theme.size.xs };
214
+ font-weight: 700;
215
+ white-space: nowrap;
216
+
217
+ &:hover {
218
+ text-decoration: none;
219
+ background: ${ props => props.theme.background.neutral };
220
+ }
221
+ `;
222
+
223
+ /**
224
+ * Everything past the first week.
225
+ *
226
+ * Edited: Claude - Date: 2026-09-16
227
+ *
228
+ * A native <details>, for the one property that decides it: THE ROWS ARE IN
229
+ * THE DOM WHETHER IT IS OPEN OR SHUT. This page is snapshot by the
230
+ * prerender crawl and read by machines that do not click, and the whole
231
+ * point of the section is that all thirty days are visible to them. An
232
+ * accordion built out of useState would ship a crawler seven days and a
233
+ * button - which is exactly how the homepage FAQ came to be prerendered
234
+ * with zero answers in it - while the BusTrip markup alongside promised
235
+ * thirty days of departures.
236
+ *
237
+ * Collapsed rather than not, because thirty day-blocks between the
238
+ * timetable and the footer is a page nobody reaches the bottom of.
239
+ */
240
+ export const Later = styled.details`
241
+ margin-top: 4px;
242
+
243
+ &[open] summary {
244
+ margin-bottom: 4px;
245
+ }
246
+ `;
247
+
248
+ export const More = styled.summary`
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 8px;
252
+ padding: 10px 0;
253
+ cursor: pointer;
254
+ font-size: ${ props => props.theme.size.s };
255
+ font-weight: 700;
256
+ list-style: none;
257
+
258
+ /* the default disclosure triangle, replaced below by one that turns */
259
+ &::-webkit-details-marker {
260
+ display: none;
261
+ }
262
+
263
+ &:after {
264
+ content: '+';
265
+ color: ${ props => props.theme.font.faded };
266
+ font-weight: 400;
267
+ }
268
+
269
+ [open] > &:after {
270
+ content: '-';
271
+ }
272
+ `;
273
+
274
+ /**
275
+ * When the seats and fares above were measured - see `checked` in
276
+ * ./service for why the page says so at all.
277
+ */
278
+ export const Updated = styled.p`
279
+ margin: 12px 0 0;
280
+ font-size: ${ props => props.theme.size.xs };
281
+ color: ${ props => props.theme.font.faded };
282
+ `;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What /api/routes/search/availability answers with.
3
+ *
4
+ * Edited: Claude - Date: 2026-09-16
5
+ *
6
+ * A SECOND endpoint, deliberately, rather than another branch of
7
+ * /facts. The facts payload is cached for six hours because everything in
8
+ * it - the duration range, the fares-from, the operators - is a property of
9
+ * the TIMETABLE and does not change between two visitors. A seat count is
10
+ * the opposite: it is true at the moment it was measured and false by the
11
+ * afternoon, and folding it into a payload with a six-hour cache would have
12
+ * published stale seat counts as structured data.
13
+ *
14
+ * The nullable fields mirror ScheduleRow in ../Facts/types for the same
15
+ * reason it has them: this arrives over the wire from a search that can
16
+ * legitimately not know an operator name or a formatted fare, and a page
17
+ * that renders "undefined" in a timetable cell is worse than one that
18
+ * renders a dash.
19
+ */
20
+ export interface AvailabilityDeparture {
21
+ // local clock time at the origin, "08:30"
22
+ departure: string
23
+ arrival: string | null
24
+ /**
25
+ * Days between boarding and arriving - 0 same day, 1 the next morning.
26
+ * Optional because an older build of the endpoint may not send it, and
27
+ * absent has to read as "same day" rather than as a crash.
28
+ */
29
+ arrival_offset?: number
30
+ duration_min: number | null
31
+ operator: string | null
32
+ // how many intermediate calls - carried for completeness; this section
33
+ // does not show it, and the timetable below already does
34
+ stops: number
35
+ price: number | null
36
+ price_display: string | null
37
+ // null where the search cannot say - see the render, which then shows
38
+ // nothing rather than guessing at a number people act on
39
+ seats_left: number | null
40
+ // the booking URL for THIS departure, already carrying its date and its
41
+ // tracking parameters - built by obtapi, never assembled here
42
+ url: string | null
43
+ }
44
+
45
+ export interface AvailabilityDay {
46
+ // ISO date, "2026-09-20"
47
+ date: string
48
+ // empty on a day nothing runs, which is information rather than an
49
+ // omission - see Availability.tsx
50
+ departures: AvailabilityDeparture[]
51
+ }
52
+
53
+ export interface AvailabilityData {
54
+ from: string
55
+ to: string
56
+ from_slug: string
57
+ to_slug: string
58
+ // ISO 4217, for the Offer - price_display has the symbol baked in and
59
+ // guessing a code back out of it would be confidently wrong
60
+ currency: string | null
61
+ // IANA zone of the ORIGIN, which is what turns "08:30 on 20 September"
62
+ // into an instant a machine can compare against another operator's
63
+ timezone: string
64
+
65
+ /**
66
+ * The clock the ARRIVAL times are on, which is not the departure city's on
67
+ * exactly the journeys this section exists for.
68
+ *
69
+ * Edited: Claude - Date: 2026-09-16
70
+ *
71
+ * A timetable prints every stop in its own local time. Stamping an arrival
72
+ * with the origin's offset publishes an instant that is wrong by the
73
+ * difference between the two - Thessaloniki to Tirana arrives 16:30 in
74
+ * Tirana, an hour after the +03:00 reading of the same clock face - and in
75
+ * schema.org markup that is a claim an assistant repeats as fact.
76
+ *
77
+ * Optional: an older API does not send it, and the code falls back to the
78
+ * departure zone, which is exactly what it did before this existed.
79
+ */
80
+ arrival_timezone?: string
81
+ // when the seats and fares below were measured
82
+ generated_at: string
83
+ days: AvailabilityDay[]
84
+ }
85
+
86
+ export interface AvailabilityResponse {
87
+ // an envelope rather than a bare null, matching FactsResponse - `{}` is
88
+ // truthy in JavaScript and a bare null tells a caller nothing about
89
+ // whether the request was even understood
90
+ availability: AvailabilityData | null
91
+ }
@@ -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
 
package/Facts/service.ts CHANGED
@@ -41,8 +41,15 @@ export const stopped = (facts: FactsData): (Stopped | null) => {
41
41
  * ISO month index to the month names the site already ships in all fifteen
42
42
  * languages (`data.months.*`), rather than a new set nobody would have
43
43
  * translated for one sentence.
44
+ *
45
+ * Edited: Claude - Date: 2026-09-16
46
+ * Exported, because the next-30-days section names months too. Two copies
47
+ * of this list is two chances for one of them to be edited - and the whole
48
+ * value of leaning on `data.months.*` is that there is exactly one set of
49
+ * month names on the site, in fifteen languages, that somebody has already
50
+ * proof-read.
44
51
  */
45
- const MONTHS = [
52
+ export const MONTHS = [
46
53
  'january', 'february', 'march', 'april', 'may', 'june',
47
54
  'july', 'august', 'september', 'october', 'november', 'december'
48
55
  ];
@@ -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,28 @@ 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 count of
101
+ * ours, so it simply shows nothing.
102
+ *
103
+ * Edited: Claude - Date: 2026-09-16
104
+ * `seats_left`, NOT `trip.available_seats`. The first version of this read
105
+ * the capacity column - the only seat number the payload had - and shipped
106
+ * it as "Seats left", which announces an empty coach on a sold-out one.
107
+ * The search has always worked out the real figure to decide whether to
108
+ * offer the departure; it just never sent it. Now it does.
109
+ */
110
+ const seats = typeof data.seats_left === 'number' && data.seats_left > 0
111
+ ? data.seats_left
112
+ : null;
113
+
81
114
  // Edited: Ferjolt Ozuni - Date: 2026-08-03
82
115
  // Only today's results carry this - obtapi sends null on every other date,
83
116
  // so there is no "in 26 days" badge to suppress here.
@@ -296,6 +329,19 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
296
329
  { details ? <HiChevronUp /> : <HiChevronDown /> }
297
330
  { t('routes_order.step2.route.details') }
298
331
  </ButtonDetails>
332
+
333
+ { /* Claude - 2026-09-16 (Ferjolt: "show somewhere on each search
334
+ result the number of available/bookable seats per route").
335
+
336
+ Last in source, and ordered by class rather than by position -
337
+ see Price in ./styles. It sits inside the fare cell because
338
+ how many are left is a fact about this fare on this coach, and
339
+ it is read at the same moment as the price. */ }
340
+ { seats !== null && (
341
+ <SeatsLeft className="seats-left" $low={ seats <= LOW_SEATS }>
342
+ { t('routes_order.step2.route.seats_left', { count: seats }) }
343
+ </SeatsLeft>
344
+ ) }
299
345
  </Price>
300
346
  </Trip>
301
347
 
@@ -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
+ `;