@autobusal/routes-order 1.8.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 (76) hide show
  1. package/CHANGELOG.md +731 -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 +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -1,20 +1,33 @@
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
+ import Empty from '../Empty/Empty';
4
6
  import { getFavorites, getNormal } from './utilities';
5
- import { Container, ContainerNotFound } from './styles';
7
+ import { sortItems, groupItems, SortKey } from '../refine';
8
+ import { Container, ContainerNotFound, Reset } from './styles';
6
9
  import { FoundData } from '@autobusal/providers/types/routes';
10
+ import { RoutesSearchForm } from '@autobusal/routes-search/types';
11
+ import { AlternativesData } from '../../types';
7
12
 
8
13
  interface Props {
9
14
  loading: boolean
10
- data?: FoundData[]
15
+ data: FoundData[]
16
+ empty: boolean
17
+ refined: boolean
18
+ sort: SortKey
11
19
  type: 'departure' | '_return'
20
+ passengers: number
12
21
  preferredStop?: number
22
+ step1: RoutesSearchForm
23
+ alternatives?: AlternativesData
24
+ alternativesLoading: boolean
13
25
  t: TFunction<'common'>
26
+ onReset: () => void
14
27
  onSave: (data: FoundData) => void
15
28
  }
16
29
 
17
- const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.Element => {
30
+ const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, step1, alternatives, alternativesLoading, t, onReset, onSave }: Props): JSX.Element => {
18
31
  if (loading) {
19
32
  return (
20
33
  <div className="box">
@@ -23,33 +36,82 @@ const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.E
23
36
  );
24
37
  }
25
38
 
26
- // undefined means the search errored (e.g. same origin and destination) -
27
- // show the not-found message instead of an empty shell
28
- if (data === undefined || data.length === 0) {
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.
44
+ if (empty) {
29
45
  return (
30
- <ContainerNotFound className="box">{ t('routes_order.step2.not_found') }</ContainerNotFound>
46
+ <Empty type={ type } step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
31
47
  );
32
48
  }
33
49
 
34
- const favorites = getFavorites(type, data, preferredStop).map(item => (
35
- <Route
36
- key={ item.id }
37
- type="favorite"
38
- data={ item }
39
- t={ t }
40
- onSave={ onSave }
41
- />
42
- ));
43
-
44
- const items = getNormal(type, data, preferredStop).map(item => (
45
- <Route
46
- key={ item.id }
47
- type="normal"
48
- data={ item }
49
- t={ t }
50
- onSave={ onSave }
51
- />
52
- ));
50
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
51
+ // Results exist but the user's own filters hid all of them. Saying
52
+ // "we couldn't find any tickets" there would be a lie that sends people
53
+ // away from a page that does have inventory - offer the way out instead.
54
+ if (data.length === 0 && refined) {
55
+ return (
56
+ <ContainerNotFound className="box">
57
+ { t('routes_order.step2.filters.empty') }
58
+
59
+ <Reset type="button" onClick={ onReset }>{ t('routes_order.step2.filters.reset') }</Reset>
60
+ </ContainerNotFound>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
66
+ *
67
+ * Preferred-stop pinning stays the OUTER partition and each side is
68
+ * sorted within itself. Sorting the whole list first would have let
69
+ * "cheapest" quietly un-pin a stop the user deliberately saved.
70
+ *
71
+ * Grouping runs AFTER sorting: groupItems keeps input order, so a
72
+ * collapsed set of shuttles lands wherever its best member sorted to.
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
+
84
+ 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
+ )
109
+ ))
110
+ );
111
+
112
+ const favorites = render(getFavorites(type, data, preferredStop), 'favorite');
113
+
114
+ const items = render(getNormal(type, data, preferredStop), 'normal');
53
115
 
54
116
  return (
55
117
  <Container>
@@ -59,4 +121,4 @@ const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.E
59
121
  );
60
122
  };
61
123
 
62
- export default Orders;
124
+ export default Orders;
@@ -7,6 +7,20 @@ export const Container = styled.div`
7
7
  `;
8
8
 
9
9
  export const ContainerNotFound = styled.div`
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: center;
13
+ gap: 12px;
10
14
  text-align: center;
11
15
  color: ${ props => props.theme.font.faded };
16
+ `;
17
+
18
+ export const Reset = styled.button`
19
+ font-weight: 700;
20
+ color: ${ props => props.theme.primary.normal };
21
+ transition: all 0.2s ease;
22
+
23
+ &:hover {
24
+ opacity: .8;
25
+ }
12
26
  `;
@@ -0,0 +1,75 @@
1
+ import { TFunction } from 'i18next';
2
+ import { Container, Pick, Label, Value, Meta, Company } from './styles';
3
+ import { SortKey, sortItems } from '../refine';
4
+ import { FoundData } from '@autobusal/providers/types/routes';
5
+
6
+ interface Props {
7
+ items: FoundData[]
8
+ sort: SortKey
9
+ t: TFunction<'common'>
10
+ onSelect: (sort: SortKey) => void
11
+ }
12
+
13
+ const OFFERED: SortKey[] = [ 'cheapest', 'fastest', 'recommended' ];
14
+
15
+ /**
16
+ * Three cards naming the best trip on each axis.
17
+ *
18
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
19
+ *
20
+ * They answer "what are my options here" before anyone reads a single row,
21
+ * and clicking one applies that ordering to the list below - so the card is
22
+ * both an answer and a control.
23
+ *
24
+ * Computed from the FILTERED list, so a pick never advertises a trip the
25
+ * user's own filters have hidden.
26
+ *
27
+ * "Recommended" is merit-first - price rank plus duration rank, with paid
28
+ * subscription priority only breaking ties. See `recommend` in refine.ts.
29
+ */
30
+ const Picks = ({ items, sort, t, onSelect }: Props): JSX.Element | null => {
31
+ // below a handful of results the cards just restate the list underneath
32
+ if (items.length < 3) {
33
+ return null;
34
+ }
35
+
36
+ return (
37
+ <Container>
38
+ { OFFERED.map(key => {
39
+ const best = sortItems(items, key)[0];
40
+
41
+ if (best === undefined) {
42
+ return null;
43
+ }
44
+
45
+ return (
46
+ <Pick
47
+ key={ key }
48
+ type="button"
49
+ $active={ sort === key }
50
+ aria-pressed={ sort === key }
51
+ onClick={ () => onSelect(key) }
52
+ >
53
+ <Label>{ t(`routes_order.step2.sort.${ key }`) }</Label>
54
+
55
+ <Value>{ best.price.display }</Value>
56
+
57
+ <Meta>
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>
67
+ </Meta>
68
+ </Pick>
69
+ );
70
+ }) }
71
+ </Container>
72
+ );
73
+ };
74
+
75
+ export default Picks;
@@ -0,0 +1,106 @@
1
+ import styled, { css } from 'styled-components';
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
+ */
12
+ export const Container = styled.div`
13
+ display: grid;
14
+ gap: 6px;
15
+ margin-bottom: 15px;
16
+ grid-template-columns: repeat(3, 1fr);
17
+
18
+ @media (min-width: 640px) {
19
+ gap: 10px;
20
+ }
21
+ `;
22
+
23
+ export const Pick = styled.button<{ $active: boolean }>`
24
+ display: flex;
25
+ flex-direction: column;
26
+ align-items: flex-start;
27
+ gap: 2px;
28
+ padding: 8px 9px;
29
+ text-align: left;
30
+ min-width: 0;
31
+
32
+ @media (min-width: 640px) {
33
+ gap: 4px;
34
+ padding: 12px 15px;
35
+ }
36
+ border-radius: ${ props => props.theme.borderRadius };
37
+ border: 1px solid ${ props => props.theme.inputs.border };
38
+ background: ${ props => props.theme.background.normal };
39
+ transition: all 0.2s ease;
40
+
41
+ &:hover {
42
+ border-color: ${ props => props.theme.primary.normal };
43
+ }
44
+
45
+ ${ props => props.$active && css`
46
+ border-color: ${ props => props.theme.primary.normal };
47
+ box-shadow: inset 0 0 0 1px ${ props => props.theme.primary.normal };
48
+ ` }
49
+ `;
50
+
51
+ export const Label = styled.span`
52
+ max-width: 100%;
53
+ overflow: hidden;
54
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
55
+ text-transform: uppercase;
56
+ letter-spacing: 0;
57
+ font-weight: 700;
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
+ }
66
+ `;
67
+
68
+ export const Value = styled.strong`
69
+ font-size: ${ props => props.theme.size.m };
70
+
71
+ @media (min-width: 640px) {
72
+ font-size: ${ props => props.theme.size.l };
73
+ }
74
+ `;
75
+
76
+ export const Meta = styled.span`
77
+ overflow: hidden;
78
+ max-width: 100%;
79
+ font-size: calc(${ props => props.theme.size.xs } - 1px);
80
+ color: ${ props => props.theme.font.faded };
81
+ text-overflow: ellipsis;
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
+ }
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, 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,18 +17,38 @@ 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
25
+ passengers: number
16
26
  t: TFunction<'common'>
17
27
  onSave: (data: FoundData) => void
28
+ // Edited: Ferjolt Ozuni - Date: 2026-08-01
29
+ // Slot under the trip row, used by Group to hang a departure-time picker
30
+ // on a card that stands for several interchangeable departures.
31
+ children?: React.ReactNode
18
32
  }
19
33
 
20
- const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
21
- 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);
22
44
 
23
45
  const customs = Object.values(data.customs).join(', ');
24
46
  const transiting = Object.values(data.transiting).join(', ');
25
47
 
48
+ const price = splitPrice(data.price.display);
49
+
26
50
  const features = data.trip.features_data.map((item, index) => (
27
- <RouteFeature key={ index } item={ item } />
51
+ <RouteFeature key={ index } item={ item } compact />
28
52
  ));
29
53
 
30
54
  // Edited: Ferjolt Ozuni - Date: 2026-07-27
@@ -38,33 +62,176 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
38
62
  ? `${ import.meta.env.VITE_API_OBT }/api/external/go/${ data.external.offer_id }`
39
63
  : undefined;
40
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
+
41
119
  return (
42
120
  <Container className="box" $type={ type }>
43
121
  { data.price.offer === true && <SpecialOffer>{ t('routes_order.step2.route.offer') }</SpecialOffer> }
44
122
  { type === 'favorite' && <FavoriteStop>{ t('routes_order.step2.route.preferred_stop') }</FavoriteStop> }
45
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
+
46
132
  <Trip>
47
133
  <Company>
48
- <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>
49
153
  </Company>
50
154
 
51
155
  <Location>
52
- <h2>{ data.locations.from.city.name } ({ data.locations.from.city.country?.iso_code }) { data.locations.from.departure }</h2>
53
- <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>
54
175
  </Location>
55
176
 
56
177
  <Time>
57
178
  <HiOutlineArrowNarrowRight />
58
- <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>
59
185
  </Time>
60
186
 
61
187
  <Location>
62
- <h2>{ data.locations.to.city.name } ({ data.locations.to.city.country?.iso_code }) { data.locations.to.arrival }</h2>
63
- <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>
64
212
  </Location>
65
213
 
66
214
  <Price>
67
- <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>
220
+
221
+ { /* Edited: Ferjolt Ozuni - Date: 2026-08-01
222
+ obtapi's price.value is the total for the WHOLE party
223
+ (adult * adults + child * children + baby * babies), not a
224
+ per-person fare - so this says how many people it covers
225
+ rather than the "per adult" line the competitors use, which
226
+ would be wrong here for any multi-passenger search. */ }
227
+ <PriceNotice>
228
+ { t(
229
+ passengers === 1
230
+ ? 'routes_order.step2.route.price_notice_single'
231
+ : 'routes_order.step2.route.price_notice_multiple',
232
+ { total: passengers }
233
+ ) }
234
+ </PriceNotice>
68
235
 
69
236
  <Button
70
237
  type="button"
@@ -73,7 +240,7 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
73
240
  text={
74
241
  <>
75
242
  <RiBus2Line />
76
- { 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') }
77
244
  </>
78
245
  }
79
246
  onClick={ () => (isExternalRedirect && bookOnUrl ? window.open(bookOnUrl, '_blank', 'noopener,noreferrer') : onSave(data)) }
@@ -81,11 +248,32 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
81
248
  </Price>
82
249
  </Trip>
83
250
 
84
- <Details>
85
- <Detail>
86
- <TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
87
- <LinkRoute to={ bookOnUrl ?? data.link } target="_blank" rel="noopener noreferrer">{ data.code }</LinkRoute>
88
- </Detail>
251
+ { children }
252
+
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
+ ) }
89
277
 
90
278
  { customs && (
91
279
  <Detail>
@@ -103,27 +291,17 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
103
291
 
104
292
  { !data.external && (
105
293
  <Detail>
106
- <TitleDetail>{ t('routes_order.step2.route.policies.title') }</TitleDetail>
294
+ <TitleDetail>{ t('routes_order.step2.route.information.title') }</TitleDetail>
107
295
 
108
- <ButtonPolicy type="button" onClick={ () => setPolicy(true) }>
296
+ <ButtonPolicy type="button" onClick={ () => setInformation(true) }>
109
297
  <HiOutlineDocumentText />
110
- { t('routes_order.step2.route.policies.view') }
298
+ { t('routes_order.step2.route.information.view') }
111
299
  </ButtonPolicy>
112
300
  </Detail>
113
301
  ) }
114
302
  </Details>
115
303
 
116
- { data.trip.features_data.length > 0 && (
117
- <Features>
118
- <TitleFeatures>{ t('routes_order.step2.route.features') }</TitleFeatures>
119
-
120
- <FeaturesItems>
121
- { features }
122
- </FeaturesItems>
123
- </Features>
124
- ) }
125
-
126
- { 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) } /> }
127
305
  </Container>
128
306
  );
129
307
  };