@autobusal/routes-order 1.33.0 → 1.33.2
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/CHANGELOG.md +18 -0
- package/Checkout/Checkout.tsx +69 -5
- package/Facts/Facts.tsx +38 -1
- package/Facts/questions.ts +39 -4
- package/Facts/service.ts +111 -0
- package/Facts/styles.ts +38 -0
- package/Facts/types.ts +30 -0
- package/Sections/Schema.tsx +14 -1
- package/Sections/Sections.tsx +40 -9
- package/Step5/Billing/Billing.tsx +117 -64
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.33.2
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A signed-in customer with an incomplete profile is now asked for what is missing**, in the same billing fields a guest already fills, instead of reaching an API that had nothing to put in three NOT NULL columns. Only the gaps are asked for: a complete profile sees no change at all.
|
|
8
|
+
|
|
9
|
+
This is not a rare state. A self-registered visitor has a name and nothing else, and the form that collects phone, address and city is reachable only by admin-created accounts — so the customer could not have completed it in advance even if asked to.
|
|
10
|
+
|
|
11
|
+
- `Billing` takes an optional `only` list; undefined keeps the whole guest block exactly as it was. Field rules follow whichever profile the value will be stored on, so the form never accepts more than the API will keep.
|
|
12
|
+
|
|
13
|
+
- Checkout treats "profile not loaded yet" as unknown rather than empty — the user store persists a redacted stub and re-fetches on mount, so the other reading would flash required fields at customers whose profile is complete.
|
|
14
|
+
|
|
15
|
+
## 1.33.1
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **A pair with no future scheduled service says so**, with the date it last ran, instead of showing a facts block that quietly omits frequency and a departure list that is empty on every date the visitor tries. The links to journeys that do run stay prominent, so the page converts rather than dead-ends. No `noindex`: an honest page about a seasonal route is not thin content, and a route that stopped in December will want its ranking back in June.
|
|
20
|
+
|
|
3
21
|
## 1.33.0
|
|
4
22
|
|
|
5
23
|
### Added
|
package/Checkout/Checkout.tsx
CHANGED
|
@@ -49,6 +49,35 @@ interface Props {
|
|
|
49
49
|
// Routes without a passenger_fields config keep the pre-feature form
|
|
50
50
|
const DEFAULT_FIELDS = ['sex', 'passport'];
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Edited: Claude - Date: 2026-08-21
|
|
54
|
+
*
|
|
55
|
+
* THE THREE COLUMNS A REGISTERED CUSTOMER CAN ARRIVE HERE UNABLE TO FILL,
|
|
56
|
+
* as `checkout field => the profile field that answers it`.
|
|
57
|
+
*
|
|
58
|
+
* `orders_contact`.`phone`/`address`/`city` are NOT NULL with no default;
|
|
59
|
+
* the same three on `visitors` are nullable, and registration collects a
|
|
60
|
+
* name, an email and a password and nothing else. So an incomplete profile
|
|
61
|
+
* is the ORDINARY state of a self-registered customer rather than an edge
|
|
62
|
+
* case - and until today it meant a 500 fired after the seats had already
|
|
63
|
+
* been held, and then (once that was patched) a booking handed to the
|
|
64
|
+
* operator with no phone number for the passenger.
|
|
65
|
+
*
|
|
66
|
+
* The customer is asked here instead, in the guest form's own fields, for
|
|
67
|
+
* the gaps and nothing else. Mirrors Orders\Make\Contact::BILLING and
|
|
68
|
+
* Orders\Make\Rules on the API side, which make exactly this set required
|
|
69
|
+
* for exactly this case.
|
|
70
|
+
*/
|
|
71
|
+
const PROFILE_BILLING: Record<string, 'phone' | 'address' | 'city'> = {
|
|
72
|
+
bill_phone: 'phone',
|
|
73
|
+
bill_address: 'address',
|
|
74
|
+
bill_city: 'city'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const empty = (value?: string | null): boolean => (
|
|
78
|
+
value === undefined || value === null || String(value).trim() === ''
|
|
79
|
+
);
|
|
80
|
+
|
|
52
81
|
/**
|
|
53
82
|
* Passenger details and payment on one screen.
|
|
54
83
|
*
|
|
@@ -98,6 +127,34 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
98
127
|
const { data: UserData } = useUserStore();
|
|
99
128
|
const { data: SettingsData } = useGetSettings();
|
|
100
129
|
|
|
130
|
+
/*
|
|
131
|
+
* Edited: Claude - Date: 2026-08-21
|
|
132
|
+
*
|
|
133
|
+
* WHAT THE BILLING BLOCK ASKS THIS BUYER FOR.
|
|
134
|
+
*
|
|
135
|
+
* A guest is asked for the whole thing, as always (`gaps` stays
|
|
136
|
+
* undefined, which is what Billing reads as "the whole form").
|
|
137
|
+
*
|
|
138
|
+
* A signed-in CUSTOMER is asked only for the profile columns that cannot
|
|
139
|
+
* answer for themselves - see PROFILE_BILLING above. Only a customer: an
|
|
140
|
+
* agent or an operator selling at the counter writes no `orders_contact`
|
|
141
|
+
* row at all, so there is nothing to ask them for.
|
|
142
|
+
*
|
|
143
|
+
* Gated on the profile actually being loaded. The user store persists
|
|
144
|
+
* only a display-only stub to localStorage (see stores/user.ts) and
|
|
145
|
+
* re-fetches the rest on mount, so `visitor` is briefly absent on a cold
|
|
146
|
+
* start - and treating "not loaded yet" as "empty" would flash three
|
|
147
|
+
* required fields at customers whose profile is complete. The wizard
|
|
148
|
+
* reaches this screen several navigations after the app mounts, by which
|
|
149
|
+
* time the bootstrap has long since landed.
|
|
150
|
+
*/
|
|
151
|
+
const gaps = (UserData !== undefined && UserData.type === 'visitor' && UserData.visitor !== undefined)
|
|
152
|
+
? Object.keys(PROFILE_BILLING).filter(field => empty(UserData.visitor?.[PROFILE_BILLING[field]]))
|
|
153
|
+
: undefined;
|
|
154
|
+
|
|
155
|
+
// the guest form, or a customer with something still to fill in
|
|
156
|
+
const askBilling = UserData === undefined || (gaps !== undefined && gaps.length > 0);
|
|
157
|
+
|
|
101
158
|
const navigate = useNavigate();
|
|
102
159
|
|
|
103
160
|
// one form serves both legs of a return trip, so it collects the UNION of
|
|
@@ -247,11 +304,18 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
247
304
|
return;
|
|
248
305
|
}
|
|
249
306
|
|
|
250
|
-
|
|
251
|
-
|
|
307
|
+
/*
|
|
308
|
+
* Edited: Claude - Date: 2026-08-21
|
|
309
|
+
* The billing form is rendered for a guest (all of it) and for a
|
|
310
|
+
* customer whose profile has gaps (only those) - so it is validated
|
|
311
|
+
* whenever it was drawn, and skipped when it was not. It used to be
|
|
312
|
+
* skipped for every signed-in buyer, which is the assumption that let
|
|
313
|
+
* an incomplete profile reach the API with nothing to fill three NOT
|
|
314
|
+
* NULL columns with.
|
|
315
|
+
*/
|
|
252
316
|
let details = {} as BillingData;
|
|
253
317
|
|
|
254
|
-
if (
|
|
318
|
+
if (askBilling) {
|
|
255
319
|
let collected: BillingData | undefined;
|
|
256
320
|
|
|
257
321
|
await billing.handleSubmit((data) => { collected = data; })();
|
|
@@ -346,8 +410,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
346
410
|
} }
|
|
347
411
|
/>
|
|
348
412
|
|
|
349
|
-
{
|
|
350
|
-
<Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } />
|
|
413
|
+
{ askBilling && (
|
|
414
|
+
<Billing t={ t } errors={ billing.formState.errors } refs={ billing.register } only={ gaps } />
|
|
351
415
|
) }
|
|
352
416
|
</Listing>
|
|
353
417
|
|
package/Facts/Facts.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
-
import { Container, Grid, Fact, Label, Value, Note, Operators, Prose } from './styles';
|
|
2
|
+
import { Container, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
|
|
3
3
|
import { FactsData } from './types';
|
|
4
4
|
import { DAYS, duration } from './questions';
|
|
5
|
+
import { stopped, detail } from './service';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
id: string
|
|
@@ -81,10 +82,46 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
|
81
82
|
: `${ duration(facts.duration_min, t) } – ${ duration(facts.duration_max, t) }`)
|
|
82
83
|
: null;
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Edited: Claude - Date: 2026-08-21
|
|
87
|
+
*
|
|
88
|
+
* SAY IT, rather than leaving a reader to work it out. A route is
|
|
89
|
+
* published to the sitemap on a flag nothing re-checks, so a season
|
|
90
|
+
* ending never un-publishes its page - and the page's only tell was a
|
|
91
|
+
* missing frequency tile and a booking wizard that came back empty on
|
|
92
|
+
* every date tried. That is a visitor doing our diagnostics for us and
|
|
93
|
+
* concluding the site is broken.
|
|
94
|
+
*
|
|
95
|
+
* DORMANT NAMES THE DATE. "Not currently scheduled" alone reads as a
|
|
96
|
+
* fault; "the last service ran on 30 September 2024" reads as a season,
|
|
97
|
+
* which is what it usually is, and tells a traveller whether to come back
|
|
98
|
+
* in the summer or give up. A route that never had a timetable has no
|
|
99
|
+
* such date and gets the sentence that says so instead of a borrowed one.
|
|
100
|
+
*
|
|
101
|
+
* AND IT POINTS ONWARD in the same breath. The nearby-pair links below
|
|
102
|
+
* are live journeys on live routes; somebody who came here for a bus is
|
|
103
|
+
* one click from one, and the whole reason this page keeps its ranking
|
|
104
|
+
* rather than being de-listed is that it can make that click happen.
|
|
105
|
+
*/
|
|
106
|
+
const dead = stopped(facts);
|
|
107
|
+
|
|
108
|
+
const said = dead ? detail(dead, t) : null;
|
|
109
|
+
|
|
84
110
|
return (
|
|
85
111
|
<Container id={ id } className="box">
|
|
86
112
|
<h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
|
|
87
113
|
|
|
114
|
+
{ dead && (
|
|
115
|
+
<Notice>
|
|
116
|
+
<strong>{ t('routes_order.facts.not_scheduled') }</strong>
|
|
117
|
+
|
|
118
|
+
<p>
|
|
119
|
+
{ said && `${ said } ` }
|
|
120
|
+
{ t('routes_order.facts.not_scheduled_links') }
|
|
121
|
+
</p>
|
|
122
|
+
</Notice>
|
|
123
|
+
) }
|
|
124
|
+
|
|
88
125
|
<Grid>
|
|
89
126
|
{ facts.price_from_display && (
|
|
90
127
|
<Fact>
|
package/Facts/questions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { FactsData } from './types';
|
|
3
|
+
import { stopped, sentence } from './service';
|
|
3
4
|
|
|
4
5
|
export interface Question {
|
|
5
6
|
// stable enough to key a list on, and never rendered
|
|
@@ -66,6 +67,40 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
66
67
|
|
|
67
68
|
const cities = { from: facts.from, to: facts.to };
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Edited: Claude - Date: 2026-08-21
|
|
72
|
+
*
|
|
73
|
+
* THE FIRST QUESTION, WHEN THE ANSWER IS NO. "Is there a bus from Tirana
|
|
74
|
+
* to Prizren?" is the question a pair page exists to answer, and on a
|
|
75
|
+
* pair whose every timetable window has closed the honest answer is not
|
|
76
|
+
* currently, with the day it last ran.
|
|
77
|
+
*
|
|
78
|
+
* It also dictates which of the questions BELOW may be asked, which is
|
|
79
|
+
* the part that matters more than the extra entry. Every answer here is
|
|
80
|
+
* a standalone sentence that Google may lift into a rich result with no
|
|
81
|
+
* page around it, so "There are 4 scheduled services from Tirana to
|
|
82
|
+
* Prizren" quoted on its own about a route that stopped in 2025 is a
|
|
83
|
+
* claim we published, not a nuance a reader will supply. The four
|
|
84
|
+
* present-tense SERVICE COUNTS - how many run, what time they leave,
|
|
85
|
+
* how many are direct, how many are overnight - are dropped there.
|
|
86
|
+
* (Frequency drops itself: obtapi already sends null for it.)
|
|
87
|
+
*
|
|
88
|
+
* What stays is what remains true of the journey as published: how long
|
|
89
|
+
* it takes, what it cost, where it calls, what is on board, who runs it,
|
|
90
|
+
* how far it is. Those describe the route rather than assert this
|
|
91
|
+
* morning's departures, and each of them sits under an answer that has
|
|
92
|
+
* already said the route is not running.
|
|
93
|
+
*/
|
|
94
|
+
const dead = stopped(facts);
|
|
95
|
+
|
|
96
|
+
if (dead) {
|
|
97
|
+
all.push({
|
|
98
|
+
id: 'service',
|
|
99
|
+
q: t('routes_order.schema.service_q', cities),
|
|
100
|
+
a: sentence(dead, t)
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
if (facts.duration_min !== null && facts.duration_max !== null) {
|
|
70
105
|
all.push({
|
|
71
106
|
id: 'duration',
|
|
@@ -104,7 +139,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
104
139
|
});
|
|
105
140
|
}
|
|
106
141
|
|
|
107
|
-
if (facts.departures > 0) {
|
|
142
|
+
if (facts.departures > 0 && !dead) {
|
|
108
143
|
all.push({
|
|
109
144
|
id: 'departures',
|
|
110
145
|
q: t('routes_order.schema.departures_q', cities),
|
|
@@ -127,7 +162,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
127
162
|
});
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
if (facts.departure_first && facts.departure_last) {
|
|
165
|
+
if (facts.departure_first && facts.departure_last && !dead) {
|
|
131
166
|
all.push({
|
|
132
167
|
id: 'times',
|
|
133
168
|
q: t('routes_order.schema.times_q', cities),
|
|
@@ -137,7 +172,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
137
172
|
|
|
138
173
|
// Asked only where the answer is a real count. `direct` is meaningless
|
|
139
174
|
// without a total to read it against, and `departures` is that total.
|
|
140
|
-
if (facts.departures > 0) {
|
|
175
|
+
if (facts.departures > 0 && !dead) {
|
|
141
176
|
all.push({
|
|
142
177
|
id: 'direct',
|
|
143
178
|
q: t('routes_order.schema.direct_q', cities),
|
|
@@ -151,7 +186,7 @@ const questions = (facts: FactsData, t: TFunction<'common'>): Question[] => {
|
|
|
151
186
|
|
|
152
187
|
// Only when there IS a night service. "No, none of these run overnight" is
|
|
153
188
|
// a question asked to be answered no, which is padding.
|
|
154
|
-
if (facts.overnight > 0) {
|
|
189
|
+
if (facts.overnight > 0 && !dead) {
|
|
155
190
|
all.push({
|
|
156
191
|
id: 'overnight',
|
|
157
192
|
q: t('routes_order.schema.overnight_q', cities),
|
package/Facts/service.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { FactsData } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A pair that is published but has nothing left to sell.
|
|
6
|
+
*
|
|
7
|
+
* Edited: Claude - Date: 2026-08-21
|
|
8
|
+
*
|
|
9
|
+
* ONE READING OF THE FLAG, shared by everything that reacts to it - the
|
|
10
|
+
* notice in the facts block, the FAQ answer that a search engine quotes,
|
|
11
|
+
* and the schema.org availability. Three separate `facts.service?.scheduled
|
|
12
|
+
* === false` checks would be three chances for the page to say one thing
|
|
13
|
+
* and its own structured data to say another, which is the exact mismatch
|
|
14
|
+
* that gets a site's rich results pulled.
|
|
15
|
+
*
|
|
16
|
+
* `dormant` is a pair that RAN and stopped, and `last` is the day it last
|
|
17
|
+
* ran. `never` is a route published before anybody gave it a timetable -
|
|
18
|
+
* there is no last day to name, and the copy must not imply there was one.
|
|
19
|
+
*
|
|
20
|
+
* NULL for a live pair AND for an old cached payload with no `service` key
|
|
21
|
+
* at all (obtapi caches these for six hours, so the field is missing from
|
|
22
|
+
* warm entries for six hours after a deploy). Both mean "say nothing",
|
|
23
|
+
* which is right: an absent field is not evidence that a route is dead.
|
|
24
|
+
*/
|
|
25
|
+
export interface Stopped {
|
|
26
|
+
state: 'dormant' | 'never'
|
|
27
|
+
last: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const stopped = (facts: FactsData): (Stopped | null) => {
|
|
31
|
+
if (!facts.service || facts.service.scheduled !== false) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return facts.service.last
|
|
36
|
+
? { state: 'dormant', last: facts.service.last }
|
|
37
|
+
: { state: 'never', last: null };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ISO month index to the month names the site already ships in all fifteen
|
|
42
|
+
* languages (`data.months.*`), rather than a new set nobody would have
|
|
43
|
+
* translated for one sentence.
|
|
44
|
+
*/
|
|
45
|
+
const MONTHS = [
|
|
46
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
47
|
+
'july', 'august', 'september', 'october', 'november', 'december'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* "The last service ran on 30 September 2024", in the reader's language.
|
|
52
|
+
*
|
|
53
|
+
* Edited: Claude - Date: 2026-08-21
|
|
54
|
+
*
|
|
55
|
+
* The day, month and year go in as THREE interpolations rather than one
|
|
56
|
+
* pre-joined string, so a locale that orders them differently can. obtapi
|
|
57
|
+
* sends the date as ISO precisely because it is the one format that does
|
|
58
|
+
* not already contain somebody's opinion about that ordering.
|
|
59
|
+
*
|
|
60
|
+
* Returns null on anything that is not an ISO date rather than printing a
|
|
61
|
+
* half-parsed one. A malformed date here would be printed as fact in a
|
|
62
|
+
* sentence about when a bus last ran, and no date at all is better than a
|
|
63
|
+
* wrong one - the headline above it already carries the actual message.
|
|
64
|
+
*/
|
|
65
|
+
export const ran = (iso: string, t: TFunction<'common'>): (string | null) => {
|
|
66
|
+
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
67
|
+
|
|
68
|
+
if (!parts) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const month = MONTHS[Number(parts[2]) - 1];
|
|
73
|
+
|
|
74
|
+
if (!month) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return t('routes_order.facts.not_scheduled_last', {
|
|
79
|
+
day: String(Number(parts[3])),
|
|
80
|
+
month: t(`data.months.${ month }`),
|
|
81
|
+
year: parts[1]
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The second sentence: when it last ran, or that it never has.
|
|
87
|
+
*
|
|
88
|
+
* Null rather than the never-scheduled wording when a DORMANT pair's date
|
|
89
|
+
* fails to parse - "a timetable has not been published yet" about a coach
|
|
90
|
+
* that ran for two years is a different false statement, not a graceful
|
|
91
|
+
* fallback. The headline carries the message on its own there.
|
|
92
|
+
*/
|
|
93
|
+
export const detail = (item: Stopped, t: TFunction<'common'>): (string | null) => (
|
|
94
|
+
item.last ? ran(item.last, t) : (item.state === 'never' ? t('routes_order.facts.not_scheduled_never') : null)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The whole message as one paragraph of running text.
|
|
99
|
+
*
|
|
100
|
+
* Shared with the FAQ answer for the same reason the flag is: Google
|
|
101
|
+
* requires the `acceptedAnswer` in the markup to be the text on the page,
|
|
102
|
+
* and the only way to guarantee that across fifteen languages is for there
|
|
103
|
+
* to be one string rather than two that agree today.
|
|
104
|
+
*/
|
|
105
|
+
export const sentence = (item: Stopped, t: TFunction<'common'>): string => {
|
|
106
|
+
const second = detail(item, t);
|
|
107
|
+
|
|
108
|
+
return second
|
|
109
|
+
? `${ t('routes_order.facts.not_scheduled') } ${ second }`
|
|
110
|
+
: t('routes_order.facts.not_scheduled');
|
|
111
|
+
};
|
package/Facts/styles.ts
CHANGED
|
@@ -72,3 +72,41 @@ export const Prose = styled.div`
|
|
|
72
72
|
line-height: 1.6;
|
|
73
73
|
}
|
|
74
74
|
`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The pair is published but nothing on it runs.
|
|
78
|
+
*
|
|
79
|
+
* Edited: Claude - Date: 2026-08-21
|
|
80
|
+
*
|
|
81
|
+
* ABOVE the grid and inside the same box, because it changes how every
|
|
82
|
+
* number below it is to be read: those fares and times are what the
|
|
83
|
+
* operator last published, not what is on sale this morning. A banner
|
|
84
|
+
* floating outside the block would leave the grid looking like a live
|
|
85
|
+
* offer with a disclaimer somewhere else on the page.
|
|
86
|
+
*
|
|
87
|
+
* Informational, not an error - `font.info` rather than `font.error`. A
|
|
88
|
+
* seasonal coach is a normal thing that has happened, and dressing it as a
|
|
89
|
+
* fault would push a reader to leave rather than to look at the routes that
|
|
90
|
+
* ARE running a few hundred pixels down.
|
|
91
|
+
*/
|
|
92
|
+
export const Notice = styled.div`
|
|
93
|
+
margin: 0 0 22px;
|
|
94
|
+
padding: 14px 16px;
|
|
95
|
+
border-left: 3px solid ${ props => props.theme.font.info };
|
|
96
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
97
|
+
background: ${ props => props.theme.background.neutral };
|
|
98
|
+
line-height: 1.6;
|
|
99
|
+
|
|
100
|
+
p {
|
|
101
|
+
margin: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* The headline claim, in the reader's weight rather than a heading level:
|
|
105
|
+
the block already owns an <h2> and a second one here would put "not
|
|
106
|
+
currently scheduled" into the document outline above the facts it
|
|
107
|
+
qualifies. */
|
|
108
|
+
strong {
|
|
109
|
+
display: block;
|
|
110
|
+
font-weight: 700;
|
|
111
|
+
}
|
|
112
|
+
`;
|
package/Facts/types.ts
CHANGED
|
@@ -59,6 +59,36 @@ export interface FactsData {
|
|
|
59
59
|
* and obtapi withholds the whole object rather than counting it.
|
|
60
60
|
*/
|
|
61
61
|
frequency: { days: number[], weekly: number, daily: boolean } | null
|
|
62
|
+
/**
|
|
63
|
+
* Edited: Claude - Date: 2026-08-21
|
|
64
|
+
*
|
|
65
|
+
* WHETHER ANY OF THIS IS STILL ON OFFER. A route is published to the
|
|
66
|
+
* sitemap on a `searchable` flag that nothing re-checks, so a season
|
|
67
|
+
* ending never un-publishes the page - and the page had no way to know.
|
|
68
|
+
* It rendered a facts block quietly missing its frequency and a departure
|
|
69
|
+
* list that came back empty for every date the visitor tried.
|
|
70
|
+
*
|
|
71
|
+
* `scheduled` false means NO leg on this pair has a timetable window that
|
|
72
|
+
* is still open, which is the same test the search applies - so nothing
|
|
73
|
+
* can be bought here on any date. `last` is then the day the last of them
|
|
74
|
+
* ran, or NULL when no leg ever had a window at all (a route published
|
|
75
|
+
* before anybody gave it a timetable). Those two want different
|
|
76
|
+
* sentences: one has a date to name and the other does not, and inventing
|
|
77
|
+
* one would be a lie.
|
|
78
|
+
*
|
|
79
|
+
* `last` is deliberately null while `scheduled` is true - there it would
|
|
80
|
+
* be some other leg's season ending, printed where a reader reads "the
|
|
81
|
+
* last day this ran".
|
|
82
|
+
*
|
|
83
|
+
* OPTIONAL, and that is not laziness: obtapi caches this payload for six
|
|
84
|
+
* hours, so for six hours after a deploy the field is simply absent from
|
|
85
|
+
* warm entries. Absent means WE DID NOT COMPUTE IT and the page says
|
|
86
|
+
* nothing - which is also the honest reading of `frequency: null` with
|
|
87
|
+
* `scheduled: true`, a pair whose windows are open but whose operators
|
|
88
|
+
* never listed their days. "We cannot say how often" and "it does not
|
|
89
|
+
* run" are different claims and only the second one gets the notice.
|
|
90
|
+
*/
|
|
91
|
+
service?: { scheduled: boolean, last: string | null }
|
|
62
92
|
// how many of the `departures` run non-stop, and how many are still
|
|
63
93
|
// rolling the next morning - counted out of the total rather than reported
|
|
64
94
|
// as a yes/no, because "one of ten is direct" is the honest version
|
package/Sections/Schema.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
|
|
|
2
2
|
import { JsonLd } from '@autobusal/common';
|
|
3
3
|
import { FactsData } from '../Facts/types';
|
|
4
4
|
import { Question } from '../Facts/questions';
|
|
5
|
+
import { stopped } from '../Facts/service';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
facts: FactsData
|
|
@@ -144,7 +145,19 @@ const Schema = ({ facts, questions, t }: Props): (JSX.Element | null) => {
|
|
|
144
145
|
'@type': 'Offer',
|
|
145
146
|
price: facts.price_from,
|
|
146
147
|
priceCurrency: facts.currency,
|
|
147
|
-
|
|
148
|
+
|
|
149
|
+
// Edited: Claude - Date: 2026-08-21
|
|
150
|
+
// The availability is now READ rather than asserted. This said
|
|
151
|
+
// InStock on every pair it was ever emitted for, including the
|
|
152
|
+
// ones whose every timetable window had closed - a machine-readable
|
|
153
|
+
// claim that a ticket could be bought, published about journeys
|
|
154
|
+
// that sell on no date a visitor can pick. OutOfStock is the same
|
|
155
|
+
// thing the page itself now says above the fare, which is the
|
|
156
|
+
// whole rule this file opens with: never state here what the page
|
|
157
|
+
// does not show.
|
|
158
|
+
availability: stopped(facts)
|
|
159
|
+
? 'https://schema.org/OutOfStock'
|
|
160
|
+
: 'https://schema.org/InStock'
|
|
148
161
|
}
|
|
149
162
|
} : {})
|
|
150
163
|
};
|
package/Sections/Sections.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import Links from '../Links/Links';
|
|
|
6
6
|
import Schema from './Schema';
|
|
7
7
|
import { useGetFacts } from '../Facts/services';
|
|
8
8
|
import buildQuestions from '../Facts/questions';
|
|
9
|
+
import { stopped } from '../Facts/service';
|
|
9
10
|
import { Nav, Anchor, Anchored } from './styles';
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
@@ -65,6 +66,42 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
65
66
|
// that happen to agree today.
|
|
66
67
|
const questions = buildQuestions(facts, t);
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Edited: Claude - Date: 2026-08-21
|
|
71
|
+
*
|
|
72
|
+
* ON A PAIR THAT HAS STOPPED RUNNING, THE WAY OUT COMES FIRST. The facts
|
|
73
|
+
* block now says plainly that the route is not currently scheduled - and
|
|
74
|
+
* the moment it does, the most useful thing on the page is the list of
|
|
75
|
+
* nearby pairs that ARE running, not four hundred pixels of timetable
|
|
76
|
+
* the reader has just been told they cannot travel on.
|
|
77
|
+
*
|
|
78
|
+
* Hoisted rather than duplicated: it is the same <Links> element, moved,
|
|
79
|
+
* so a crawler still finds exactly one copy of each internal link. That
|
|
80
|
+
* is the whole reason this page keeps its ranking instead of being
|
|
81
|
+
* de-listed - an honest seasonal page that converts to a live journey is
|
|
82
|
+
* worth more than a 404, and it only converts if the link is where the
|
|
83
|
+
* reader is looking.
|
|
84
|
+
*
|
|
85
|
+
* Everything below stays exactly where it was. The timetable is still
|
|
86
|
+
* what the operator published and the FAQ still answers the questions
|
|
87
|
+
* this journey raises; neither is hidden, because a page that hides its
|
|
88
|
+
* content to admit a problem is thin content, which is what noindex is
|
|
89
|
+
* for and what this deliberately is not.
|
|
90
|
+
*/
|
|
91
|
+
const onward = (
|
|
92
|
+
<Links
|
|
93
|
+
id={ LINKS }
|
|
94
|
+
from={ links.from }
|
|
95
|
+
to={ links.to }
|
|
96
|
+
reverse={ links.reverse }
|
|
97
|
+
fromName={ facts.from }
|
|
98
|
+
toName={ facts.to }
|
|
99
|
+
t={ t }
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const dead = Boolean(stopped(facts));
|
|
104
|
+
|
|
68
105
|
return (
|
|
69
106
|
<>
|
|
70
107
|
<Nav aria-label={ t('routes_order.sections.label') }>
|
|
@@ -89,6 +126,8 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
89
126
|
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
90
127
|
</Anchored>
|
|
91
128
|
|
|
129
|
+
{ dead && onward }
|
|
130
|
+
|
|
92
131
|
<Anchored>
|
|
93
132
|
<Schedule id={ SCHEDULE } from={ facts.from } to={ facts.to } rows={ schedule } t={ t } />
|
|
94
133
|
</Anchored>
|
|
@@ -113,15 +152,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
113
152
|
The `pair` mode of Reviews\BrowseController is untouched and
|
|
114
153
|
still works; nothing calls it from here. */ }
|
|
115
154
|
|
|
116
|
-
|
|
117
|
-
id={ LINKS }
|
|
118
|
-
from={ links.from }
|
|
119
|
-
to={ links.to }
|
|
120
|
-
reverse={ links.reverse }
|
|
121
|
-
fromName={ facts.from }
|
|
122
|
-
toName={ facts.to }
|
|
123
|
-
t={ t }
|
|
124
|
-
/>
|
|
155
|
+
{ !dead && onward }
|
|
125
156
|
</>
|
|
126
157
|
);
|
|
127
158
|
};
|
|
@@ -15,11 +15,46 @@ interface Props {
|
|
|
15
15
|
t: TFunction<'common'>
|
|
16
16
|
errors: FieldErrors
|
|
17
17
|
refs: UseFormRegister<BillingData>
|
|
18
|
+
/**
|
|
19
|
+
* Edited: Claude - Date: 2026-08-21
|
|
20
|
+
*
|
|
21
|
+
* Which fields to ask for. Undefined is the whole block, which is what a
|
|
22
|
+
* guest has always been shown and stays the default.
|
|
23
|
+
*
|
|
24
|
+
* A SIGNED-IN CUSTOMER gets a list instead, holding only the fields their
|
|
25
|
+
* profile cannot answer. `orders_contact`.`phone`/`address`/`city` are
|
|
26
|
+
* NOT NULL, the same three on `visitors` are nullable, and registration
|
|
27
|
+
* never collects them - so a customer could reach this screen unable to
|
|
28
|
+
* fill three columns that refuse to be empty, which the API used to
|
|
29
|
+
* answer with a 500 and then with a booking carrying no phone number.
|
|
30
|
+
* They are asked here instead, in the same boxes and under the same
|
|
31
|
+
* labels a guest sees, rather than being sent out of the checkout to
|
|
32
|
+
* their account page and back.
|
|
33
|
+
*/
|
|
34
|
+
only?: string[]
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
37
|
+
const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
21
38
|
const [ company, setCompany ] = useState<boolean>(false);
|
|
22
39
|
|
|
40
|
+
// A field is asked for when nothing narrowed the block, or when it is on
|
|
41
|
+
// the list of gaps.
|
|
42
|
+
const asked = (field: string): boolean => only === undefined || only.includes(field);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Edited: Claude - Date: 2026-08-21
|
|
46
|
+
*
|
|
47
|
+
* Two of these fields are validated differently depending on WHO is
|
|
48
|
+
* filling them in, and the difference is not cosmetic - it mirrors the
|
|
49
|
+
* two sets of rules the API applies. A guest's answers go to
|
|
50
|
+
* `orders_contact` and nowhere else; a signed-in customer's are also
|
|
51
|
+
* written back onto their `visitors` profile, whose `city` column holds
|
|
52
|
+
* 40 characters and whose address rule has always asked for 5. A form
|
|
53
|
+
* that accepted more than the API will store would just move the refusal
|
|
54
|
+
* to after the pay button.
|
|
55
|
+
*/
|
|
56
|
+
const gaps = only !== undefined;
|
|
57
|
+
|
|
23
58
|
/**
|
|
24
59
|
* Edited: Claude - Date: 2026-08-19
|
|
25
60
|
* Same fix as the passenger block: the labels here were text sitting
|
|
@@ -53,82 +88,96 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
|
53
88
|
{ t('routes_order.step5.billing.title') }
|
|
54
89
|
</SubTitle>
|
|
55
90
|
|
|
56
|
-
|
|
57
|
-
<div className="
|
|
58
|
-
<
|
|
91
|
+
{ asked('bill_fn') && (
|
|
92
|
+
<div className="column">
|
|
93
|
+
<div className="row">
|
|
94
|
+
<Required htmlFor={ id('bill_fn') }>{ t('routes_order.step5.billing.first_name') }</Required>
|
|
59
95
|
|
|
60
|
-
|
|
96
|
+
<input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
|
|
61
97
|
|
|
62
|
-
|
|
63
|
-
|
|
98
|
+
{ Display(errors.bill_fn) }
|
|
99
|
+
</div>
|
|
64
100
|
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
<div className="row">
|
|
102
|
+
<Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
<input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
{ Display(errors.bill_ln) }
|
|
107
|
+
</div>
|
|
71
108
|
</div>
|
|
72
|
-
|
|
109
|
+
) }
|
|
73
110
|
|
|
74
111
|
<div className="column">
|
|
75
|
-
|
|
76
|
-
<
|
|
112
|
+
{ asked('email') && (
|
|
113
|
+
<div className="row">
|
|
114
|
+
<Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
|
|
77
115
|
|
|
78
|
-
|
|
116
|
+
<input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
|
|
79
117
|
|
|
80
|
-
|
|
118
|
+
{ Display(errors.email) }
|
|
81
119
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
<NoticeEmail>
|
|
121
|
+
<AiOutlineInfoCircle /> { t('routes_order.step5.billing.email_info') }
|
|
122
|
+
</NoticeEmail>
|
|
123
|
+
</div>
|
|
124
|
+
) }
|
|
86
125
|
|
|
87
|
-
|
|
88
|
-
<
|
|
126
|
+
{ asked('bill_phone') && (
|
|
127
|
+
<div className="row">
|
|
128
|
+
<Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
|
|
89
129
|
|
|
90
|
-
|
|
130
|
+
<input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
{ Display(errors.bill_phone) }
|
|
133
|
+
</div>
|
|
134
|
+
) }
|
|
94
135
|
</div>
|
|
95
136
|
|
|
96
|
-
|
|
97
|
-
<
|
|
137
|
+
{ asked('bill_address') && (
|
|
138
|
+
<div className="row">
|
|
139
|
+
<Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
|
|
98
140
|
|
|
99
|
-
|
|
141
|
+
<textarea id={ id('bill_address') } { ...refs('bill_address', Validate(`required|min_length:${ gaps ? 5 : 2 }`, t)) }></textarea>
|
|
100
142
|
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
{ Display(errors.bill_address) }
|
|
144
|
+
</div>
|
|
145
|
+
) }
|
|
103
146
|
|
|
104
147
|
<div className="column">
|
|
105
|
-
|
|
106
|
-
<
|
|
148
|
+
{ asked('bill_city') && (
|
|
149
|
+
<div className="row">
|
|
150
|
+
<Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
|
|
107
151
|
|
|
108
|
-
|
|
152
|
+
<input type="text" id={ id('bill_city') } { ...refs('bill_city', Validate(`required|min_length:2|max_length:${ gaps ? 40 : 100 }`, t)) } />
|
|
109
153
|
|
|
110
|
-
|
|
111
|
-
|
|
154
|
+
{ Display(errors.bill_city) }
|
|
155
|
+
</div>
|
|
156
|
+
) }
|
|
112
157
|
|
|
113
|
-
|
|
114
|
-
<
|
|
158
|
+
{ asked('bill_country') && (
|
|
159
|
+
<div className="row">
|
|
160
|
+
<Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
|
|
115
161
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
162
|
+
<select id={ id('bill_country') } { ...refs('bill_country', Validate('required|min:1', t)) }>
|
|
163
|
+
<option value={ 0 }>{ t('routes_order.step5.billing.country_choose') }</option>
|
|
164
|
+
{ items }
|
|
165
|
+
</select>
|
|
120
166
|
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
{ Display(errors.bill_country) }
|
|
168
|
+
</div>
|
|
169
|
+
) }
|
|
123
170
|
</div>
|
|
124
171
|
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
{ asked('bill_company') && (
|
|
173
|
+
<div className="row">
|
|
174
|
+
<label>
|
|
175
|
+
<input type="checkbox" defaultChecked={ false } onClick={ (event: React.MouseEvent<HTMLInputElement>) => onAddCompany(event) } /> { t('routes_order.step5.billing.ask_company') }
|
|
176
|
+
</label>
|
|
177
|
+
</div>
|
|
178
|
+
) }
|
|
130
179
|
|
|
131
|
-
{ company && (
|
|
180
|
+
{ asked('bill_company') && company && (
|
|
132
181
|
<div className="column">
|
|
133
182
|
<div className="row">
|
|
134
183
|
{ /* not Required - these are optional, so a plain <label>
|
|
@@ -150,25 +199,29 @@ const Billing = ({ errors, t, refs }: Props): JSX.Element => {
|
|
|
150
199
|
</div>
|
|
151
200
|
) }
|
|
152
201
|
|
|
153
|
-
|
|
154
|
-
<
|
|
202
|
+
{ asked('comments') && (
|
|
203
|
+
<div className="row">
|
|
204
|
+
<label htmlFor={ id('comments') }>{ t('routes_order.step5.billing.comments') }</label>
|
|
155
205
|
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
<textarea id={ id('comments') } { ...refs('comments') }></textarea>
|
|
207
|
+
</div>
|
|
208
|
+
) }
|
|
158
209
|
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
<
|
|
210
|
+
{ asked('terms') && (
|
|
211
|
+
<div className="row row-nomargin">
|
|
212
|
+
<label className="single">
|
|
213
|
+
<input type="checkbox" value="1" { ...refs('terms', Validate('required', t)) } />
|
|
162
214
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
215
|
+
<span dangerouslySetInnerHTML={{
|
|
216
|
+
__html: t('routes_order.step5.billing.terms', {
|
|
217
|
+
terms: `<a href="/page/terms" target="_blank">${ t('routes_order.step5.billing.terms_name') }</a>`
|
|
218
|
+
})
|
|
219
|
+
}} />
|
|
220
|
+
</label>
|
|
169
221
|
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
{ Display(errors.terms) }
|
|
223
|
+
</div>
|
|
224
|
+
) }
|
|
172
225
|
</Container>
|
|
173
226
|
);
|
|
174
227
|
};
|