@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.
@@ -17,8 +17,15 @@ interface Props {
17
17
  *
18
18
  * A week: enough that somebody asking "is there a bus this weekend" never
19
19
  * has to expand anything, few enough that the page still ends.
20
+ *
21
+ * Edited: Claude - Date: 2026-09-16
22
+ *
23
+ * Exported now: ./Skeleton reserves height for exactly this many day-blocks,
24
+ * not all thirty, because days 8-30 sit inside the closed <details> below
25
+ * and reserve nothing themselves. One number, so the skeleton and the real
26
+ * table can never disagree about how many days are "above the fold".
20
27
  */
21
- const VISIBLE = 7;
28
+ export const VISIBLE = 7;
22
29
 
23
30
  /**
24
31
  * Real departures on real dates, for the next thirty days.
@@ -0,0 +1,119 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Bone } from '../styles';
3
+ import { Container, Heading, Lead, Wrap, Table } from './styles';
4
+ import { VISIBLE } from './Availability';
5
+
6
+ interface Props {
7
+ id: string
8
+ // Real city names where they are already known - see the call site in
9
+ // ../Sections/Sections, which has them from the SAME facts query this
10
+ // section's own heading is built from once it answers. Undefined only
11
+ // for the instant before that, in which case the heading is a bar rather
12
+ // than a guess at English words in the middle of another language.
13
+ from?: string
14
+ to?: string
15
+ t: TFunction<'common'>
16
+ }
17
+
18
+ const COLUMNS = 6;
19
+
20
+ /**
21
+ * How many rows a day's placeholder reserves.
22
+ *
23
+ * Edited: Claude - Date: 2026-09-16
24
+ *
25
+ * Not knowable before the fetch answers - a quiet rural pair might run once
26
+ * a day, a trunk route ten times (see TRIPS in ./Schema, measured on
27
+ * Tirana-Thessaloniki). Three is not a guess pulled from nowhere: three rows
28
+ * across the seven VISIBLE days is twenty-one, and at this table's real
29
+ * padding and line-height that comes to almost exactly the ~1,290px,
30
+ * ~20-row block that was actually measured on production - see the
31
+ * height math this number produces below, which is what earns it the name
32
+ * "defensible" rather than "round". Under-reserving still shifts the page
33
+ * when the real rows land; over-reserving by a lot wastes screen before
34
+ * anyone has asked for anything - three is the number that keeps both
35
+ * errors small.
36
+ */
37
+ const ROWS_PER_DAY = 3;
38
+
39
+ const day = (index: number): JSX.Element => (
40
+ <tbody key={ index }>
41
+ <tr>
42
+ <th colSpan={ COLUMNS } scope="colgroup">
43
+ <Bone $width={ 35 } $height={ 16 } />
44
+ </th>
45
+ </tr>
46
+
47
+ { Array.from({ length: ROWS_PER_DAY }, (_, row) => (
48
+ <tr key={ row }>
49
+ <td><Bone $width={ 70 } /></td>
50
+ <td><Bone $width={ 70 } /></td>
51
+ <td><Bone $width={ 60 } /></td>
52
+ <td><Bone $width={ 80 } /></td>
53
+ <td><Bone $width={ 55 } /></td>
54
+ <td><Bone $width={ 60 } /></td>
55
+ </tr>
56
+ )) }
57
+ </tbody>
58
+ );
59
+
60
+ /**
61
+ * What the section looks like before /api/routes/search/availability
62
+ * answers.
63
+ *
64
+ * Edited: Claude - Date: 2026-09-16
65
+ *
66
+ * WHY THIS EXISTS. This section used to render nothing at all while the
67
+ * fetch was in flight (~1.2-1.4s) and then drop a ~1,290px block in all at
68
+ * once - measured CLS of 0.5172-1.1646 on pair pages, against a homepage
69
+ * (which has no such block) of 0.0000. The fix is not to load faster, it is
70
+ * to occupy the space from the FIRST paint, in the shape the real content
71
+ * will take, so the swap moves nothing.
72
+ *
73
+ * SAME BOXES, PLACEHOLDER CONTENT. This reuses Availability's own Container,
74
+ * Heading, Lead, Wrap and Table rather than a hand-measured approximation of
75
+ * them, so the two can never quietly drift apart - a padding change in
76
+ * ./styles resizes both at once. Only the day count (VISIBLE, imported
77
+ * rather than repeated) and the row count (ROWS_PER_DAY, above) are guesses;
78
+ * everything else is the real box model.
79
+ *
80
+ * aria-hidden, and no live region: a placeholder table announces nothing,
81
+ * because it has nothing to announce - the section's actual content is
82
+ * either the real table that replaces this, a few seconds later, or nothing
83
+ * at all.
84
+ */
85
+ const AvailabilitySkeleton = ({ id, from, to, t }: Props): JSX.Element => (
86
+ <Container id={ id } className="box" aria-hidden="true">
87
+ <Heading>
88
+ { from && to
89
+ ? t('routes_order.availability.title', { from, to })
90
+ : <Bone $width={ 45 } $height={ 22 } /> }
91
+ </Heading>
92
+
93
+ { /* Not data-dependent - the same translation the real Lead prints,
94
+ so this line never has to move when the real table lands. */ }
95
+ <Lead>{ t('routes_order.availability.lead') }</Lead>
96
+
97
+ <Wrap>
98
+ <Table>
99
+ <thead>
100
+ <tr>
101
+ { /* Real column headers, not placeholders - like the lead
102
+ above, these come from static translations rather than
103
+ from the fetch, so there is no reason to guess at them. */ }
104
+ <th>{ t('routes_order.schedule.departure') }</th>
105
+ <th>{ t('routes_order.schedule.arrival') }</th>
106
+ <th>{ t('routes_order.schedule.duration') }</th>
107
+ <th>{ t('routes_order.schedule.operator') }</th>
108
+ <th>{ t('routes_order.availability.price') }</th>
109
+ <th>{ t('routes_order.availability.book') }</th>
110
+ </tr>
111
+ </thead>
112
+
113
+ { Array.from({ length: VISIBLE }, (_, index) => day(index)) }
114
+ </Table>
115
+ </Wrap>
116
+ </Container>
117
+ );
118
+
119
+ export default AvailabilitySkeleton;
@@ -185,7 +185,8 @@ export const Price = styled.strong`
185
185
  * until a customer did.
186
186
  */
187
187
  export const SeatsLeft = styled.span<{ $low: boolean }>`
188
- font-size: ${ props => props.theme.size.xxs };
188
+ // Claude - 2026-09-16 (audit): xs - the xxs was 10px, under the phone floor
189
+ font-size: ${ props => props.theme.size.xs };
189
190
  line-height: 1.25;
190
191
  white-space: nowrap;
191
192
  font-weight: ${ props => (props.$low ? 700 : 400) };
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`
@@ -45,15 +45,28 @@ export const Toggle = styled.button<{ $open: boolean }>`
45
45
  z-index: 900;
46
46
 
47
47
  /*
48
- * Clear of the home indicator on a notched phone. env() resolves to 0 on
49
- * everything that has no inset, so the fallback is simply the base value.
48
+ * Clear of the home indicator on a notched phone, AND clear of the
49
+ * cookie notice on a narrow one.
50
+ *
51
+ * Edited: Claude - Date: 2026-09-16
52
+ *
53
+ * --obt-cookie-notice-height is published by CookieNotification
54
+ * (packages/common) while its own bar sits fixed to this same edge under
55
+ * 640px - see NOTICE_HEIGHT_VAR there. Two elements anchored to the same
56
+ * "bottom: 0" both being fixed to the viewport does not resolve itself by
57
+ * z-index, which only decides which one paints on top of the other; this
58
+ * button was measured sitting directly under the notice at 375px. The
59
+ * variable defaults to 0px, so a page without the notice - or a visitor
60
+ * who has already dismissed it - sees no change at all.
50
61
  */
51
- bottom: calc(20px + env(safe-area-inset-bottom, 0px));
62
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px) + var(--obt-cookie-notice-height, 0px));
52
63
 
53
64
  display: flex;
54
65
  align-items: center;
55
66
  gap: 6px;
56
67
  padding: 9px 16px;
68
+ // Claude - 2026-09-16 (audit): the 44px tap minimum - this measured 35px
69
+ min-height: 44px;
57
70
  font-size: ${ props => props.theme.size.xs };
58
71
  font-weight: 700;
59
72
  border-radius: 100px;
@@ -1,10 +1,9 @@
1
1
  import { TFunction } from 'i18next';
2
- import { Inline } from '@autobusal/common';
3
2
  import Route from '../Route/Route';
4
- import Group from '../Group/Group';
5
3
  import Empty from '../Empty/Empty';
4
+ import Skeleton from './Skeleton';
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';
@@ -28,12 +27,16 @@ interface Props {
28
27
  }
29
28
 
30
29
  const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, step1, alternatives, alternativesLoading, t, onReset, onSave }: Props): JSX.Element => {
30
+ /*
31
+ * Edited: Claude - Date: 2026-09-16
32
+ *
33
+ * A skeleton the shape of the real list, not a one-line message - see
34
+ * ./Skeleton for why (measured on a dated results URL, which is never
35
+ * prerendered: 0.6868-1.1646 CLS from a page that renders almost nothing
36
+ * until the search answers, then drops every card in at once).
37
+ */
31
38
  if (loading) {
32
- return (
33
- <div className="box">
34
- <Inline text={ t('routes_order.step2.loading.routes') } />
35
- </div>
36
- );
39
+ return <Skeleton t={ t } />;
37
40
  }
38
41
 
39
42
  // Edited: Ferjolt Ozuni - Date: 2026-08-01
@@ -67,9 +70,22 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
67
70
  * Preferred-stop pinning stays the OUTER partition and each side is
68
71
  * sorted within itself. Sorting the whole list first would have let
69
72
  * "cheapest" quietly un-pin a stop the user deliberately saved.
73
+ */
74
+ /*
75
+ * Claude - 2026-09-16 (Ferjolt: "When we have more than one departure for
76
+ * the same route from the same operator, should not group them as we do
77
+ * now but should show them as a standalone search result in full. Is very
78
+ * easy to get confused visitors with grouped alternatives. So, remove the
79
+ * grouping and put them all as standalone.")
70
80
  *
71
- * Grouping runs AFTER sorting: groupItems keeps input order, so a
72
- * collapsed set of shuttles lands wherever its best member sorted to.
81
+ * EVERY DEPARTURE IS ITS OWN CARD. Interchangeable departures used to
82
+ * collapse into one card with a row of times under it, and the card showed
83
+ * only the earliest - so somebody scanning for "the 14:00" saw a 06:00 and
84
+ * moved on, and somebody who did find the picker booked whichever time
85
+ * happened to be highlighted. The grouped card also defeated the sort it
86
+ * sat in: ordered "latest first", a group still led with its EARLIEST
87
+ * time. A run of similar rows is the honest shape of an hourly service,
88
+ * and the sort and filters above are there to cut it down.
73
89
  */
74
90
  /**
75
91
  * Edited: Ferjolt Ozuni - Date: 2026-08-05
@@ -82,30 +98,16 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
82
98
  const date = step1[type];
83
99
 
84
100
  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
- )
101
+ sortItems(list, sort).map(item => (
102
+ <Route
103
+ key={ item.id }
104
+ type={ kind }
105
+ data={ item }
106
+ date={ date }
107
+ passengers={ passengers }
108
+ t={ t }
109
+ onSave={ onSave }
110
+ />
109
111
  ))
110
112
  );
111
113
 
@@ -0,0 +1,102 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Bone } from '../../styles';
3
+ import { Container as Card, Trip, Company, Location, Time, Price, Amount } from '../Route/styles';
4
+ import { Container } from './styles';
5
+
6
+ /**
7
+ * How many placeholder cards to reserve room for.
8
+ *
9
+ * Edited: Claude - Date: 2026-09-16
10
+ *
11
+ * Not knowable before the search answers - see ROWS_PER_DAY in
12
+ * ../../Availability/Skeleton for the same problem one section over. Five
13
+ * is the same kind of middle estimate: enough to fill roughly one screen on
14
+ * a desktop, which is where a reserved-but-empty block would otherwise be
15
+ * most visible, and not so many that a route with genuinely few departures
16
+ * reserves a page and a half of blank cards. A result count either side of
17
+ * five is a residual shift of one card's height, not the ~20-card collapse
18
+ * this exists to prevent.
19
+ */
20
+ const CARDS = 5;
21
+
22
+ /**
23
+ * One placeholder card, in the shape Route/Route.tsx renders.
24
+ *
25
+ * Edited: Claude - Date: 2026-09-16
26
+ *
27
+ * SAME STYLED COMPONENTS AS THE REAL CARD - Card, Trip, Company, Location,
28
+ * Time, Price, Amount all come from ../Route/styles, not a redrawn
29
+ * approximation of them. That is what keeps this accurate as the card
30
+ * itself changes: a padding or gap edited there resizes the skeleton at the
31
+ * same time, rather than the two silently drifting apart.
32
+ *
33
+ * The three bars inside Price carry the REAL card's own class names
34
+ * (price-notice, details-toggle, seats-left) so the same nth-child/class
35
+ * ordering rules in Price's CSS lay them out exactly as they lay out the
36
+ * real amount, tax note, button, details toggle and seat count - including
37
+ * the extra rows those last two add on a phone, which is most of what made
38
+ * the old three-line estimate come up short.
39
+ */
40
+ const SkeletonCard = (): JSX.Element => (
41
+ <Card className="box" $type="normal" aria-hidden="true">
42
+ <Trip>
43
+ <Company>
44
+ <Bone $width={ 45 } $height={ 28 } />
45
+ <Bone $width={ 60 } $height={ 14 } />
46
+ </Company>
47
+
48
+ <Location $side="from">
49
+ <Bone $width={ 75 } $height={ 17 } />
50
+ <Bone $width={ 90 } $height={ 15 } />
51
+ </Location>
52
+
53
+ <Time>
54
+ <Bone $width={ 40 } $height={ 30 } />
55
+ </Time>
56
+
57
+ <Location $side="to">
58
+ <Bone $width={ 75 } $height={ 17 } />
59
+ <Bone $width={ 90 } $height={ 15 } />
60
+ </Location>
61
+
62
+ <Price>
63
+ <Amount><Bone $width={ 60 } $height={ 26 } /></Amount>
64
+ <Bone $width={ 100 } $height={ 44 } />
65
+ <Bone className="price-notice" $width={ 85 } $height={ 14 } />
66
+ <Bone className="details-toggle" $width={ 55 } $height={ 16 } />
67
+ <Bone className="seats-left" $width={ 65 } $height={ 14 } />
68
+ </Price>
69
+ </Trip>
70
+ </Card>
71
+ );
72
+
73
+ interface Props {
74
+ t: TFunction<'common'>
75
+ }
76
+
77
+ /**
78
+ * What the results list looks like before a search answers.
79
+ *
80
+ * Edited: Claude - Date: 2026-09-16
81
+ *
82
+ * WHY THIS EXISTS. A dated results URL (?departure=...) is never
83
+ * prerendered - it serves the SPA shell, so the very first thing a visitor
84
+ * sees is whatever this component renders while `loading` is true. That
85
+ * used to be a single line of text ("Searching for tickets..."), which
86
+ * collapsed the whole page to almost nothing and then dropped every result
87
+ * card in at once the moment the search answered - the same shape of CLS
88
+ * bug as ../../Availability/Skeleton, on a page that cannot be prerendered
89
+ * around it.
90
+ *
91
+ * `role="status"` with the SAME loading string the old text used, rather
92
+ * than a live region per card: a screen reader is told once that results
93
+ * are loading, not read five identical placeholder cards. The cards
94
+ * themselves are aria-hidden - there is nothing in them to announce.
95
+ */
96
+ const Skeleton = ({ t }: Props): JSX.Element => (
97
+ <Container role="status" aria-label={ t('routes_order.step2.loading.routes') }>
98
+ { Array.from({ length: CARDS }, (_, index) => <SkeletonCard key={ index } />) }
99
+ </Container>
100
+ );
101
+
102
+ export default Skeleton;
@@ -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"