@autobusal/routes-order 1.37.7 → 1.37.8
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 +9 -3
- package/Facts/styles.ts +12 -0
- package/Found/Dates/Dates.tsx +29 -5
- package/Found/Dates/styles.ts +20 -7
- package/Found/Orders/Orders.tsx +26 -28
- package/Found/Route/Route.tsx +4 -10
- package/Found/Route/styles.ts +50 -7
- package/Found/refine.ts +0 -46
- package/RoutesOrder.tsx +24 -2
- package/Sections/Sections.tsx +4 -2
- package/Step1/Step1.tsx +32 -4
- package/Step1/prepare.ts +39 -2
- package/Step4/Passenger/Passenger.tsx +67 -14
- package/Step4/Passenger/styles.ts +31 -0
- package/Step5/Billing/Billing.tsx +12 -9
- package/Step5/Billing/styles.ts +8 -0
- package/Step5/Summary/About/About.tsx +15 -2
- package/Step5/Summary/About/styles.ts +22 -0
- package/package.json +1 -1
- package/styles.ts +6 -0
- package/Found/Group/Group.tsx +0 -68
- package/Found/Group/styles.ts +0 -44
package/Facts/Facts.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
-
import { Container, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
|
|
2
|
+
import { Container, Heading, Grid, Fact, Label, Value, Note, Notice, Operators, Prose } from './styles';
|
|
3
3
|
import { FactsData } from './types';
|
|
4
4
|
import { DAYS, duration } from './questions';
|
|
5
5
|
import { stopped, detail } from './service';
|
|
@@ -11,6 +11,12 @@ interface Props {
|
|
|
11
11
|
// same response, and two components each running the same query would have
|
|
12
12
|
// meant the prerender crawl paying for every pair twice.
|
|
13
13
|
facts: FactsData
|
|
14
|
+
/**
|
|
15
|
+
* Claude - 2026-09-16 (audit): h1 on the pair page, where this title is
|
|
16
|
+
* the page's subject; h2 on the results steps, whose own step heading is
|
|
17
|
+
* the h1. The caller knows which page it is; this block does not.
|
|
18
|
+
*/
|
|
19
|
+
heading?: 'h1' | 'h2'
|
|
14
20
|
t: TFunction<'common'>
|
|
15
21
|
}
|
|
16
22
|
|
|
@@ -45,7 +51,7 @@ interface Props {
|
|
|
45
51
|
* is printed in the weaker wording. A pair with one weekly coach ends up
|
|
46
52
|
* with a short honest block, not a padded one.
|
|
47
53
|
*/
|
|
48
|
-
const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
54
|
+
const Facts = ({ id, facts, heading = 'h2', t }: Props): JSX.Element => {
|
|
49
55
|
/**
|
|
50
56
|
* Edited: Claude - Date: 2026-08-20
|
|
51
57
|
*
|
|
@@ -109,7 +115,7 @@ const Facts = ({ id, facts, t }: Props): JSX.Element => {
|
|
|
109
115
|
|
|
110
116
|
return (
|
|
111
117
|
<Container id={ id } className="box">
|
|
112
|
-
<
|
|
118
|
+
<Heading as={ heading }>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</Heading>
|
|
113
119
|
|
|
114
120
|
{ dead && (
|
|
115
121
|
<Notice>
|
package/Facts/styles.ts
CHANGED
|
@@ -4,6 +4,18 @@ export const Container = styled.section`
|
|
|
4
4
|
margin-top: 25px;
|
|
5
5
|
`;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The section's title, at whichever level the page hands it.
|
|
9
|
+
*
|
|
10
|
+
* Claude - 2026-09-16 (audit): on the pair page this IS the page's subject
|
|
11
|
+
* and renders as its one h1; on the results steps it drops to h2 under the
|
|
12
|
+
* step heading. The size is pinned to the h2 scale either way, so promoting
|
|
13
|
+
* the level is an outline decision and does not resize the block.
|
|
14
|
+
*/
|
|
15
|
+
export const Heading = styled.h2`
|
|
16
|
+
font-size: ${ props => props.theme.size.xl };
|
|
17
|
+
`;
|
|
18
|
+
|
|
7
19
|
export const Grid = styled.dl`
|
|
8
20
|
display: grid;
|
|
9
21
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
package/Found/Dates/Dates.tsx
CHANGED
|
@@ -3,7 +3,10 @@ import { TFunction } from 'i18next';
|
|
|
3
3
|
import { AiOutlineCaretLeft, AiOutlineCaretRight } from 'react-icons/ai';
|
|
4
4
|
import { Button } from '@autobusal/common';
|
|
5
5
|
import { useMediaQuery } from '@autobusal/hooks';
|
|
6
|
-
import { createDate, getDayParts } from '@autobusal/utilities';
|
|
6
|
+
import { createDate, prepareDate, getDayParts } from '@autobusal/utilities';
|
|
7
|
+
import { useGetSettings } from '@autobusal/providers/services';
|
|
8
|
+
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
9
|
+
import { todayIn } from '../../Step1/prepare';
|
|
7
10
|
import Loading from './Loading';
|
|
8
11
|
import { Container, Inner, ButtonDay, DayPrice, Cheapest, Weekday, DayNumber, Month } from './styles';
|
|
9
12
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
@@ -40,6 +43,29 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
|
|
|
40
43
|
|
|
41
44
|
const { data, isLoading } = useGetDates(step1, type, number);
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The first day that can still be boarded, as the departure city reckons it.
|
|
48
|
+
*
|
|
49
|
+
* Claude - 2026-09-16 (audit): the cut-off used to be `new Date() <= day`
|
|
50
|
+
* - the device's current INSTANT against the day's UTC midnight - which
|
|
51
|
+
* declared today unavailable from 00:00 UTC onwards, i.e. from 02:00 in
|
|
52
|
+
* Tirana. obtapi already starts the window on today (Search\Dates::find)
|
|
53
|
+
* and says per day whether anything runs, so the only job left here is
|
|
54
|
+
* not to offer a day that has passed where the coach leaves from: the
|
|
55
|
+
* origin's calendar day, then the server's, then the device's. The same
|
|
56
|
+
* clock the form now opens on. On the return step the strip is the
|
|
57
|
+
* reverse pair, so its origin is the destination that was searched.
|
|
58
|
+
*/
|
|
59
|
+
const { data: cities } = useGetSuggestions();
|
|
60
|
+
|
|
61
|
+
const { data: settings } = useGetSettings();
|
|
62
|
+
|
|
63
|
+
const origin = type === '_return' ? step1.to : step1.from;
|
|
64
|
+
|
|
65
|
+
const zone = cities.find(city => city.slug === origin)?.country?.timezone;
|
|
66
|
+
|
|
67
|
+
const floor = createDate(todayIn(zone) ?? settings.today ?? prepareDate(new Date()));
|
|
68
|
+
|
|
43
69
|
if (isLoading) {
|
|
44
70
|
return <Loading t={ t } />;
|
|
45
71
|
}
|
|
@@ -60,8 +86,6 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
|
|
|
60
86
|
setNumber(number + 1);
|
|
61
87
|
};
|
|
62
88
|
|
|
63
|
-
const today = new Date();
|
|
64
|
-
|
|
65
89
|
/**
|
|
66
90
|
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
67
91
|
*
|
|
@@ -116,8 +140,8 @@ const Dates = ({ type, step1, t, onSearch }: Props): JSX.Element => {
|
|
|
116
140
|
const items = shown.map(item => {
|
|
117
141
|
const date = createDate(item.day);
|
|
118
142
|
|
|
119
|
-
//
|
|
120
|
-
const isAvailable =
|
|
143
|
+
// today or later where the coach leaves from, and something runs that day
|
|
144
|
+
const isAvailable = date.getTime() >= floor.getTime() && item.routes;
|
|
121
145
|
|
|
122
146
|
const isCheapest = varies && lowest !== null && item.from_price === lowest;
|
|
123
147
|
|
package/Found/Dates/styles.ts
CHANGED
|
@@ -44,12 +44,18 @@ export const Inner = styled.div`
|
|
|
44
44
|
* they took 70px of a 355px row between them - a fifth of the week's worth
|
|
45
45
|
* of space spent on two chevrons, which left each day 34px and forced the
|
|
46
46
|
* prices to wrap three times.
|
|
47
|
+
*
|
|
48
|
+
* Claude - 2026-09-16 (audit): squeezed to 30px tall they were 26px wide,
|
|
49
|
+
* and a thumb misses that. 44px square each: the phone strip shows THREE
|
|
50
|
+
* days now rather than seven, so 88px of arrows still leaves each day
|
|
51
|
+
* about 89px of a 375px screen.
|
|
47
52
|
*/
|
|
48
53
|
& > button:first-child,
|
|
49
54
|
& > button:last-child {
|
|
50
55
|
flex: 0 0 auto;
|
|
51
|
-
padding: 0
|
|
52
|
-
|
|
56
|
+
padding: 0;
|
|
57
|
+
width: 44px;
|
|
58
|
+
height: 44px;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
@media (min-width: 640px) {
|
|
@@ -134,7 +140,8 @@ export const ButtonDay = styled.button<{ $available: boolean, $selected: boolean
|
|
|
134
140
|
justify-content: center;
|
|
135
141
|
gap: 2px;
|
|
136
142
|
padding: 4px 0;
|
|
137
|
-
|
|
143
|
+
// Claude - 2026-09-16 (audit): the 44px tap minimum - the chips measured 37px
|
|
144
|
+
min-height: 44px;
|
|
138
145
|
font-weight: 700;
|
|
139
146
|
text-align: center;
|
|
140
147
|
font-size: ${ props => props.theme.size.xs };
|
|
@@ -178,11 +185,11 @@ export const DayPrice = styled.span<{ $cheapest: boolean }>`
|
|
|
178
185
|
white-space: normal;
|
|
179
186
|
overflow-wrap: anywhere;
|
|
180
187
|
line-height: 1.15;
|
|
181
|
-
|
|
188
|
+
// Claude - 2026-09-16 (audit): xs at every width - 11px is under the floor
|
|
189
|
+
font-size: ${ props => props.theme.size.xs };
|
|
182
190
|
|
|
183
191
|
@media (min-width: 640px) {
|
|
184
192
|
white-space: nowrap;
|
|
185
|
-
font-size: ${ props => props.theme.size.xs };
|
|
186
193
|
}
|
|
187
194
|
font-weight: ${ props => props.$cheapest ? 700 : 400 };
|
|
188
195
|
opacity: .85;
|
|
@@ -196,13 +203,19 @@ export const DayPrice = styled.span<{ $cheapest: boolean }>`
|
|
|
196
203
|
* unreadable at 10px. On the selected day it inherits the button's own
|
|
197
204
|
* contrast colour and leans on weight instead of hue.
|
|
198
205
|
*/
|
|
206
|
+
/*
|
|
207
|
+
* Claude - 2026-09-16 (audit): the strip is no longer dark - it sits on
|
|
208
|
+
* background.neutral - and the green that read well on it measures 2.42:1
|
|
209
|
+
* there (#79AB51 on #F2F2F2). The word is the cue now, in the ordinary ink
|
|
210
|
+
* (14.4:1 light, 11:1 dark), and at xs rather than a 10px xxs.
|
|
211
|
+
*/
|
|
199
212
|
export const Cheapest = styled.span<{ $selected: boolean }>`
|
|
200
213
|
display: block;
|
|
201
|
-
font-size: ${ props => props.theme.size.
|
|
214
|
+
font-size: ${ props => props.theme.size.xs };
|
|
202
215
|
text-transform: uppercase;
|
|
203
216
|
letter-spacing: .5px;
|
|
204
217
|
font-weight: 700;
|
|
205
|
-
color: ${ props => props.$selected ? 'inherit' : props.theme.font.
|
|
218
|
+
color: ${ props => props.$selected ? 'inherit' : props.theme.font.normal };
|
|
206
219
|
`;
|
|
207
220
|
|
|
208
221
|
export const ContainerLoading = styled.div`
|
package/Found/Orders/Orders.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { Inline } from '@autobusal/common';
|
|
3
3
|
import Route from '../Route/Route';
|
|
4
|
-
import Group from '../Group/Group';
|
|
5
4
|
import Empty from '../Empty/Empty';
|
|
6
5
|
import { getFavorites, getNormal } from './utilities';
|
|
7
|
-
import { sortItems,
|
|
6
|
+
import { sortItems, SortKey } from '../refine';
|
|
8
7
|
import { Container, ContainerNotFound, Reset } from './styles';
|
|
9
8
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
10
9
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
@@ -67,9 +66,22 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
|
|
|
67
66
|
* Preferred-stop pinning stays the OUTER partition and each side is
|
|
68
67
|
* sorted within itself. Sorting the whole list first would have let
|
|
69
68
|
* "cheapest" quietly un-pin a stop the user deliberately saved.
|
|
69
|
+
*/
|
|
70
|
+
/*
|
|
71
|
+
* Claude - 2026-09-16 (Ferjolt: "When we have more than one departure for
|
|
72
|
+
* the same route from the same operator, should not group them as we do
|
|
73
|
+
* now but should show them as a standalone search result in full. Is very
|
|
74
|
+
* easy to get confused visitors with grouped alternatives. So, remove the
|
|
75
|
+
* grouping and put them all as standalone.")
|
|
70
76
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
77
|
+
* EVERY DEPARTURE IS ITS OWN CARD. Interchangeable departures used to
|
|
78
|
+
* collapse into one card with a row of times under it, and the card showed
|
|
79
|
+
* only the earliest - so somebody scanning for "the 14:00" saw a 06:00 and
|
|
80
|
+
* moved on, and somebody who did find the picker booked whichever time
|
|
81
|
+
* happened to be highlighted. The grouped card also defeated the sort it
|
|
82
|
+
* sat in: ordered "latest first", a group still led with its EARLIEST
|
|
83
|
+
* time. A run of similar rows is the honest shape of an hourly service,
|
|
84
|
+
* and the sort and filters above are there to cut it down.
|
|
73
85
|
*/
|
|
74
86
|
/**
|
|
75
87
|
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
@@ -82,30 +94,16 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
|
|
|
82
94
|
const date = step1[type];
|
|
83
95
|
|
|
84
96
|
const render = (list: FoundData[], kind: 'favorite' | 'normal'): JSX.Element[] => (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
onSave={ onSave }
|
|
96
|
-
/>
|
|
97
|
-
)
|
|
98
|
-
: (
|
|
99
|
-
<Route
|
|
100
|
-
key={ group[0].id }
|
|
101
|
-
type={ kind }
|
|
102
|
-
data={ group[0] }
|
|
103
|
-
date={ date }
|
|
104
|
-
passengers={ passengers }
|
|
105
|
-
t={ t }
|
|
106
|
-
onSave={ onSave }
|
|
107
|
-
/>
|
|
108
|
-
)
|
|
97
|
+
sortItems(list, sort).map(item => (
|
|
98
|
+
<Route
|
|
99
|
+
key={ item.id }
|
|
100
|
+
type={ kind }
|
|
101
|
+
data={ item }
|
|
102
|
+
date={ date }
|
|
103
|
+
passengers={ passengers }
|
|
104
|
+
t={ t }
|
|
105
|
+
onSave={ onSave }
|
|
106
|
+
/>
|
|
109
107
|
))
|
|
110
108
|
);
|
|
111
109
|
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -37,13 +37,9 @@ interface Props {
|
|
|
37
37
|
passengers: number
|
|
38
38
|
t: TFunction<'common'>
|
|
39
39
|
onSave: (data: FoundData) => void
|
|
40
|
-
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
41
|
-
// Slot under the trip row, used by Group to hang a departure-time picker
|
|
42
|
-
// on a card that stands for several interchangeable departures.
|
|
43
|
-
children?: React.ReactNode
|
|
44
40
|
}
|
|
45
41
|
|
|
46
|
-
const Route = ({ data, type, date, passengers, t, onSave
|
|
42
|
+
const Route = ({ data, type, date, passengers, t, onSave }: Props): JSX.Element => {
|
|
47
43
|
const [ information, setInformation ] = useState<boolean>(false);
|
|
48
44
|
|
|
49
45
|
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
@@ -345,12 +341,10 @@ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JS
|
|
|
345
341
|
</Price>
|
|
346
342
|
</Trip>
|
|
347
343
|
|
|
348
|
-
{ children }
|
|
349
|
-
|
|
350
344
|
{ /* Claude - 2026-09-01: the wide-screen copy, back where it sat before
|
|
351
|
-
the phone rebuild - bottom-left of the card, below the itinerary
|
|
352
|
-
|
|
353
|
-
|
|
345
|
+
the phone rebuild - bottom-left of the card, below the itinerary,
|
|
346
|
+
rather than inside the fare column. Exactly one of the two is ever
|
|
347
|
+
displayed; see ButtonDetails. */ }
|
|
354
348
|
<ButtonDetails
|
|
355
349
|
$only="wide"
|
|
356
350
|
type="button"
|
package/Found/Route/styles.ts
CHANGED
|
@@ -344,9 +344,14 @@ export const Iso = styled.span`
|
|
|
344
344
|
* every millimetre it gives back goes to the city name, which is what
|
|
345
345
|
* people actually read.
|
|
346
346
|
*/
|
|
347
|
+
/*
|
|
348
|
+
* Claude - 2026-09-16 (audit): xs and no smaller. "Even smaller" came out
|
|
349
|
+
* at 10px, under the 12px floor for text on a phone; the tighter margin
|
|
350
|
+
* gives back what those two points asked for.
|
|
351
|
+
*/
|
|
347
352
|
@media (max-width: 639px) {
|
|
348
353
|
margin: 0 3px;
|
|
349
|
-
font-size:
|
|
354
|
+
font-size: ${ props => props.theme.size.xs };
|
|
350
355
|
}
|
|
351
356
|
font-weight: 700;
|
|
352
357
|
text-transform: uppercase;
|
|
@@ -377,16 +382,20 @@ export const Time = styled.div`
|
|
|
377
382
|
* mid-word. Stripped of its pill background and padding here it costs
|
|
378
383
|
* about half that, and both ends gain 20px.
|
|
379
384
|
*/
|
|
385
|
+
/*
|
|
386
|
+
* Claude - 2026-09-16 (audit): xs, not xs minus one - 11px is under the
|
|
387
|
+
* 12px floor, and the pill's padding was the space, not the point size.
|
|
388
|
+
*/
|
|
380
389
|
@media (max-width: 639px) {
|
|
381
390
|
flex-direction: column;
|
|
382
391
|
gap: 0;
|
|
383
|
-
font-size:
|
|
392
|
+
font-size: ${ props => props.theme.size.xs };
|
|
384
393
|
white-space: nowrap;
|
|
385
394
|
|
|
386
395
|
& > span {
|
|
387
396
|
padding: 0;
|
|
388
397
|
background: none;
|
|
389
|
-
font-size:
|
|
398
|
+
font-size: ${ props => props.theme.size.xs };
|
|
390
399
|
}
|
|
391
400
|
|
|
392
401
|
& > span > svg {
|
|
@@ -521,6 +530,19 @@ export const Price = styled.div`
|
|
|
521
530
|
flex: 1 1 100%;
|
|
522
531
|
}
|
|
523
532
|
|
|
533
|
+
/*
|
|
534
|
+
* Claude - 2026-09-16 (audit): a 44px "Select" under the thumb - it
|
|
535
|
+
* measured 154x32. The shared Button has no phone size and common is not
|
|
536
|
+
* this package's to edit, so the height is set from the cell that holds
|
|
537
|
+
* it; by exclusion, because the details toggle is a button in the same
|
|
538
|
+
* cell and sizes itself.
|
|
539
|
+
*/
|
|
540
|
+
@media (max-width: 767px) {
|
|
541
|
+
& > button:not(.details-toggle) {
|
|
542
|
+
height: 44px;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
524
546
|
@media (min-width: 768px) {
|
|
525
547
|
flex: 0 0 165px;
|
|
526
548
|
flex-direction: column;
|
|
@@ -613,7 +635,8 @@ export const PriceNotice = styled.span`
|
|
|
613
635
|
* Left-aligned under the fare on a phone, where it is a full-width line
|
|
614
636
|
* beneath a price and a button rather than the middle of a centred stack.
|
|
615
637
|
*/
|
|
616
|
-
|
|
638
|
+
// Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
|
|
639
|
+
font-size: ${ props => props.theme.size.xs };
|
|
617
640
|
line-height: 1.25;
|
|
618
641
|
color: ${ props => props.theme.font.faded };
|
|
619
642
|
text-align: left;
|
|
@@ -685,7 +708,8 @@ export const CodeCompany = styled(Link)`
|
|
|
685
708
|
border-radius: 100px;
|
|
686
709
|
background: ${ props => props.theme.background.neutral };
|
|
687
710
|
color: ${ props => props.theme.font.faded };
|
|
688
|
-
|
|
711
|
+
// Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
|
|
712
|
+
font-size: ${ props => props.theme.size.xs };
|
|
689
713
|
font-weight: 700;
|
|
690
714
|
line-height: 1.4;
|
|
691
715
|
letter-spacing: .3px;
|
|
@@ -800,6 +824,14 @@ export const ButtonDetails = styled.button<{ $only?: 'narrow' | 'wide' }>`
|
|
|
800
824
|
font-size: ${ props => props.theme.size.xs };
|
|
801
825
|
}
|
|
802
826
|
|
|
827
|
+
/*
|
|
828
|
+
* Claude - 2026-09-16 (audit): 54x25 on a phone is a target a thumb
|
|
829
|
+
* misses. The box grows to the 44px minimum; the type does not.
|
|
830
|
+
*/
|
|
831
|
+
@media (max-width: 767px) {
|
|
832
|
+
min-height: 44px;
|
|
833
|
+
}
|
|
834
|
+
|
|
803
835
|
&:hover {
|
|
804
836
|
color: ${ props => props.theme.font.normal };
|
|
805
837
|
}
|
|
@@ -976,11 +1008,21 @@ export const NextDay = styled.sup`
|
|
|
976
1008
|
* leaves the reader working out what day the departure was, which is the
|
|
977
1009
|
* question the whole thing exists to answer.
|
|
978
1010
|
*/
|
|
1011
|
+
/*
|
|
1012
|
+
* Claude - 2026-09-16 (audit): a PILL, not yellow type. Set in primary.normal
|
|
1013
|
+
* on the white card this measured 1.78:1 (#EABD23 on #FFFFFF) - the brand
|
|
1014
|
+
* yellow is a fill, not an ink, and 12px text needs 4.5:1. The highlight the
|
|
1015
|
+
* yellow was there for survives as the background, with the theme's own
|
|
1016
|
+
* contrast ink on it: #121212 on #EABD23 is 10.5:1, in both modes.
|
|
1017
|
+
*/
|
|
979
1018
|
export const Day = styled.span`
|
|
1019
|
+
padding: 1px 6px;
|
|
1020
|
+
border-radius: 4px;
|
|
980
1021
|
font-size: ${ props => props.theme.size.xs };
|
|
981
1022
|
font-weight: 700;
|
|
982
1023
|
white-space: nowrap;
|
|
983
|
-
color: ${ props => props.theme.primary.
|
|
1024
|
+
color: ${ props => props.theme.primary.contrast };
|
|
1025
|
+
background: ${ props => props.theme.primary.normal };
|
|
984
1026
|
`;
|
|
985
1027
|
|
|
986
1028
|
/**
|
|
@@ -1002,7 +1044,8 @@ export const Day = styled.span`
|
|
|
1002
1044
|
* fact about the bus, not a countdown we invented to hurry anyone.
|
|
1003
1045
|
*/
|
|
1004
1046
|
export const SeatsLeft = styled.span<{ $low: boolean }>`
|
|
1005
|
-
|
|
1047
|
+
// Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
|
|
1048
|
+
font-size: ${ props => props.theme.size.xs };
|
|
1006
1049
|
line-height: 1.25;
|
|
1007
1050
|
text-align: left;
|
|
1008
1051
|
font-weight: ${ props => (props.$low ? 700 : 400) };
|
package/Found/refine.ts
CHANGED
|
@@ -531,52 +531,6 @@ export const filterItems = (items: FoundData[], refinement: Refinement): FoundDa
|
|
|
531
531
|
items.filter(item => matches(item, refinement))
|
|
532
532
|
);
|
|
533
533
|
|
|
534
|
-
/**
|
|
535
|
-
* Collapse interchangeable departures into one entry.
|
|
536
|
-
*
|
|
537
|
-
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
538
|
-
*
|
|
539
|
-
* Hourly shuttle service is the dominant Albanian intercity pattern, so a
|
|
540
|
-
* busy pair renders a wall of rows that differ only in the departure time -
|
|
541
|
-
* the same operator, the same two stops, the same fare, the same journey
|
|
542
|
-
* length. Those are one product with a choice of time, and reading them as
|
|
543
|
-
* twenty separate options is what makes the page tiring.
|
|
544
|
-
*
|
|
545
|
-
* Duration and price are BOTH in the key on purpose: without them a slower
|
|
546
|
-
* or dearer trip would hide inside a group under a headline it doesn't
|
|
547
|
-
* honour. External offers are keyed by their own id so they never merge
|
|
548
|
-
* with anything - they carry no stop ids, and grouping on missing values
|
|
549
|
-
* would fuse unrelated partner inventory into a single row.
|
|
550
|
-
*
|
|
551
|
-
* Input order is preserved, so a group lands wherever its best member
|
|
552
|
-
* sorted to and the caller does not have to re-sort.
|
|
553
|
-
*/
|
|
554
|
-
export const groupItems = (items: FoundData[]): FoundData[][] => {
|
|
555
|
-
const groups = new Map<string, FoundData[]>();
|
|
556
|
-
const order: string[] = [];
|
|
557
|
-
|
|
558
|
-
items.forEach(item => {
|
|
559
|
-
const key = item.external
|
|
560
|
-
? `ext:${ item.id }`
|
|
561
|
-
: [
|
|
562
|
-
item.operator.id,
|
|
563
|
-
item.locations.from.stop?.id ?? '-',
|
|
564
|
-
item.locations.to.stop?.id ?? '-',
|
|
565
|
-
item.price.value,
|
|
566
|
-
item.duration
|
|
567
|
-
].join('|');
|
|
568
|
-
|
|
569
|
-
if (!groups.has(key)) {
|
|
570
|
-
groups.set(key, []);
|
|
571
|
-
order.push(key);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
groups.get(key)?.push(item);
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
return order.map(key => groups.get(key) as FoundData[]);
|
|
578
|
-
};
|
|
579
|
-
|
|
580
534
|
const countBy = (
|
|
581
535
|
items: FoundData[],
|
|
582
536
|
refinement: Refinement,
|
package/RoutesOrder.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import RoutesSearch from '@autobusal/routes-search';
|
|
|
12
12
|
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
13
13
|
import { SearchAgain, SearchInner, SearchToggle, SearchSummary, SearchPanel } from './styles';
|
|
14
14
|
import Sections from './Sections/Sections';
|
|
15
|
+
import { useGetFacts } from './Facts/services';
|
|
15
16
|
import Step1 from './Step1/Step1';
|
|
16
17
|
import prepare from './Step1/prepare';
|
|
17
18
|
import Step2 from './Step2';
|
|
@@ -48,6 +49,25 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
48
49
|
const fromCity = cities.find(city => city.slug === params.from);
|
|
49
50
|
const toCity = cities.find(city => city.slug === params.to);
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Which heading is the page's h1.
|
|
54
|
+
*
|
|
55
|
+
* Claude - 2026-09-16 (audit): on /bus-lines/{from}/{to} the only h1 was
|
|
56
|
+
* the search form's "Search Schedules" and the journey itself - the facts
|
|
57
|
+
* title - was an h2. The facts block is the subject, so it takes the h1
|
|
58
|
+
* on the step the pair page renders and the form's heading steps down.
|
|
59
|
+
* Keyed on the facts having ARRIVED rather than on the URL naming a pair:
|
|
60
|
+
* a pair obtapi does not sell renders no facts block, and demoting the
|
|
61
|
+
* form there would leave the page with no h1 at all. Same query key as
|
|
62
|
+
* Sections' own call, so react-query answers both from one request.
|
|
63
|
+
*/
|
|
64
|
+
const pairFrom = fromCity?.slug ?? params.from;
|
|
65
|
+
const pairTo = toCity?.slug ?? params.to;
|
|
66
|
+
|
|
67
|
+
const { data: pairFacts } = useGetFacts(pairFrom, pairTo);
|
|
68
|
+
|
|
69
|
+
const subject = Boolean(pairFacts?.facts);
|
|
70
|
+
|
|
51
71
|
const title = fromCity && toCity
|
|
52
72
|
? t('routes_order.title_pair', { from: fromCity.name, to: toCity.name })
|
|
53
73
|
: t('routes_order.title');
|
|
@@ -382,6 +402,7 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
382
402
|
<Step1
|
|
383
403
|
value={ step1 }
|
|
384
404
|
redirected={ redirected }
|
|
405
|
+
heading={ subject ? 'h2' : 'h1' }
|
|
385
406
|
t={ t }
|
|
386
407
|
onSave={ onStep1 }
|
|
387
408
|
/>
|
|
@@ -436,8 +457,9 @@ const RoutesOrder = ({ t }: Props): JSX.Element => {
|
|
|
436
457
|
checkout. */ }
|
|
437
458
|
{ (showStep1 || showStep2 || showStep3) && (
|
|
438
459
|
<Sections
|
|
439
|
-
from={
|
|
440
|
-
to={
|
|
460
|
+
from={ pairFrom }
|
|
461
|
+
to={ pairTo }
|
|
462
|
+
heading={ showStep1 ? 'h1' : 'h2' }
|
|
441
463
|
t={ t }
|
|
442
464
|
/>
|
|
443
465
|
) }
|
package/Sections/Sections.tsx
CHANGED
|
@@ -16,6 +16,8 @@ import { Nav, Anchor, Anchored } from './styles';
|
|
|
16
16
|
interface Props {
|
|
17
17
|
from?: string
|
|
18
18
|
to?: string
|
|
19
|
+
// the facts title's level - h1 on the pair page, h2 under a step heading
|
|
20
|
+
heading?: 'h1' | 'h2'
|
|
19
21
|
t: TFunction<'common'>
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -55,7 +57,7 @@ const LINKS = 'links';
|
|
|
55
57
|
* The nav renders only when there is more than the wizard to navigate to -
|
|
56
58
|
* a bar whose only link is "Trips" is furniture.
|
|
57
59
|
*/
|
|
58
|
-
const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
60
|
+
const Sections = ({ from, to, heading, t }: Props): (JSX.Element | null) => {
|
|
59
61
|
const { data } = useGetFacts(from, to);
|
|
60
62
|
|
|
61
63
|
const { data: dates } = useGetAvailability(from, to);
|
|
@@ -165,7 +167,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
165
167
|
{ availability && <AvailabilitySchema data={ availability } t={ t } /> }
|
|
166
168
|
|
|
167
169
|
<Anchored>
|
|
168
|
-
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
170
|
+
<Facts id={ FACTS } facts={ facts } heading={ heading } t={ t } />
|
|
169
171
|
</Anchored>
|
|
170
172
|
|
|
171
173
|
{ dead && onward }
|
package/Step1/Step1.tsx
CHANGED
|
@@ -3,27 +3,55 @@ import { useParams, useSearchParams } from 'react-router-dom';
|
|
|
3
3
|
import { TFunction } from 'i18next';
|
|
4
4
|
import { FaRoute } from 'react-icons/fa';
|
|
5
5
|
import RoutesSearch from '@autobusal/routes-search';
|
|
6
|
-
import prepare from './prepare';
|
|
6
|
+
import prepare, { todayIn } from './prepare';
|
|
7
7
|
import Steps from '../Steps/Steps';
|
|
8
8
|
import { Title } from '../styles';
|
|
9
9
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
10
10
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
11
|
+
import { useGetSuggestions } from '@autobusal/routes-search/services';
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
value?: RoutesSearchForm
|
|
14
15
|
redirected: boolean
|
|
16
|
+
/**
|
|
17
|
+
* The level of the "Search Schedules" heading.
|
|
18
|
+
*
|
|
19
|
+
* Claude - 2026-09-16 (audit): on the bare /bus-lines page the form IS
|
|
20
|
+
* the page and this is its h1. On a pair page the subject is the journey
|
|
21
|
+
* - the facts block's own title - and an h1 above it left every
|
|
22
|
+
* /bus-lines/{from}/{to} page named "Search Schedules" to crawlers and
|
|
23
|
+
* screen readers alike. RoutesOrder decides, because it is the one place
|
|
24
|
+
* that knows whether the facts block is going to render.
|
|
25
|
+
*/
|
|
26
|
+
heading?: 'h1' | 'h2'
|
|
15
27
|
t: TFunction<'common'>
|
|
16
28
|
onSave: (data: RoutesSearchForm) => void
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
const Step1 = ({ value, redirected, t, onSave }: Props): JSX.Element => {
|
|
31
|
+
const Step1 = ({ value, redirected, heading = 'h1', t, onSave }: Props): JSX.Element => {
|
|
20
32
|
const params = useParams();
|
|
21
33
|
|
|
22
34
|
const [ searchParams ] = useSearchParams();
|
|
23
35
|
|
|
24
36
|
const { data } = useGetSettings();
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The day the form opens on: TODAY, where the coach leaves from.
|
|
40
|
+
*
|
|
41
|
+
* Claude - 2026-09-16 (audit): it was futureDate(1) - tomorrow on the
|
|
42
|
+
* buyer's device clock - which predates same-day selling and put 17/09 in
|
|
43
|
+
* the box while it was still the 16th in Thessaloniki. The same clock the
|
|
44
|
+
* picker floors on (setDepartureZone in the search form): the origin's
|
|
45
|
+
* timezone, then the server's own date, then the device as a last resort.
|
|
46
|
+
* The city list is the search form's cached query, so this costs nothing.
|
|
47
|
+
*/
|
|
48
|
+
const { data: cities } = useGetSuggestions();
|
|
49
|
+
|
|
50
|
+
const origin = params.from ?? data.preferences.defaultLocations.from;
|
|
51
|
+
|
|
52
|
+
const zone = cities.find(city => city.slug === origin)?.country?.timezone;
|
|
53
|
+
|
|
54
|
+
const prepared = prepare(value, params, searchParams, data.preferences.defaultLocations, todayIn(zone) ?? data.today);
|
|
27
55
|
|
|
28
56
|
// Two ways this page ends up with a complete search spec:
|
|
29
57
|
// 1. `?type=submit` - set by the in-app search form (home/Main/Main.tsx)
|
|
@@ -45,7 +73,7 @@ const Step1 = ({ value, redirected, t, onSave }: Props): JSX.Element => {
|
|
|
45
73
|
|
|
46
74
|
return (
|
|
47
75
|
<>
|
|
48
|
-
<Title>
|
|
76
|
+
<Title as={ heading }>
|
|
49
77
|
<FaRoute />
|
|
50
78
|
{ t('routes_order.step1.title') }
|
|
51
79
|
</Title>
|
package/Step1/prepare.ts
CHANGED
|
@@ -2,11 +2,42 @@ import { Params } from 'react-router-dom';
|
|
|
2
2
|
import { futureDate, isPreparedDate } from '@autobusal/utilities';
|
|
3
3
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Today, DD/MM/YYYY, as the clock in `zone` reckons it.
|
|
7
|
+
*
|
|
8
|
+
* Claude - 2026-09-16 (audit): the same en-CA trick the calendar's departure
|
|
9
|
+
* floor uses (common/Calendar/utilities/settings.ts) - it formats as
|
|
10
|
+
* YYYY-MM-DD, the one date string that parses unambiguously, and the parts
|
|
11
|
+
* are reassembled rather than the string parsed so no UTC instant can land
|
|
12
|
+
* on the day before. Null when there is no zone or the runtime rejects it,
|
|
13
|
+
* so the caller falls back rather than opening the form on nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const todayIn = (zone?: string | null): string | null => {
|
|
16
|
+
if (!zone) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const [ year, month, day ] = new Intl.DateTimeFormat('en-CA', {
|
|
22
|
+
timeZone: zone,
|
|
23
|
+
year: 'numeric',
|
|
24
|
+
month: '2-digit',
|
|
25
|
+
day: '2-digit'
|
|
26
|
+
}).format(new Date()).split('-');
|
|
27
|
+
|
|
28
|
+
return `${ day }/${ month }/${ year }`;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
const prepare = (
|
|
6
35
|
value: (RoutesSearchForm | undefined),
|
|
7
36
|
params: Readonly<Params<string>>,
|
|
8
37
|
searchParams: URLSearchParams,
|
|
9
|
-
defaultLocations: { from: string, to: string }
|
|
38
|
+
defaultLocations: { from: string, to: string },
|
|
39
|
+
// the day to open on when the URL names none - see Step1 for whose clock
|
|
40
|
+
today?: string | null
|
|
10
41
|
): RoutesSearchForm => {
|
|
11
42
|
if (value !== undefined) {
|
|
12
43
|
return value;
|
|
@@ -24,7 +55,13 @@ const prepare = (
|
|
|
24
55
|
*/
|
|
25
56
|
const to = params.to ?? '';
|
|
26
57
|
|
|
27
|
-
|
|
58
|
+
/*
|
|
59
|
+
* Claude - 2026-09-16 (audit): TODAY, not tomorrow. `today` is the day as
|
|
60
|
+
* the departure city reckons it (Step1 works it out); without one the
|
|
61
|
+
* device clock is the only clock left. futureDate(1) dated from before
|
|
62
|
+
* same-day sales existed, and nothing documented a reason to keep it.
|
|
63
|
+
*/
|
|
64
|
+
const departure = params.departure?.replace(/-/g, '/') ?? today ?? futureDate(0);
|
|
28
65
|
|
|
29
66
|
/*
|
|
30
67
|
* Edited: Claude - Date: 2026-08-29
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useId, useState } from 'react';
|
|
2
2
|
import { TFunction } from 'i18next';
|
|
3
3
|
import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
|
|
4
|
-
import { Calendar,
|
|
4
|
+
import { Calendar, ChooseSeat, Required } from '@autobusal/common';
|
|
5
5
|
import { Validate, Display } from '@autobusal/utilities';
|
|
6
6
|
import Choose from '../Saved/Choose';
|
|
7
7
|
import Remember from '../Saved/Remember';
|
|
8
8
|
import { useGetSavedPassenger } from '../../services';
|
|
9
|
-
import { Container, Title, Notice } from './styles';
|
|
9
|
+
import { Container, Title, Notice, Fieldset, Legend } from './styles';
|
|
10
10
|
import { PersonData, SavedPassenger, SavedPassengerRecord } from '@autobusal/providers/types/persons';
|
|
11
11
|
import { OccupiedData } from '@autobusal/providers/types/buses';
|
|
12
12
|
|
|
@@ -171,9 +171,10 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
171
171
|
// The form should post exactly what it shows.
|
|
172
172
|
if (has('sex')) {
|
|
173
173
|
// 0 is a perfectly good answer here, so this tests for null rather than
|
|
174
|
-
// for falsiness. A traveller with no stored sex leaves the
|
|
175
|
-
//
|
|
176
|
-
|
|
174
|
+
// for falsiness. A traveller with no stored sex leaves the group EMPTY
|
|
175
|
+
// - reported by `missing`, and then required below - rather than being
|
|
176
|
+
// quietly booked as male.
|
|
177
|
+
onUpdate(names.sex, chosen.sex === null ? '' : chosen.sex);
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
if (has('passport')) {
|
|
@@ -233,6 +234,23 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
233
234
|
// nobody has been chosen to merge into.
|
|
234
235
|
const full = saved !== undefined && savedMax !== undefined && savedMax > 0 && saved.length >= savedMax && picked === 0;
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The gender to tick when the form mounts, or null for none.
|
|
239
|
+
*
|
|
240
|
+
* Claude - 2026-09-16 (audit): common's <Gender> ticked "Male" whenever
|
|
241
|
+
* nothing was known, so every passenger whose buyer never touched the
|
|
242
|
+
* control travelled as male in the operator's manifest - our guess dressed
|
|
243
|
+
* up as an answer. The radios are rendered here instead, start empty and
|
|
244
|
+
* are required like the fields around them; only a value that was
|
|
245
|
+
* actually recorded (a saved traveller's, or this form's own before a
|
|
246
|
+
* step back) pre-selects one.
|
|
247
|
+
*/
|
|
248
|
+
const stored = values?.[names.sex];
|
|
249
|
+
|
|
250
|
+
const sex = record !== undefined
|
|
251
|
+
? record.sex
|
|
252
|
+
: (stored === undefined || stored === '' ? null : Number(stored));
|
|
253
|
+
|
|
236
254
|
return (
|
|
237
255
|
<Container className="box">
|
|
238
256
|
<Title>
|
|
@@ -259,10 +277,19 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
259
277
|
<div className="row">
|
|
260
278
|
<Required htmlFor={ id(names.firstName) }>{ t('routes_order.step4.first_name') }</Required>
|
|
261
279
|
|
|
280
|
+
{ /* Claude - 2026-09-16 (audit): autocomplete tokens on every field
|
|
281
|
+
a browser or password manager can fill, SCOPED per passenger
|
|
282
|
+
with a section- prefix - without it a two-passenger form is
|
|
283
|
+
offered the same person twice. aria-required says what the
|
|
284
|
+
asterisk shows. The date of birth and the seat pickers are
|
|
285
|
+
shared controls (common) that render their own inputs and are
|
|
286
|
+
not touched here. */ }
|
|
262
287
|
<input
|
|
263
288
|
type="text"
|
|
264
289
|
key={ `${ names.firstName }-${ applied }` }
|
|
265
290
|
id={ id(names.firstName) }
|
|
291
|
+
autoComplete={ `section-${ name } given-name` }
|
|
292
|
+
aria-required="true"
|
|
266
293
|
defaultValue={ start(names.firstName, record?.first_name) }
|
|
267
294
|
{ ...refs(names.firstName, Validate('required|min_length:2|max_length:100', t)) }
|
|
268
295
|
/>
|
|
@@ -277,6 +304,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
277
304
|
type="text"
|
|
278
305
|
key={ `${ names.lastName }-${ applied }` }
|
|
279
306
|
id={ id(names.lastName) }
|
|
307
|
+
autoComplete={ `section-${ name } family-name` }
|
|
308
|
+
aria-required="true"
|
|
280
309
|
defaultValue={ start(names.lastName, record?.last_name) }
|
|
281
310
|
{ ...refs(names.lastName, Validate('required|min_length:2|max_length:100', t)) }
|
|
282
311
|
/>
|
|
@@ -306,15 +335,31 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
306
335
|
|
|
307
336
|
{ has('sex') && (
|
|
308
337
|
<div className="row">
|
|
309
|
-
{
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
338
|
+
{ /* a fieldset, so the two radios share one accessible name - on
|
|
339
|
+
their own each announced only "Male" or "Female" with nothing
|
|
340
|
+
saying what the question was */ }
|
|
341
|
+
<Fieldset key={ `${ names.sex }-${ applied }` }>
|
|
342
|
+
<Legend>
|
|
343
|
+
<Required>{ t('routes_order.step4.gender') }</Required>
|
|
344
|
+
</Legend>
|
|
345
|
+
|
|
346
|
+
<div className="list">
|
|
347
|
+
{ ([ [ 0, 'male' ], [ 1, 'female' ] ] as const).map(([ value, label ]) => (
|
|
348
|
+
<label key={ value }>
|
|
349
|
+
<input
|
|
350
|
+
type="radio"
|
|
351
|
+
value={ value }
|
|
352
|
+
defaultChecked={ sex === value }
|
|
353
|
+
autoComplete={ `section-${ name } sex` }
|
|
354
|
+
aria-required="true"
|
|
355
|
+
{ ...refs(names.sex, Validate('required', t)) }
|
|
356
|
+
/>
|
|
357
|
+
|
|
358
|
+
{ t(`data.gender.${ label }`, { ns: 'common' }) }
|
|
359
|
+
</label>
|
|
360
|
+
)) }
|
|
361
|
+
</div>
|
|
362
|
+
</Fieldset>
|
|
318
363
|
|
|
319
364
|
{ Display(errors[names.sex]) }
|
|
320
365
|
</div>
|
|
@@ -331,6 +376,7 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
331
376
|
type="text"
|
|
332
377
|
key={ `${ names.passport }-${ applied }` }
|
|
333
378
|
id={ id(names.passport) }
|
|
379
|
+
aria-required="true"
|
|
334
380
|
defaultValue={ start(names.passport, record?.passport) }
|
|
335
381
|
{ ...refs(names.passport, Validate('required|min_length:2|max_length:50', t)) }
|
|
336
382
|
/>
|
|
@@ -349,6 +395,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
349
395
|
type="text"
|
|
350
396
|
key={ `${ names.phone }-${ applied }` }
|
|
351
397
|
id={ id(names.phone) }
|
|
398
|
+
autoComplete={ `section-${ name } tel` }
|
|
399
|
+
aria-required={ phoneOptional ? undefined : 'true' }
|
|
352
400
|
defaultValue={ start(names.phone, record?.phone) }
|
|
353
401
|
{ ...refs(names.phone, Validate(phoneOptional ? 'min_length:5|max_length:50' : 'required|min_length:5|max_length:50', t)) }
|
|
354
402
|
/>
|
|
@@ -376,6 +424,7 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
376
424
|
type="email"
|
|
377
425
|
key={ `${ names.email }-${ applied }` }
|
|
378
426
|
id={ id(names.email) }
|
|
427
|
+
autoComplete={ `section-${ name } email` }
|
|
379
428
|
defaultValue={ start(names.email, undefined) }
|
|
380
429
|
{ ...refs(names.email, Validate('email|max_length:255', t)) }
|
|
381
430
|
/>
|
|
@@ -402,6 +451,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
402
451
|
type="text"
|
|
403
452
|
key={ `${ names.whatsapp }-${ applied }` }
|
|
404
453
|
id={ id(names.whatsapp) }
|
|
454
|
+
autoComplete={ `section-${ name } tel` }
|
|
455
|
+
aria-required="true"
|
|
405
456
|
defaultValue={ start(names.whatsapp, record?.whatsapp) }
|
|
406
457
|
{ ...refs(names.whatsapp, Validate('required|min_length:5|max_length:50', t)) }
|
|
407
458
|
/>
|
|
@@ -418,6 +469,8 @@ const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, with
|
|
|
418
469
|
type="text"
|
|
419
470
|
key={ `${ names.telegram }-${ applied }` }
|
|
420
471
|
id={ id(names.telegram) }
|
|
472
|
+
autoComplete={ `section-${ name } nickname` }
|
|
473
|
+
aria-required="true"
|
|
421
474
|
defaultValue={ start(names.telegram, record?.telegram) }
|
|
422
475
|
{ ...refs(names.telegram, Validate('required|min_length:2|max_length:100', t)) }
|
|
423
476
|
/>
|
|
@@ -2,6 +2,37 @@ import styled from 'styled-components';
|
|
|
2
2
|
|
|
3
3
|
export const Container = styled.div`
|
|
4
4
|
margin-bottom: 5px;
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Claude - 2026-09-16 (audit): 44px fields under a thumb. The theme sizes
|
|
8
|
+
* every text input at 38px, which is fine under a mouse and short of the
|
|
9
|
+
* 44px minimum on a phone; min-height beats the global height, so nothing
|
|
10
|
+
* else about the fields changes.
|
|
11
|
+
*/
|
|
12
|
+
@media (max-width: 639px) {
|
|
13
|
+
input:not([type='checkbox']):not([type='radio']), select {
|
|
14
|
+
min-height: 44px;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The gender radios, as a group with one accessible name.
|
|
21
|
+
*
|
|
22
|
+
* Claude - 2026-09-16 (audit): the browser's default fieldset chrome is
|
|
23
|
+
* removed so the group sits in its row like every other field; the legend
|
|
24
|
+
* carries the required label the way the other rows' labels do.
|
|
25
|
+
*/
|
|
26
|
+
export const Fieldset = styled.fieldset`
|
|
27
|
+
margin: 0;
|
|
28
|
+
padding: 0;
|
|
29
|
+
min-width: 0;
|
|
30
|
+
border: 0;
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
export const Legend = styled.legend`
|
|
34
|
+
padding: 0;
|
|
35
|
+
margin-bottom: 6px;
|
|
5
36
|
`;
|
|
6
37
|
|
|
7
38
|
export const Title = styled.h3`
|
|
@@ -93,7 +93,10 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
93
93
|
<div className="row">
|
|
94
94
|
<Required htmlFor={ id('bill_fn') }>{ t('routes_order.step5.billing.first_name') }</Required>
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
{ /* Claude - 2026-09-16 (audit): `billing` autocomplete tokens, so a
|
|
97
|
+
browser or password manager fills the block as the billing
|
|
98
|
+
address it is; aria-required says what the asterisk shows. */ }
|
|
99
|
+
<input type="text" id={ id('bill_fn') } autoComplete="billing given-name" aria-required="true" { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
|
|
97
100
|
|
|
98
101
|
{ Display(errors.bill_fn) }
|
|
99
102
|
</div>
|
|
@@ -101,7 +104,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
101
104
|
<div className="row">
|
|
102
105
|
<Required htmlFor={ id('bill_ln') }>{ t('routes_order.step5.billing.last_name') }</Required>
|
|
103
106
|
|
|
104
|
-
<input type="text" id={ id('bill_ln') } { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
|
|
107
|
+
<input type="text" id={ id('bill_ln') } autoComplete="billing family-name" aria-required="true" { ...refs('bill_ln', Validate('required|min_length:2|max_length:100', t)) } />
|
|
105
108
|
|
|
106
109
|
{ Display(errors.bill_ln) }
|
|
107
110
|
</div>
|
|
@@ -113,7 +116,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
113
116
|
<div className="row">
|
|
114
117
|
<Required htmlFor={ id('email') }>{ t('routes_order.step5.billing.email') }</Required>
|
|
115
118
|
|
|
116
|
-
<input type="email" id={ id('email') } { ...refs('email', Validate('required|email|max_length:255', t)) } />
|
|
119
|
+
<input type="email" id={ id('email') } autoComplete="email" aria-required="true" { ...refs('email', Validate('required|email|max_length:255', t)) } />
|
|
117
120
|
|
|
118
121
|
{ Display(errors.email) }
|
|
119
122
|
|
|
@@ -127,7 +130,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
127
130
|
<div className="row">
|
|
128
131
|
<Required htmlFor={ id('bill_phone') }>{ t('routes_order.step5.billing.phone') }</Required>
|
|
129
132
|
|
|
130
|
-
<input type="text" id={ id('bill_phone') } { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
|
|
133
|
+
<input type="text" id={ id('bill_phone') } autoComplete="billing tel" aria-required="true" { ...refs('bill_phone', Validate('required|min_length:10|max_length:20', t)) } />
|
|
131
134
|
|
|
132
135
|
{ Display(errors.bill_phone) }
|
|
133
136
|
</div>
|
|
@@ -138,7 +141,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
138
141
|
<div className="row">
|
|
139
142
|
<Required htmlFor={ id('bill_address') }>{ t('routes_order.step5.billing.address') }</Required>
|
|
140
143
|
|
|
141
|
-
<textarea id={ id('bill_address') } { ...refs('bill_address', Validate(`required|min_length:${ gaps ? 5 : 2 }`, t)) }></textarea>
|
|
144
|
+
<textarea id={ id('bill_address') } autoComplete="billing street-address" aria-required="true" { ...refs('bill_address', Validate(`required|min_length:${ gaps ? 5 : 2 }`, t)) }></textarea>
|
|
142
145
|
|
|
143
146
|
{ Display(errors.bill_address) }
|
|
144
147
|
</div>
|
|
@@ -149,7 +152,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
149
152
|
<div className="row">
|
|
150
153
|
<Required htmlFor={ id('bill_city') }>{ t('routes_order.step5.billing.city') }</Required>
|
|
151
154
|
|
|
152
|
-
<input type="text" id={ id('bill_city') } { ...refs('bill_city', Validate(`required|min_length:2|max_length:${ gaps ? 40 : 100 }`, t)) } />
|
|
155
|
+
<input type="text" id={ id('bill_city') } autoComplete="billing address-level2" aria-required="true" { ...refs('bill_city', Validate(`required|min_length:2|max_length:${ gaps ? 40 : 100 }`, t)) } />
|
|
153
156
|
|
|
154
157
|
{ Display(errors.bill_city) }
|
|
155
158
|
</div>
|
|
@@ -159,7 +162,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
159
162
|
<div className="row">
|
|
160
163
|
<Required htmlFor={ id('bill_country') }>{ t('routes_order.step5.billing.country') }</Required>
|
|
161
164
|
|
|
162
|
-
<select id={ id('bill_country') } { ...refs('bill_country', Validate('required|min:1', t)) }>
|
|
165
|
+
<select id={ id('bill_country') } autoComplete="billing country-name" aria-required="true" { ...refs('bill_country', Validate('required|min:1', t)) }>
|
|
163
166
|
<option value={ 0 }>{ t('routes_order.step5.billing.country_choose') }</option>
|
|
164
167
|
{ items }
|
|
165
168
|
</select>
|
|
@@ -184,7 +187,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
184
187
|
rather than the asterisked one, but still associated */ }
|
|
185
188
|
<label htmlFor={ id('bill_company') }>{ t('routes_order.step5.billing.company') }</label>
|
|
186
189
|
|
|
187
|
-
<input type="text" id={ id('bill_company') } { ...refs('bill_company') } />
|
|
190
|
+
<input type="text" id={ id('bill_company') } autoComplete="organization" { ...refs('bill_company') } />
|
|
188
191
|
|
|
189
192
|
{ Display(errors.bill_company) }
|
|
190
193
|
</div>
|
|
@@ -210,7 +213,7 @@ const Billing = ({ errors, t, refs, only }: Props): JSX.Element => {
|
|
|
210
213
|
{ asked('terms') && (
|
|
211
214
|
<div className="row row-nomargin">
|
|
212
215
|
<label className="single">
|
|
213
|
-
<input type="checkbox" value="1" { ...refs('terms', Validate('required', t)) } />
|
|
216
|
+
<input type="checkbox" value="1" aria-required="true" { ...refs('terms', Validate('required', t)) } />
|
|
214
217
|
|
|
215
218
|
<span dangerouslySetInnerHTML={{
|
|
216
219
|
__html: t('routes_order.step5.billing.terms', {
|
package/Step5/Billing/styles.ts
CHANGED
|
@@ -2,6 +2,14 @@ import styled from 'styled-components';
|
|
|
2
2
|
|
|
3
3
|
export const Container = styled.div`
|
|
4
4
|
margin-top: 25px;
|
|
5
|
+
|
|
6
|
+
// Claude - 2026-09-16 (audit): the passenger block's 44px phone fields,
|
|
7
|
+
// for the same reason - see Step4/Passenger/styles.ts
|
|
8
|
+
@media (max-width: 639px) {
|
|
9
|
+
input:not([type='checkbox']):not([type='radio']), select {
|
|
10
|
+
min-height: 44px;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
5
13
|
`;
|
|
6
14
|
|
|
7
15
|
export const NoticeEmail = styled.div`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa';
|
|
3
3
|
import { FaCircleDot, FaLocationDot } from 'react-icons/fa6';
|
|
4
|
-
import { Trip, TripLabel, Container, ContainerIcon, Icon, Information, Title, Operator, Carrier, Logo, Leg, Point, PointIcon, PointCity, Country, PointMeta } from './styles';
|
|
4
|
+
import { Trip, TripLabel, Container, ContainerIcon, Icon, Information, Title, Line, Code, Operator, Carrier, Logo, Leg, Point, PointIcon, PointCity, Country, PointMeta } from './styles';
|
|
5
5
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
6
6
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
7
7
|
|
|
@@ -57,7 +57,20 @@ const About = ({ type, step1, data, number, t }: Props): (JSX.Element | null) =>
|
|
|
57
57
|
</ContainerIcon>
|
|
58
58
|
|
|
59
59
|
<Information>
|
|
60
|
-
|
|
60
|
+
{ /* Claude - 2026-09-16 (audit): THE LEG THAT WAS BOUGHT, not the
|
|
61
|
+
coach's own name. "THESSALONIKI - VERIA - SHKODER (II)" is the
|
|
62
|
+
operator's route - a coach that carries on to Shkodër - and a
|
|
63
|
+
buyer who searched Thessaloniki to Tirana read it as the wrong
|
|
64
|
+
ticket. The two cities are the title, and the date and time sit
|
|
65
|
+
in bold on the origin line below. The code and the operator's
|
|
66
|
+
route name drop to a line of detail, where they still match
|
|
67
|
+
the printed ticket without claiming to be the journey. */ }
|
|
68
|
+
<Title>{ data.locations.from.city.name } → { data.locations.to.city.name }</Title>
|
|
69
|
+
|
|
70
|
+
<Line>
|
|
71
|
+
<Code title={ t('routes_order.step2.route.name') }>{ data.code }</Code>
|
|
72
|
+
{ data.name }
|
|
73
|
+
</Line>
|
|
61
74
|
|
|
62
75
|
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
63
76
|
Mark and name together, ranged right. The name used to sit
|
|
@@ -62,6 +62,28 @@ export const Title = styled.h2`
|
|
|
62
62
|
text-transform: uppercase;
|
|
63
63
|
`;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The route code and the operator's own name for the route, under the title.
|
|
67
|
+
*
|
|
68
|
+
* Claude - 2026-09-16 (audit): detail, set as detail - small and faded, with
|
|
69
|
+
* only the code in the ordinary ink so it can be read back against a ticket.
|
|
70
|
+
* `anywhere`, because an operator's route name lists every city it calls at
|
|
71
|
+
* and the checkout sidebar is a narrow column.
|
|
72
|
+
*/
|
|
73
|
+
export const Line = styled.div`
|
|
74
|
+
margin-top: 2px;
|
|
75
|
+
font-size: ${ props => props.theme.size.xs };
|
|
76
|
+
color: ${ props => props.theme.font.faded };
|
|
77
|
+
overflow-wrap: anywhere;
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
export const Code = styled.span`
|
|
81
|
+
margin-right: 6px;
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: .3px;
|
|
84
|
+
color: ${ props => props.theme.font.normal };
|
|
85
|
+
`;
|
|
86
|
+
|
|
65
87
|
export const SubTitle = styled.div`
|
|
66
88
|
display: inline-block;
|
|
67
89
|
padding: 4px 10px;
|
package/package.json
CHANGED
package/styles.ts
CHANGED
|
@@ -4,6 +4,12 @@ export const Title = styled.h1`
|
|
|
4
4
|
display: flex;
|
|
5
5
|
align-items: center;
|
|
6
6
|
gap: 10px;
|
|
7
|
+
/*
|
|
8
|
+
* Claude - 2026-09-16: pinned to the h1 scale, because Step1 now renders
|
|
9
|
+
* this AS an h2 on the pair page (see its heading prop) - the level is a
|
|
10
|
+
* document-outline decision, not a request for a smaller heading.
|
|
11
|
+
*/
|
|
12
|
+
font-size: ${ props => props.theme.size.xxl };
|
|
7
13
|
`;
|
|
8
14
|
|
|
9
15
|
export const SubTitle = styled.h3`
|
package/Found/Group/Group.tsx
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
2
|
-
import { TFunction } from 'i18next';
|
|
3
|
-
import Route from '../Route/Route';
|
|
4
|
-
import { departureOf } from '../refine';
|
|
5
|
-
import { Container, Title, Times, Time } from './styles';
|
|
6
|
-
import { FoundData } from '@autobusal/providers/types/routes';
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
items: FoundData[]
|
|
10
|
-
type: 'favorite' | 'normal'
|
|
11
|
-
date?: string
|
|
12
|
-
passengers: number
|
|
13
|
-
t: TFunction<'common'>
|
|
14
|
-
onSave: (data: FoundData) => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* One card for a set of interchangeable departures.
|
|
19
|
-
*
|
|
20
|
-
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
21
|
-
*
|
|
22
|
-
* The card shows a real departure - never an abstract summary - and the
|
|
23
|
-
* other times sit under it as a picker. Whatever is selected is what gets
|
|
24
|
-
* booked, so there is no separate "now choose a time" step to forget: the
|
|
25
|
-
* card always represents a bookable trip, starting with the earliest.
|
|
26
|
-
*/
|
|
27
|
-
const Group = ({ items, type, date, passengers, t, onSave }: Props): JSX.Element => {
|
|
28
|
-
// chronological for the picker, regardless of how the list itself is
|
|
29
|
-
// sorted - a row of times out of time order is just confusing
|
|
30
|
-
const departures = useMemo(() => (
|
|
31
|
-
[ ...items ].sort((first, second) => departureOf(first) - departureOf(second))
|
|
32
|
-
), [ items ]);
|
|
33
|
-
|
|
34
|
-
const [ selected, setSelected ] = useState<number>(0);
|
|
35
|
-
|
|
36
|
-
const current = departures[selected] ?? departures[0];
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<Route
|
|
40
|
-
type={ type }
|
|
41
|
-
data={ current }
|
|
42
|
-
date={ date }
|
|
43
|
-
passengers={ passengers }
|
|
44
|
-
t={ t }
|
|
45
|
-
onSave={ onSave }
|
|
46
|
-
>
|
|
47
|
-
<Container>
|
|
48
|
-
<Title>{ t('routes_order.step2.route.departures', { total: departures.length }) }</Title>
|
|
49
|
-
|
|
50
|
-
<Times>
|
|
51
|
-
{ departures.map((item, index) => (
|
|
52
|
-
<Time
|
|
53
|
-
key={ item.id }
|
|
54
|
-
type="button"
|
|
55
|
-
$active={ index === selected }
|
|
56
|
-
aria-pressed={ index === selected }
|
|
57
|
-
onClick={ () => setSelected(index) }
|
|
58
|
-
>
|
|
59
|
-
{ item.locations.from.departure }
|
|
60
|
-
</Time>
|
|
61
|
-
)) }
|
|
62
|
-
</Times>
|
|
63
|
-
</Container>
|
|
64
|
-
</Route>
|
|
65
|
-
);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export default Group;
|
package/Found/Group/styles.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import styled, { css } from 'styled-components';
|
|
2
|
-
|
|
3
|
-
export const Container = styled.div`
|
|
4
|
-
display: flex;
|
|
5
|
-
flex-direction: column;
|
|
6
|
-
gap: 8px;
|
|
7
|
-
padding: 12px;
|
|
8
|
-
background: ${ props => props.theme.background.neutral };
|
|
9
|
-
border-radius: ${ props => props.theme.borderRadius };
|
|
10
|
-
`;
|
|
11
|
-
|
|
12
|
-
export const Title = styled.h6`
|
|
13
|
-
margin-bottom: 0;
|
|
14
|
-
font-size: ${ props => props.theme.size.xs };
|
|
15
|
-
text-transform: uppercase;
|
|
16
|
-
color: ${ props => props.theme.font.faded };
|
|
17
|
-
`;
|
|
18
|
-
|
|
19
|
-
export const Times = styled.div`
|
|
20
|
-
display: flex;
|
|
21
|
-
flex-wrap: wrap;
|
|
22
|
-
gap: 6px;
|
|
23
|
-
`;
|
|
24
|
-
|
|
25
|
-
export const Time = styled.button<{ $active: boolean }>`
|
|
26
|
-
padding: 5px 12px;
|
|
27
|
-
font-size: ${ props => props.theme.size.s };
|
|
28
|
-
font-weight: 700;
|
|
29
|
-
border-radius: 100px;
|
|
30
|
-
border: 1px solid ${ props => props.theme.inputs.border };
|
|
31
|
-
background: ${ props => props.theme.background.normal };
|
|
32
|
-
color: ${ props => props.theme.font.normal };
|
|
33
|
-
transition: all 0.2s ease;
|
|
34
|
-
|
|
35
|
-
&:hover {
|
|
36
|
-
border-color: ${ props => props.theme.primary.normal };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
${ props => props.$active && css`
|
|
40
|
-
color: ${ props => props.theme.primary.contrast };
|
|
41
|
-
background: ${ props => props.theme.primary.normal };
|
|
42
|
-
border-color: ${ props => props.theme.primary.normal };
|
|
43
|
-
` }
|
|
44
|
-
`;
|