@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 +22 -2
- package/Facts/questions.ts +145 -0
- package/Facts/types.ts +47 -0
- package/Links/styles.ts +5 -3
- package/package.json +1 -1
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
|
-
|
|
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(', ') }) }
|
package/Facts/questions.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
64
|
+
export const ReverseLink = styled(LocalLink)`
|
|
63
65
|
display: inline-block;
|
|
64
66
|
padding: 8px 16px;
|
|
65
67
|
border-radius: ${ props => props.theme.borderRadius };
|