@autobusal/routes-order 1.9.0 → 1.9.1

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +673 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. package/Step5/Step5.tsx +0 -185
@@ -1,8 +1,21 @@
1
1
  import styled from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * This row only means anything if its labels sit over the columns they
7
+ * name, so its geometry has to match the result card's exactly: the same
8
+ * 15px horizontal padding and the same 15px gap between columns. It had
9
+ * 4px padding and no gap at all, which pushed every label right of the
10
+ * data it labelled - Duration was out by 34px.
11
+ *
12
+ * If Found/Route/styles.ts ever changes the card's padding or the Trip
13
+ * gap, this has to move with it.
14
+ */
3
15
  export const Container = styled.div`
4
16
  display: none;
5
- padding: 8px 4px;
17
+ gap: 15px;
18
+ padding: 8px 15px;
6
19
  margin-bottom: 15px;
7
20
 
8
21
  @media (min-width: 1024px) {
@@ -23,18 +36,21 @@ const Item = styled.div`
23
36
  }
24
37
  `;
25
38
 
39
+ // these five must stay identical to their counterparts in
40
+ // Found/Route/styles.ts, or the labels stop sitting over their columns
26
41
  export const Company = styled(Item)`
27
- flex-basis: 185px;
42
+ flex: 0 0 185px;
28
43
  `;
29
44
 
30
45
  export const Location = styled(Item)`
31
46
  flex: 1;
47
+ min-width: 0;
32
48
  `;
33
49
 
34
50
  export const Time = styled(Item)`
35
- flex-basis: 120px;
51
+ flex: 0 0 120px;
36
52
  `;
37
53
 
38
54
  export const Price = styled(Item)`
39
- flex-basis: 165px;
55
+ flex: 0 0 165px;
40
56
  `;
@@ -2,10 +2,13 @@ import { TFunction } from 'i18next';
2
2
  import { Inline } from '@autobusal/common';
3
3
  import Route from '../Route/Route';
4
4
  import Group from '../Group/Group';
5
+ import Empty from '../Empty/Empty';
5
6
  import { getFavorites, getNormal } from './utilities';
6
7
  import { sortItems, groupItems, SortKey } from '../refine';
7
8
  import { Container, ContainerNotFound, Reset } from './styles';
8
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
11
+ import { AlternativesData } from '../../types';
9
12
 
10
13
  interface Props {
11
14
  loading: boolean
@@ -16,12 +19,15 @@ interface Props {
16
19
  type: 'departure' | '_return'
17
20
  passengers: number
18
21
  preferredStop?: number
22
+ step1: RoutesSearchForm
23
+ alternatives?: AlternativesData
24
+ alternativesLoading: boolean
19
25
  t: TFunction<'common'>
20
26
  onReset: () => void
21
27
  onSave: (data: FoundData) => void
22
28
  }
23
29
 
24
- const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, t, onReset, onSave }: Props): JSX.Element => {
30
+ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, step1, alternatives, alternativesLoading, t, onReset, onSave }: Props): JSX.Element => {
25
31
  if (loading) {
26
32
  return (
27
33
  <div className="box">
@@ -30,11 +36,14 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
30
36
  );
31
37
  }
32
38
 
33
- // no results at all - the search itself found nothing (or errored, e.g.
34
- // same origin and destination) - show the not-found message
39
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
40
+ // No results at all - the search itself found nothing (or errored, e.g.
41
+ // same origin and destination). Rather than a bare apology, offer the
42
+ // nearby origins, nearby destinations and other dates that obtapi has
43
+ // confirmed actually have seats.
35
44
  if (empty) {
36
45
  return (
37
- <ContainerNotFound className="box">{ t('routes_order.step2.not_found') }</ContainerNotFound>
46
+ <Empty type={ type } step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
38
47
  );
39
48
  }
40
49
 
@@ -62,6 +71,16 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
62
71
  * Grouping runs AFTER sorting: groupItems keeps input order, so a
63
72
  * collapsed set of shuttles lands wherever its best member sorted to.
64
73
  */
74
+ /**
75
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
76
+ *
77
+ * The day these results board on, for the cards that arrive on a later
78
+ * one. It is the date that was SEARCHED - the same value the strip above
79
+ * highlights - rather than anything the server sends back, so the two can
80
+ * never disagree about which day a card is for.
81
+ */
82
+ const date = step1[type];
83
+
65
84
  const render = (list: FoundData[], kind: 'favorite' | 'normal'): JSX.Element[] => (
66
85
  groupItems(sortItems(list, sort)).map(group => (
67
86
  group.length > 1
@@ -70,6 +89,7 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
70
89
  key={ group[0].id }
71
90
  items={ group }
72
91
  type={ kind }
92
+ date={ date }
73
93
  passengers={ passengers }
74
94
  t={ t }
75
95
  onSave={ onSave }
@@ -80,6 +100,7 @@ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferr
80
100
  key={ group[0].id }
81
101
  type={ kind }
82
102
  data={ group[0] }
103
+ date={ date }
83
104
  passengers={ passengers }
84
105
  t={ t }
85
106
  onSave={ onSave }
@@ -1,5 +1,5 @@
1
1
  import { TFunction } from 'i18next';
2
- import { Container, Pick, Label, Value, Meta } from './styles';
2
+ import { Container, Pick, Label, Value, Meta, Company } from './styles';
3
3
  import { SortKey, sortItems } from '../refine';
4
4
  import { FoundData } from '@autobusal/providers/types/routes';
5
5
 
@@ -55,8 +55,15 @@ const Picks = ({ items, sort, t, onSelect }: Props): JSX.Element | null => {
55
55
  <Value>{ best.price.display }</Value>
56
56
 
57
57
  <Meta>
58
- { best.duration !== '-' && `${ best.duration } · ` }
59
- { best.operator.company }
58
+ { best.duration !== '-' && best.duration }
59
+
60
+ { /* The operator's name is hidden on a phone - see Company in
61
+ styles. The separator goes with it, or a card reads
62
+ "4h ·" with nothing after the dot. */ }
63
+ <Company>
64
+ { best.duration !== '-' && ' · ' }
65
+ { best.operator.company }
66
+ </Company>
60
67
  </Meta>
61
68
  </Pick>
62
69
  );
@@ -1,13 +1,22 @@
1
1
  import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * Three across at every width, including phones, where these used to stack.
7
+ * Stacked, they cost three full bands of vertical space before a single
8
+ * result - so the thing that exists to summarise the list was pushing the
9
+ * list itself off the screen. Side by side they are a strip, and comparing
10
+ * three numbers is what they are for; a column of three is a list to read.
11
+ */
3
12
  export const Container = styled.div`
4
13
  display: grid;
5
- gap: 10px;
14
+ gap: 6px;
6
15
  margin-bottom: 15px;
7
- grid-template-columns: 1fr;
16
+ grid-template-columns: repeat(3, 1fr);
8
17
 
9
18
  @media (min-width: 640px) {
10
- grid-template-columns: repeat(3, 1fr);
19
+ gap: 10px;
11
20
  }
12
21
  `;
13
22
 
@@ -15,9 +24,15 @@ export const Pick = styled.button<{ $active: boolean }>`
15
24
  display: flex;
16
25
  flex-direction: column;
17
26
  align-items: flex-start;
18
- gap: 4px;
19
- padding: 12px 15px;
27
+ gap: 2px;
28
+ padding: 8px 9px;
20
29
  text-align: left;
30
+ min-width: 0;
31
+
32
+ @media (min-width: 640px) {
33
+ gap: 4px;
34
+ padding: 12px 15px;
35
+ }
21
36
  border-radius: ${ props => props.theme.borderRadius };
22
37
  border: 1px solid ${ props => props.theme.inputs.border };
23
38
  background: ${ props => props.theme.background.normal };
@@ -34,22 +49,58 @@ export const Pick = styled.button<{ $active: boolean }>`
34
49
  `;
35
50
 
36
51
  export const Label = styled.span`
37
- font-size: ${ props => props.theme.size.xs };
52
+ max-width: 100%;
53
+ overflow: hidden;
54
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
38
55
  text-transform: uppercase;
39
- letter-spacing: .5px;
56
+ letter-spacing: 0;
40
57
  font-weight: 700;
41
58
  color: ${ props => props.theme.font.faded };
59
+ text-overflow: ellipsis;
60
+ white-space: nowrap;
61
+
62
+ @media (min-width: 640px) {
63
+ font-size: ${ props => props.theme.size.xs };
64
+ letter-spacing: .5px;
65
+ }
42
66
  `;
43
67
 
44
68
  export const Value = styled.strong`
45
- font-size: ${ props => props.theme.size.l };
69
+ font-size: ${ props => props.theme.size.m };
70
+
71
+ @media (min-width: 640px) {
72
+ font-size: ${ props => props.theme.size.l };
73
+ }
46
74
  `;
47
75
 
48
76
  export const Meta = styled.span`
49
77
  overflow: hidden;
50
78
  max-width: 100%;
51
- font-size: ${ props => props.theme.size.xs };
79
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
52
80
  color: ${ props => props.theme.font.faded };
53
81
  text-overflow: ellipsis;
54
82
  white-space: nowrap;
83
+
84
+ @media (min-width: 640px) {
85
+ font-size: ${ props => props.theme.size.xs };
86
+ }
87
+ `;
88
+
89
+ /**
90
+ * The operator's name, dropped on a phone.
91
+ *
92
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
93
+ *
94
+ * At a third of a 375px screen each card gets about 110px, and a company
95
+ * name is the one thing here that will not fit in it - it would ellipsis to
96
+ * two or three characters, which is noise rather than information. The
97
+ * duration stays, because it is short and it is the number being compared;
98
+ * the name is one line down in the results anyway.
99
+ */
100
+ export const Company = styled.span`
101
+ display: none;
102
+
103
+ @media (min-width: 640px) {
104
+ display: inline;
105
+ }
55
106
  `;
@@ -1,9 +1,13 @@
1
1
  import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } from 'react-icons/hi';
3
+ import { HiOutlineArrowNarrowRight, HiOutlineDocumentText, HiChevronDown, HiChevronUp } from 'react-icons/hi';
4
+ import { BiHourglass } from 'react-icons/bi';
4
5
  import { RiBus2Line } from 'react-icons/ri';
5
- import { Button, RouteFeature, Policy } from '@autobusal/common';
6
- import { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Time, Price, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, Features, TitleFeatures, FeaturesItems } from './styles';
6
+ import { MdOutlineAltRoute } from 'react-icons/md';
7
+ import { formatDuration, splitPrice } from '../refine';
8
+ import { Button, RouteFeature, Information, Rating } from '@autobusal/common';
9
+ import { createDate, getDayParts } from '@autobusal/utilities';
10
+ import { Container, ButtonDetails, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, Amount, Decimals, Currency, PriceNotice, LinkCompany, CodeCompany, Details, Detail, ButtonPolicy, TitleDetail, FeaturesItems, Leaves, Day, NextDay } from './styles';
7
11
  import { FoundData } from '@autobusal/providers/types/routes';
8
12
 
9
13
  // neutral placeholder so a missing/broken operator logo doesn't render the
@@ -13,6 +17,11 @@ const LOGO_FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
13
17
  interface Props {
14
18
  data: FoundData
15
19
  type: 'favorite' | 'normal'
20
+ // Edited: Ferjolt Ozuni - Date: 2026-08-05
21
+ // The searched boarding day, dd/mm/yyyy. Only used to print the two dates
22
+ // on an overnight trip - a card that arrives the same day says nothing
23
+ // about days at all, which is why this can be absent.
24
+ date?: string
16
25
  passengers: number
17
26
  t: TFunction<'common'>
18
27
  onSave: (data: FoundData) => void
@@ -22,14 +31,24 @@ interface Props {
22
31
  children?: React.ReactNode
23
32
  }
24
33
 
25
- const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Element => {
26
- const [ policy, setPolicy ] = useState<boolean>(false);
34
+ const Route = ({ data, type, date, passengers, t, onSave, children }: Props): JSX.Element => {
35
+ const [ information, setInformation ] = useState<boolean>(false);
36
+
37
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
38
+ // Closed to begin with. Amenities, route code, transit and policies are
39
+ // supporting detail on a row somebody is scanning, and four bands of it
40
+ // per result pushed the next result off a phone screen. The content stays
41
+ // in the DOM either way (see Details in styles) so the prerendered
42
+ // snapshot a crawler reads is unchanged.
43
+ const [ details, setDetails ] = useState<boolean>(false);
27
44
 
28
45
  const customs = Object.values(data.customs).join(', ');
29
46
  const transiting = Object.values(data.transiting).join(', ');
30
47
 
48
+ const price = splitPrice(data.price.display);
49
+
31
50
  const features = data.trip.features_data.map((item, index) => (
32
- <RouteFeature key={ index } item={ item } />
51
+ <RouteFeature key={ index } item={ item } compact />
33
52
  ));
34
53
 
35
54
  // Edited: Ferjolt Ozuni - Date: 2026-07-27
@@ -43,33 +62,161 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
43
62
  ? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
44
63
  : undefined;
45
64
 
65
+ // Edited: Ferjolt Ozuni - Date: 2026-08-03
66
+ // Only today's results carry this - obtapi sends null on every other date,
67
+ // so there is no "in 26 days" badge to suppress here.
68
+ const leaves = data.departs_in ?? null;
69
+
70
+ const soon = leaves !== null && leaves < 60;
71
+
72
+ /**
73
+ * Edited: Ferjolt Ozuni - Date: 2026-08-05
74
+ *
75
+ * Overnight trips. "22:30 -> 06:00" is the one thing on this card that
76
+ * can be read as the exact opposite of what it means - a seven-and-a-half
77
+ * hour journey looks like a sixteen-hour one run backwards - and the
78
+ * duration beside it does not fix that, because nobody reads a duration
79
+ * to work out a date. So when the coach arrives on a later day, BOTH ends
80
+ * print their day and the arrival carries a +1.
81
+ *
82
+ * Nothing is added to a same-day card: a date under every arrival on a
83
+ * page of same-day results is noise that makes the overnight ones stop
84
+ * standing out, which is the only reason any of this is here.
85
+ *
86
+ * The offset comes from the timetable's own midnight markers, computed
87
+ * server-side (Libraries\Routes\Search\Locations::offset) - it is not
88
+ * inferred from "arrival looks earlier than departure", which is wrong
89
+ * for exactly the cross-border journeys where the two ends keep
90
+ * different clocks.
91
+ */
92
+ const offset = data.arrival_offset ?? 0;
93
+
94
+ const days = ((): { departure: string, arrival: string } | null => {
95
+ if (offset <= 0 || !date) {
96
+ return null;
97
+ }
98
+
99
+ const departs = createDate(date);
100
+
101
+ // an invalid or unexpected date string must not print "NaN Invalid" on
102
+ // a result card - showing no day is better than showing a wrong one
103
+ if (Number.isNaN(departs.getTime())) {
104
+ return null;
105
+ }
106
+
107
+ const arrives = new Date(departs);
108
+ arrives.setUTCDate(arrives.getUTCDate() + offset);
109
+
110
+ const format = (value: Date): string => {
111
+ const parts = getDayParts(value, t as any);
112
+
113
+ return `${ parts.day } ${ parts.month }`;
114
+ };
115
+
116
+ return { departure: format(departs), arrival: format(arrives) };
117
+ })();
118
+
46
119
  return (
47
120
  <Container className="box" $type={ type }>
48
121
  { data.price.offer === true && <SpecialOffer>{ t('routes_order.step2.route.offer') }</SpecialOffer> }
49
122
  { type === 'favorite' && <FavoriteStop>{ t('routes_order.step2.route.preferred_stop') }</FavoriteStop> }
50
123
 
124
+ { leaves !== null && (
125
+ <Leaves $soon={ soon }>
126
+ { leaves < 60
127
+ ? t('routes_order.step2.route.leaves_minutes', { minutes: leaves })
128
+ : t('routes_order.step2.route.leaves_hours', { hours: Math.floor(leaves / 60), minutes: leaves % 60 }) }
129
+ </Leaves>
130
+ ) }
131
+
51
132
  <Trip>
52
133
  <Company>
53
- <LinkCompany to={ bookOnUrl ?? data.operator.link } target="_blank" rel="noopener noreferrer" title={ data.operator.company }><img src={ data.operator.logo_url || LOGO_FALLBACK } onError={ (e) => { e.currentTarget.onerror = null; e.currentTarget.src = LOGO_FALLBACK; } } alt={ data.operator.company } /></LinkCompany>
134
+ <LinkCompany to={ bookOnUrl ?? data.operator.link } target="_blank" rel="noopener noreferrer" title={ data.operator.display_name }><img src={ data.operator.logo_url || LOGO_FALLBACK } onError={ (e) => { e.currentTarget.onerror = null; e.currentTarget.src = LOGO_FALLBACK; } } alt={ data.operator.display_name } /></LinkCompany>
135
+
136
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
137
+ The OPERATOR's rating rather than this route's: a single route
138
+ rarely has enough published reviews to clear the display
139
+ threshold, and a card with a rating beats one without. Renders
140
+ nothing when there is no rating yet, which is why a brand-new
141
+ operator does not look like a badly rated one. */ }
142
+ <Rating rating={ data.operator.rating } size="small" t={ t } />
143
+
144
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
145
+ The route code, moved up out of the collapsed details. It is an
146
+ identifier of the same kind as the logo above it, and moving it
147
+ here frees a slot in the details row for things that actually
148
+ need explaining - customs on a cross-border run, for one. */ }
149
+ <CodeCompany to={ bookOnUrl ?? data.link } target="_blank" rel="noopener noreferrer" title={ t('routes_order.step2.route.name') }>
150
+ <MdOutlineAltRoute />
151
+ { data.code }
152
+ </CodeCompany>
54
153
  </Company>
55
154
 
56
155
  <Location>
57
- <h2>{ data.locations.from.city.name } ({ data.locations.from.city.country?.iso_code }) { data.locations.from.departure }</h2>
58
- <span>{ data.locations.from.stop?.name }</span>
156
+ <h2>
157
+ { data.locations.from.city.name }
158
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
159
+ REAL SPACES, and they are load-bearing. JSX drops the
160
+ whitespace around a newline, so city, country and time were
161
+ concatenated into one unbreakable token - "Thessaloniki(GRC)
162
+ 06:00" measured 215px inside a 165px column and painted 50px
163
+ into the price beside it. Nothing about the widths could fix
164
+ that on its own: with nowhere to wrap, text will always
165
+ overflow eventually. */ }
166
+ { ' ' }
167
+ <Iso>({ data.locations.from.city.country?.iso_code })</Iso>
168
+ { ' ' }
169
+ { data.locations.from.departure }
170
+ </h2>
171
+
172
+ { days && <Day>{ days.departure }</Day> }
173
+
174
+ <Stop>{ data.locations.from.stop?.name }</Stop>
59
175
  </Location>
60
176
 
61
177
  <Time>
62
178
  <HiOutlineArrowNarrowRight />
63
- <span>{ data.duration }</span>
179
+
180
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
181
+ The clock is the point: this number sits between two real
182
+ departure times, and "08:30" gave nothing to distinguish a
183
+ trip length from a time of day. */ }
184
+ <span><BiHourglass />{ formatDuration(data.duration, t) }</span>
64
185
  </Time>
65
186
 
66
187
  <Location>
67
- <h2>{ data.locations.to.city.name } ({ data.locations.to.city.country?.iso_code }) { data.locations.to.arrival }</h2>
68
- <span>{ data.locations.to.stop?.name }</span>
188
+ <h2>
189
+ { data.locations.to.city.name }
190
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-03
191
+ REAL SPACES, and they are load-bearing. JSX drops the
192
+ whitespace around a newline, so city, country and time were
193
+ concatenated into one unbreakable token - "Thessaloniki(GRC)
194
+ 06:00" measured 215px inside a 165px column and painted 50px
195
+ into the price beside it. Nothing about the widths could fix
196
+ that on its own: with nowhere to wrap, text will always
197
+ overflow eventually. */ }
198
+ { ' ' }
199
+ <Iso>({ data.locations.to.city.country?.iso_code })</Iso>
200
+ { ' ' }
201
+ { data.locations.to.arrival }
202
+ { offset > 0 && (
203
+ <NextDay title={ t('routes_order.step2.route.next_day', { count: offset }) }>
204
+ +{ offset }
205
+ </NextDay>
206
+ ) }
207
+ </h2>
208
+
209
+ { days && <Day>{ days.arrival }</Day> }
210
+
211
+ <Stop>{ data.locations.to.stop?.name }</Stop>
69
212
  </Location>
70
213
 
71
214
  <Price>
72
- <strong>{ data.price.display }</strong>
215
+ <Amount>
216
+ { price.whole }
217
+ { price.decimals !== null && <Decimals>{ price.decimals }</Decimals> }
218
+ { price.currency !== null && <Currency>{ price.currency }</Currency> }
219
+ </Amount>
73
220
 
74
221
  { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
75
222
  obtapi's price.value is the total for the WHOLE party
@@ -93,7 +240,7 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
93
240
  text={
94
241
  <>
95
242
  <RiBus2Line />
96
- { isExternalRedirect ? t('routes_order.step2.route.book_on', { provider: data.operator.company }) : t('routes_order.step2.route.select') }
243
+ { isExternalRedirect ? t('routes_order.step2.route.book_on', { provider: data.operator.display_name }) : t('routes_order.step2.route.select') }
97
244
  </>
98
245
  }
99
246
  onClick={ () => (isExternalRedirect && bookOnUrl ? window.open(bookOnUrl, '_blank', 'noopener,noreferrer') : onSave(data)) }
@@ -103,11 +250,30 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
103
250
 
104
251
  { children }
105
252
 
106
- <Details>
107
- <Detail>
108
- <TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
109
- <LinkRoute to={ bookOnUrl ?? data.link } target="_blank" rel="noopener noreferrer">{ data.code }</LinkRoute>
110
- </Detail>
253
+ <ButtonDetails
254
+ type="button"
255
+ aria-expanded={ details }
256
+ onClick={ () => setDetails(!details) }
257
+ >
258
+ { details ? <HiChevronUp /> : <HiChevronDown /> }
259
+ { t('routes_order.step2.route.details') }
260
+ </ButtonDetails>
261
+
262
+ <Details $open={ details }>
263
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
264
+ Amenities were a separate full-width block under the card.
265
+ They are the same kind of thing as route, transit and policies
266
+ - supporting detail - so they join that row as the first
267
+ column, which is one fewer band of vertical space per result. */ }
268
+ { data.trip.features_data.length > 0 && (
269
+ <Detail>
270
+ <TitleDetail>{ t('routes_order.step2.route.features') }</TitleDetail>
271
+
272
+ <FeaturesItems>
273
+ { features }
274
+ </FeaturesItems>
275
+ </Detail>
276
+ ) }
111
277
 
112
278
  { customs && (
113
279
  <Detail>
@@ -125,27 +291,17 @@ const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Elem
125
291
 
126
292
  { !data.external && (
127
293
  <Detail>
128
- <TitleDetail>{ t('routes_order.step2.route.policies.title') }</TitleDetail>
294
+ <TitleDetail>{ t('routes_order.step2.route.information.title') }</TitleDetail>
129
295
 
130
- <ButtonPolicy type="button" onClick={ () => setPolicy(true) }>
296
+ <ButtonPolicy type="button" onClick={ () => setInformation(true) }>
131
297
  <HiOutlineDocumentText />
132
- { t('routes_order.step2.route.policies.view') }
298
+ { t('routes_order.step2.route.information.view') }
133
299
  </ButtonPolicy>
134
300
  </Detail>
135
301
  ) }
136
302
  </Details>
137
303
 
138
- { data.trip.features_data.length > 0 && (
139
- <Features>
140
- <TitleFeatures>{ t('routes_order.step2.route.features') }</TitleFeatures>
141
-
142
- <FeaturesItems>
143
- { features }
144
- </FeaturesItems>
145
- </Features>
146
- ) }
147
-
148
- { policy && typeof data.id === 'number' && <Policy id={ data.id } t={ t } onClose={ () => setPolicy(false) } /> }
304
+ { information && typeof data.id === 'number' && <Information id={ data.id } t={ t } onClose={ () => setInformation(false) } /> }
149
305
  </Container>
150
306
  );
151
307
  };