@autobusal/routes-order 1.38.1 → 1.39.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.
@@ -1,4 +1,5 @@
1
1
  import { TFunction } from 'i18next';
2
+ import { Link } from 'react-router-dom';
2
3
  import { NextDay } from '../Schedule/styles';
3
4
  import { Container, Heading, Lead, Wrap, Table, Time, Faded, Fare, Price, SeatsLeft, Book, Later, More, Updated } from './styles';
4
5
  import { LOW_SEATS, Day, Prepared } from './service';
@@ -27,6 +28,31 @@ interface Props {
27
28
  */
28
29
  export const VISIBLE = 7;
29
30
 
31
+ /**
32
+ * Claude - 2026-09-25 (audit): the API builds each row's `url` for the
33
+ * partner/assistant feed - absolute, and tagged
34
+ * utm_source=busmagus-api&utm_medium=assistant. Rendered as-is on our own
35
+ * pair page, every "Book" click was a full page reload that credited our own
36
+ * visitors to the assistant channel. On this origin it becomes an in-app
37
+ * route with those two tags removed; any other origin keeps the plain link.
38
+ */
39
+ const spaTarget = (url: string): string | null => {
40
+ try {
41
+ const parsed = new URL(url, window.location.href);
42
+
43
+ if (parsed.origin !== window.location.origin) {
44
+ return null;
45
+ }
46
+
47
+ parsed.searchParams.delete('utm_source');
48
+ parsed.searchParams.delete('utm_medium');
49
+
50
+ return parsed.pathname + parsed.search;
51
+ } catch {
52
+ return null;
53
+ }
54
+ };
55
+
30
56
  /**
31
57
  * Real departures on real dates, for the next thirty days.
32
58
  *
@@ -113,7 +139,15 @@ const Availability = ({ id, data, t }: Props): JSX.Element => {
113
139
  </td>
114
140
 
115
141
  <td>
116
- { row.url ? <Book href={ row.url }>{ t('routes_order.availability.book') }</Book> : null }
142
+ { row.url
143
+ ? (() => {
144
+ const to = spaTarget(row.url);
145
+
146
+ return to
147
+ ? <Book as={ Link } to={ to }>{ t('routes_order.availability.book') }</Book>
148
+ : <Book href={ row.url }>{ t('routes_order.availability.book') }</Book>;
149
+ })()
150
+ : null }
117
151
  </td>
118
152
  </tr>
119
153
  )) }
package/Facts/Facts.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { TFunction } from 'i18next';
2
2
  import { Container, Heading, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
3
3
  import { FactsData } from './types';
4
- import { DAYS, duration } from './questions';
4
+ import { DAYS, duration, policies } from './questions';
5
5
  import { stopped, detail } from './service';
6
6
 
7
7
  interface Props {
@@ -80,7 +80,21 @@ const Facts = ({ id, facts, heading = 'h2', t }: Props): JSX.Element => {
80
80
  // A thin pair can have none of the four sentences below, and an empty
81
81
  // <Prose> is still a box with a top margin - a gap under the grid that
82
82
  // reads as something failing to load.
83
- const prose = Boolean(countries || via || amenities || facts.operators.length > 0);
83
+ // Claude - 2026-09-26: the ticket / luggage / fare / speed sentences, the
84
+ // same strings the FAQ answers quote (see policies() in questions.ts). The
85
+ // "today" claims - cheapest day, fastest coach - are held back on a pair
86
+ // that has stopped running, exactly as the FAQ holds them back.
87
+ const stoppedRunning = Boolean(stopped(facts));
88
+
89
+ const sentences = policies(facts, t);
90
+
91
+ const fares = stoppedRunning ? null : [ sentences.cheapest, sentences.range ].filter(Boolean).join(' ') || null;
92
+
93
+ const fastest = stoppedRunning ? null : sentences.fastest;
94
+
95
+ const tickets = [ sentences.flex, sentences.baggage, sentences.eTicket ].filter(Boolean) as string[];
96
+
97
+ const prose = Boolean(countries || via || amenities || facts.operators.length > 0 || tickets.length > 0 || fares || fastest);
84
98
 
85
99
  const range = facts.duration_min !== null && facts.duration_max !== null
86
100
  ? (facts.duration_min === facts.duration_max
@@ -263,6 +277,12 @@ const Facts = ({ id, facts, heading = 'h2', t }: Props): JSX.Element => {
263
277
  </p>
264
278
  ) }
265
279
 
280
+ { tickets.map(line => <p key={ line }>{ line }</p>) }
281
+
282
+ { fares && <p>{ fares }</p> }
283
+
284
+ { fastest && <p>{ fastest }</p> }
285
+
266
286
  { facts.operators.length > 0 && (
267
287
  <Operators>
268
288
  { t('routes_order.facts.operators', { operators: facts.operators.join(', ') }) }
@@ -62,6 +62,115 @@ export const duration = (minutes: number, t: TFunction<'common'>): string => (
62
62
  : t('routes_order.facts.duration_value', { hours: Math.floor(minutes / 60), minutes: minutes % 60 })
63
63
  );
64
64
 
65
+ /**
66
+ * The ticket, fare and speed sentences, built ONCE.
67
+ *
68
+ * Claude - 2026-09-26
69
+ *
70
+ * The facts block prints these and the FAQ answers quote them, so they are
71
+ * built here, beside the questions, for the reason this file exists: the
72
+ * FAQPage answer has to be the sentence on the page, character for
73
+ * character. Each one is null when its data is missing - and then neither
74
+ * the block nor the FAQ says anything about it.
75
+ *
76
+ * Same restraint as everything above. The Flexible Ticket sentence counts
77
+ * coaches ("3 of the 10") rather than claiming the pair; the notice window
78
+ * is a range when the operators differ; luggage is the least generous
79
+ * allowance on the pair (obtapi's min), so it holds on every coach; and a
80
+ * cheapest DAY is named only when obtapi found one - otherwise just the
81
+ * 30-day range is said.
82
+ */
83
+ export interface PolicySentences {
84
+ flex: string | null
85
+ baggage: string | null
86
+ eTicket: string | null
87
+ cheapest: string | null
88
+ range: string | null
89
+ fastest: string | null
90
+ }
91
+
92
+ const hoursRange = (min: number | null, max: number | null): string | null => (
93
+ min === null || max === null ? null : (min === max ? String(min) : `${ min }–${ max }`)
94
+ );
95
+
96
+ export const policies = (facts: FactsData, t: TFunction<'common'>): PolicySentences => {
97
+ const policy = facts.policies;
98
+
99
+ const flex = (() => {
100
+ if (!policy) {
101
+ return null;
102
+ }
103
+
104
+ const offer = policy.flex;
105
+
106
+ if (!offer) {
107
+ return t('routes_order.facts.flex_none');
108
+ }
109
+
110
+ const price = offer.minimum > 0 && offer.minimum_display
111
+ ? t('routes_order.facts.flex_price', { percent: offer.percent, minimum: offer.minimum_display })
112
+ : t('routes_order.facts.flex_price_percent', { percent: offer.percent });
113
+
114
+ const lead = offer.coaches >= offer.of
115
+ ? t('routes_order.facts.flex_all', { price })
116
+ : t('routes_order.facts.flex_some', { price, coaches: offer.coaches, total: offer.of });
117
+
118
+ const refund = hoursRange(offer.refund_hours_min, offer.refund_hours_max);
119
+
120
+ const reschedule = hoursRange(offer.reschedule_hours_min, offer.reschedule_hours_max);
121
+
122
+ const notice = refund && reschedule
123
+ ? (refund === reschedule
124
+ ? t('routes_order.facts.flex_both', { hours: refund })
125
+ : `${ t('routes_order.facts.flex_cancel', { hours: refund }) } ${ t('routes_order.facts.flex_change_also', { hours: reschedule }) }`)
126
+ : (refund
127
+ ? t('routes_order.facts.flex_cancel', { hours: refund })
128
+ : (reschedule ? t('routes_order.facts.flex_change', { hours: reschedule }) : null));
129
+
130
+ return notice ? `${ lead } ${ notice }` : lead;
131
+ })();
132
+
133
+ const baggage = (() => {
134
+ const bags = policy?.baggage;
135
+
136
+ if (!bags || !bags.bags) {
137
+ return null;
138
+ }
139
+
140
+ if (bags.kg) {
141
+ return bags.bags === 1
142
+ ? t('routes_order.facts.baggage_single', { kg: bags.kg })
143
+ : t('routes_order.facts.baggage', { bags: bags.bags, kg: bags.kg });
144
+ }
145
+
146
+ return bags.bags === 1
147
+ ? t('routes_order.facts.baggage_single_no_kg')
148
+ : t('routes_order.facts.baggage_no_kg', { bags: bags.bags });
149
+ })();
150
+
151
+ const eTicket = policy ? t(`routes_order.facts.e_ticket_${ policy.e_ticket }`) : null;
152
+
153
+ const days = facts.cheapest && facts.cheapest.days.length > 0
154
+ ? facts.cheapest.days.map(day => t(`data.days.${ DAYS[day] }`)).join(', ')
155
+ : null;
156
+
157
+ const cheapest = days ? t('routes_order.facts.cheapest_days', { days }) : null;
158
+
159
+ const range = facts.cheapest
160
+ ? (facts.cheapest.max_display && facts.cheapest.max_display !== facts.cheapest.price_display
161
+ ? t('routes_order.facts.cheapest_range', { min: facts.cheapest.price_display, max: facts.cheapest.max_display })
162
+ : t('routes_order.facts.cheapest_flat', { price: facts.cheapest.price_display }))
163
+ : null;
164
+
165
+ const fastest = facts.fastest
166
+ ? (facts.fastest.departure && facts.fastest.operator
167
+ ? t('routes_order.facts.fastest', { duration: duration(facts.fastest.minutes, t), time: facts.fastest.departure, operator: facts.fastest.operator })
168
+ : t('routes_order.facts.fastest_short', { duration: duration(facts.fastest.minutes, t) }))
169
+ : null;
170
+
171
+ return { flex, baggage, eTicket, cheapest, range, fastest };
172
+ };
173
+
65
174
  const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
66
175
  const all: Question[] = [];
67
176
 
@@ -259,6 +368,42 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
259
368
  });
260
369
  }
261
370
 
371
+ /**
372
+ * Claude - 2026-09-26
373
+ *
374
+ * Tickets, luggage, the cheapest day and the fastest coach - the
375
+ * questions asked after "how long" and "how much". Answers are the
376
+ * sentences policies() builds for the facts block, so the two cannot
377
+ * drift. Asked only when obtapi sent the data (older cache entries carry
378
+ * no `policies`), and the cheapest-day and fastest questions - present
379
+ * tense claims about running coaches - not on a pair that has stopped.
380
+ */
381
+ const said = policies(facts, t);
382
+
383
+ if (said.flex) {
384
+ all.push({ id: 'flex', q: t('routes_order.facts.faq_flex_q', cities), a: said.flex });
385
+ }
386
+
387
+ if (said.baggage) {
388
+ all.push({ id: 'baggage', q: t('routes_order.facts.faq_baggage_q', cities), a: said.baggage });
389
+ }
390
+
391
+ if (said.eTicket) {
392
+ all.push({ id: 'e_ticket', q: t('routes_order.facts.faq_e_ticket_q', cities), a: said.eTicket });
393
+ }
394
+
395
+ if (said.cheapest && !dead) {
396
+ all.push({
397
+ id: 'cheapest',
398
+ q: t('routes_order.facts.faq_cheapest_q', cities),
399
+ a: said.range ? `${ said.cheapest } ${ said.range }` : said.cheapest
400
+ });
401
+ }
402
+
403
+ if (said.fastest && !dead) {
404
+ all.push({ id: 'fastest', q: t('routes_order.facts.faq_fastest_q', cities), a: said.fastest });
405
+ }
406
+
262
407
  if (facts.distance) {
263
408
  all.push({
264
409
  id: 'distance',
package/Facts/types.ts CHANGED
@@ -119,6 +119,53 @@ export interface FactsData {
119
119
  distance: { road: number | null, approx: number } | null
120
120
  // every service on the pair, sorted by departure - Tier 3.3
121
121
  schedule: ScheduleRow[]
122
+ /**
123
+ * Claude - 2026-09-26
124
+ *
125
+ * What a ticket on this pair lets you do. Tickets are non-refundable by
126
+ * default; `flex` is the Flexible Ticket add-on where the pair's operators
127
+ * sell it - see FactsPolicies. OPTIONAL like `service`: warm six-hour cache
128
+ * entries from before this field existed do not carry it, and absent means
129
+ * the page says nothing rather than "not available".
130
+ */
131
+ policies?: FactsPolicies
132
+ /**
133
+ * Claude - 2026-09-26
134
+ *
135
+ * Real fares over the next 30 days. `days` is the ISO weekdays that are
136
+ * cheaper than the rest, EMPTY when no weekday is - and then there is no
137
+ * "cheapest day" to name. `price_display`/`max_display` are the range.
138
+ */
139
+ cheapest?: { days: number[], price: number, price_display: string, max_display: string | null } | null
140
+ // Claude - 2026-09-26: the quickest scheduled coach on the pair
141
+ fastest?: { minutes: number, departure: string | null, operator: string | null } | null
142
+ }
143
+
144
+ /**
145
+ * Claude - 2026-09-26
146
+ *
147
+ * `flex`: priced max(percent of the fare, minimum) per passenger, and buys
148
+ * the right to cancel (refund) or change (reschedule) up to a notice window
149
+ * before departure. The windows are per operator, so each is a min-max
150
+ * range across the pair's coaches, and `coaches` of `of` sell it at all.
151
+ *
152
+ * `baggage` is the smallest allowance across the pair (so it holds on every
153
+ * coach), `e_ticket` whether a phone ticket is accepted on all, some or none.
154
+ */
155
+ export interface FactsPolicies {
156
+ flex: {
157
+ percent: number
158
+ minimum: number
159
+ minimum_display: string | null
160
+ refund_hours_min: number | null
161
+ refund_hours_max: number | null
162
+ reschedule_hours_min: number | null
163
+ reschedule_hours_max: number | null
164
+ coaches: number
165
+ of: number
166
+ } | null
167
+ baggage: { bags: number | null, kg: number | null } | null
168
+ e_ticket: 'all' | 'some' | 'none'
122
169
  }
123
170
 
124
171
  export interface ScheduleRow {
@@ -176,6 +176,8 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
176
176
  size="small"
177
177
  noMargin
178
178
  text={ <AiOutlineCaretLeft /> }
179
+ // Claude - 2026-09-25 (audit): icon-only, so name it for screen readers
180
+ ariaLabel={ t('routes_order.step2.dates.previous') }
179
181
  onClick={ onPrevious }
180
182
  />
181
183
 
@@ -186,6 +188,7 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
186
188
  size="small"
187
189
  noMargin
188
190
  text={ <AiOutlineCaretRight /> }
191
+ ariaLabel={ t('routes_order.step2.dates.next') }
189
192
  onClick={ onNext }
190
193
  />
191
194
  </Inner>
package/Links/styles.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import styled from 'styled-components';
2
- import { Link } from 'react-router-dom';
2
+ // Claude - 2026-09-26: LocalLink - on a localised page (/sq/...) these
3
+ // internal links stay in that language
4
+ import { LocalLink } from '@autobusal/common';
3
5
 
4
6
  export const Container = styled.section`
5
7
  margin-top: 25px;
@@ -29,7 +31,7 @@ export const List = styled.ul`
29
31
  * A real router Link, not a styled div with a click handler - the entire
30
32
  * point of these blocks is that a crawler follows them.
31
33
  */
32
- export const Item = styled(Link)`
34
+ export const Item = styled(LocalLink)`
33
35
  display: inline-block;
34
36
  padding: 6px 12px;
35
37
  border-radius: ${ props => props.theme.borderRadius };
@@ -59,7 +61,7 @@ export const Reverse = styled.div`
59
61
  border-bottom: 1px solid ${ props => props.theme.background.neutral };
60
62
  `;
61
63
 
62
- export const ReverseLink = styled(Link)`
64
+ export const ReverseLink = styled(LocalLink)`
63
65
  display: inline-block;
64
66
  padding: 8px 16px;
65
67
  border-radius: ${ props => props.theme.borderRadius };
package/RoutesOrder.tsx CHANGED
@@ -432,7 +432,10 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
432
432
  <Step1
433
433
  value={ step1 }
434
434
  redirected={ redirected }
435
- heading={ subject ? 'h2' : 'h1' }
435
+ // Claude - 2026-09-25 (audit): not a heading at all on the pair page -
436
+ // as an h2 it still sat ABOVE the facts h1, so the document outline
437
+ // opened on a second-level heading (an icon plus "Search Schedules")
438
+ heading={ subject ? 'div' : 'h1' }
436
439
  t={ t }
437
440
  onSave={ onStep1 }
438
441
  />
package/Step1/Step1.tsx CHANGED
@@ -22,8 +22,12 @@ interface Props {
22
22
  * /bus-lines/{from}/{to} page named "Search Schedules" to crawlers and
23
23
  * screen readers alike. RoutesOrder decides, because it is the one place
24
24
  * that knows whether the facts block is going to render.
25
+ *
26
+ * Claude - 2026-09-25 (audit): 'div' added - the pair page's form label is
27
+ * no heading at all now (an h2 before the page's h1 broke the outline);
28
+ * Title carries the heading look itself so it renders the same.
25
29
  */
26
- heading?: 'h1' | 'h2'
30
+ heading?: 'h1' | 'h2' | 'div'
27
31
  t: TFunction<'common'>
28
32
  onSave: (data: RoutesSearchForm) => void
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.38.1",
3
+ "version": "1.39.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/styles.ts CHANGED
@@ -10,6 +10,15 @@ export const Title = styled.h1`
10
10
  * document-outline decision, not a request for a smaller heading.
11
11
  */
12
12
  font-size: ${ props => props.theme.size.xxl };
13
+ /*
14
+ * Claude - 2026-09-25 (audit): the global h1-h6 rules, restated, because
15
+ * the pair page now renders this as a <div> (not a heading - see Step1's
16
+ * heading prop) and it must look exactly like the h1 it is elsewhere.
17
+ */
18
+ margin: 0 0 20px 0;
19
+ font-weight: 700;
20
+ line-height: ${ props => props.theme.lineHeight.title };
21
+ font-family: ${ props => props.theme.fontFamily.title }, sans-serif;
13
22
  `;
14
23
 
15
24
  export const SubTitle = styled.h3`