@autobusal/routes-order 1.37.6 → 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.
@@ -36,9 +36,27 @@ interface Props {
36
36
  * is a single parse for a consumer and a single tag in the snapshot;
37
37
  * scattering them would multiply the boilerplate by the number of coaches.
38
38
  */
39
+ const TRIPS = 100;
40
+
39
41
  const Schema = ({ data, t }: Props): (JSX.Element | null) => {
40
42
  const stop = (name: string) => ({ '@type': 'BusStop', name });
41
43
 
44
+ /**
45
+ * HOW MANY DEPARTURES GET MARKUP, which is not the same as how many the
46
+ * page shows.
47
+ *
48
+ * Edited: Claude - Date: 2026-09-16
49
+ *
50
+ * MEASURED on a busy pair (Tirana-Thessaloniki, ten coaches a day): thirty
51
+ * days produced 300 BusTrip nodes and 194 KB of JSON-LD - 41% of a 482 KB
52
+ * page, for a hundred near-identical descriptions of the same two-hour
53
+ * journey at different clock times. The table underneath still lists every
54
+ * one of the thirty days in plain text, so nothing is hidden from a reader
55
+ * or from a crawler; what is capped is the repetition in the markup.
56
+ *
57
+ * A hundred is roughly a fortnight on the busiest pair here and the whole
58
+ * month on most of them, which is the horizon anybody books within.
59
+ */
42
60
  const trips = data.days.flatMap(day => day.departures.map(row => ({
43
61
  '@type': 'BusTrip',
44
62
  name: t('routes_order.facts.title', { from: data.from, to: data.to }),
@@ -103,7 +121,7 @@ const Schema = ({ data, t }: Props): (JSX.Element | null) => {
103
121
  return null;
104
122
  }
105
123
 
106
- return <JsonLd data={ { '@context': 'https://schema.org', '@graph': trips } } />;
124
+ return <JsonLd data={ { '@context': 'https://schema.org', '@graph': trips.slice(0, TRIPS) } } />;
107
125
  };
108
126
 
109
127
  export default Schema;
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
- <h2>{ t('routes_order.facts.title', { from: facts.from, to: facts.to }) }</h2>
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));
@@ -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
- // if date in the future, and we have routes available
120
- const isAvailable = today.getTime() <= date.getTime() && item.routes;
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
 
@@ -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 6px;
52
- height: 30px;
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
- min-height: 26px;
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
- font-size: calc(${ props => props.theme.size.xs } - 1px);
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.xxs };
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.success };
218
+ color: ${ props => props.$selected ? 'inherit' : props.theme.font.normal };
206
219
  `;
207
220
 
208
221
  export const ContainerLoading = styled.div`
@@ -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, groupItems, SortKey } from '../refine';
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
- * Grouping runs AFTER sorting: groupItems keeps input order, so a
72
- * collapsed set of shuttles lands wherever its best member sorted to.
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
- groupItems(sortItems(list, sort)).map(group => (
86
- group.length > 1
87
- ? (
88
- <Group
89
- key={ group[0].id }
90
- items={ group }
91
- type={ kind }
92
- date={ date }
93
- passengers={ passengers }
94
- t={ t }
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
 
@@ -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, children }: Props): JSX.Element => {
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
- and whatever `children` adds, rather than inside the fare column.
353
- Exactly one of the two is ever displayed; see ButtonDetails. */ }
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"
@@ -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: calc(${ props => props.theme.size.xs } - 2px);
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: calc(${ props => props.theme.size.xs } - 1px);
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: calc(${ props => props.theme.size.xs } - 1px);
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
- font-size: ${ props => props.theme.size.xxs };
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
- font-size: ${ props => props.theme.size.xxs };
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.normal };
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
- font-size: ${ props => props.theme.size.xxs };
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={ fromCity?.slug ?? params.from }
440
- to={ toCity?.slug ?? params.to }
460
+ from={ pairFrom }
461
+ to={ pairTo }
462
+ heading={ showStep1 ? 'h1' : 'h2' }
441
463
  t={ t }
442
464
  />
443
465
  ) }
@@ -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
- const prepared = prepare(value, params, searchParams, data.preferences.defaultLocations);
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
- const departure = params.departure?.replace(/-/g, '/') ?? futureDate(1);
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, Gender, ChooseSeat, Required } from '@autobusal/common';
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 control at
175
- // its own default and is reported by `missing` instead.
176
- onUpdate(names.sex, chosen.sex === null ? 0 : chosen.sex);
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
- { t('routes_order.step4.gender') }
310
-
311
- <Gender
312
- key={ `${ names.sex }-${ applied }` }
313
- name={ names.sex }
314
- defaultValue={ record !== undefined ? (record.sex ?? undefined) : (values !== undefined ? Number(values[names.sex]) : undefined) }
315
- t={ t }
316
- refs={ refs }
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
- <input type="text" id={ id('bill_fn') } { ...refs('bill_fn', Validate('required|min_length:2|max_length:100', t)) } />
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)) } />&nbsp;
216
+ <input type="checkbox" value="1" aria-required="true" { ...refs('terms', Validate('required', t)) } />&nbsp;
214
217
 
215
218
  <span dangerouslySetInnerHTML={{
216
219
  __html: t('routes_order.step5.billing.terms', {
@@ -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
- <Title>{ data.name } ({ data.code })</Title>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.37.6",
3
+ "version": "1.37.8",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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`
@@ -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;
@@ -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
- `;