@autobusal/routes-order 1.38.2 → 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.
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 {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.38.2",
3
+ "version": "1.39.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",