@autobusal/routes-order 1.37.7 → 1.37.9
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/Availability/Availability.tsx +8 -1
- package/Availability/Skeleton.tsx +119 -0
- package/Availability/styles.ts +2 -1
- 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/Filters/styles.ts +16 -3
- package/Found/Orders/Orders.tsx +36 -34
- package/Found/Orders/Skeleton.tsx +102 -0
- package/Found/Route/Route.tsx +4 -10
- package/Found/Route/styles.ts +50 -7
- package/Found/Sort/styles.ts +6 -1
- package/Found/refine.ts +0 -46
- package/RoutesOrder.tsx +24 -2
- package/Sections/Sections.tsx +25 -5
- 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 +43 -0
- package/Found/Group/Group.tsx +0 -68
- package/Found/Group/styles.ts +0 -44
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/Sort/styles.ts
CHANGED
|
@@ -94,7 +94,12 @@ export const Option = styled.button<{ $active: boolean }>`
|
|
|
94
94
|
overflow: hidden;
|
|
95
95
|
text-overflow: ellipsis;
|
|
96
96
|
padding: 5px 2px;
|
|
97
|
-
|
|
97
|
+
// Claude - 2026-09-16 (audit): xs, not xs minus 2 - that was 10px, under
|
|
98
|
+
// the phone floor. (These pills are only ever painted at 640px and up -
|
|
99
|
+
// see Options in ./Sort.tsx - but the rule cascades to the hidden phone
|
|
100
|
+
// copy of every one of them regardless, and a text-size audit reads
|
|
101
|
+
// computed style, not display.)
|
|
102
|
+
font-size: ${ props => props.theme.size.xs };
|
|
98
103
|
font-weight: 700;
|
|
99
104
|
white-space: nowrap;
|
|
100
105
|
|
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
|
@@ -2,6 +2,7 @@ import { TFunction } from 'i18next';
|
|
|
2
2
|
import Facts from '../Facts/Facts';
|
|
3
3
|
import Faq from '../Faq/Faq';
|
|
4
4
|
import Availability from '../Availability/Availability';
|
|
5
|
+
import AvailabilitySkeleton from '../Availability/Skeleton';
|
|
5
6
|
import AvailabilitySchema from '../Availability/Schema';
|
|
6
7
|
import Schedule from '../Schedule/Schedule';
|
|
7
8
|
import Links from '../Links/Links';
|
|
@@ -16,6 +17,8 @@ import { Nav, Anchor, Anchored } from './styles';
|
|
|
16
17
|
interface Props {
|
|
17
18
|
from?: string
|
|
18
19
|
to?: string
|
|
20
|
+
// the facts title's level - h1 on the pair page, h2 under a step heading
|
|
21
|
+
heading?: 'h1' | 'h2'
|
|
19
22
|
t: TFunction<'common'>
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -55,10 +58,15 @@ const LINKS = 'links';
|
|
|
55
58
|
* The nav renders only when there is more than the wizard to navigate to -
|
|
56
59
|
* a bar whose only link is "Trips" is furniture.
|
|
57
60
|
*/
|
|
58
|
-
const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
61
|
+
const Sections = ({ from, to, heading, t }: Props): (JSX.Element | null) => {
|
|
59
62
|
const { data } = useGetFacts(from, to);
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
// Edited: Claude - Date: 2026-09-16
|
|
65
|
+
// `isLoading`, not `isFetching`: this is the ONE state the skeleton below
|
|
66
|
+
// exists for - the first request, before anything has ever answered. A
|
|
67
|
+
// background refetch re-uses `dates` from cache while it runs, so it never
|
|
68
|
+
// sees this true and never swaps a real table back out for a placeholder.
|
|
69
|
+
const { data: dates, isLoading: datesLoading } = useGetAvailability(from, to);
|
|
62
70
|
|
|
63
71
|
const facts = data?.facts;
|
|
64
72
|
|
|
@@ -165,7 +173,7 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
165
173
|
{ availability && <AvailabilitySchema data={ availability } t={ t } /> }
|
|
166
174
|
|
|
167
175
|
<Anchored>
|
|
168
|
-
<Facts id={ FACTS } facts={ facts } t={ t } />
|
|
176
|
+
<Facts id={ FACTS } facts={ facts } heading={ heading } t={ t } />
|
|
169
177
|
</Anchored>
|
|
170
178
|
|
|
171
179
|
{ dead && onward }
|
|
@@ -178,11 +186,23 @@ const Sections = ({ from, to, t }: Props): (JSX.Element | null) => {
|
|
|
178
186
|
at all when the endpoint gives us nothing, which is also what
|
|
179
187
|
happens on a pair that has stopped running: the notice in the
|
|
180
188
|
facts block above has already said so, and thirty rows of "no
|
|
181
|
-
departures" underneath it would be a wall, not an answer.
|
|
182
|
-
|
|
189
|
+
departures" underneath it would be a wall, not an answer.
|
|
190
|
+
|
|
191
|
+
Edited: Claude - Date: 2026-09-16
|
|
192
|
+
WHILE IT LOADS, a skeleton the same shape takes the space instead
|
|
193
|
+
of nothing - see Availability/Skeleton for why (measured CLS of
|
|
194
|
+
0.52-1.16 on this exact block). `datesLoading` is the only other
|
|
195
|
+
branch: a pair that answers with nothing goes straight from the
|
|
196
|
+
skeleton to rendering nothing at all, which is correct - there
|
|
197
|
+
was never a table to reserve room for. */ }
|
|
198
|
+
{ availability ? (
|
|
183
199
|
<Anchored>
|
|
184
200
|
<Availability id={ AVAILABILITY } data={ availability } t={ t } />
|
|
185
201
|
</Anchored>
|
|
202
|
+
) : datesLoading && (
|
|
203
|
+
<Anchored>
|
|
204
|
+
<AvailabilitySkeleton id={ AVAILABILITY } from={ facts.from } to={ facts.to } t={ t } />
|
|
205
|
+
</Anchored>
|
|
186
206
|
) }
|
|
187
207
|
|
|
188
208
|
<Anchored>
|
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`
|