@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.
- package/CHANGELOG.md +731 -0
- package/Checkout/Checkout.tsx +519 -0
- package/Checkout/Sidebar/Sidebar.tsx +124 -0
- package/Checkout/Sidebar/styles.ts +52 -0
- package/Facts/Facts.tsx +271 -0
- package/Facts/questions.ts +250 -0
- package/Facts/service.ts +111 -0
- package/Facts/services.ts +19 -0
- package/Facts/styles.ts +112 -0
- package/Facts/types.ts +167 -0
- package/Faq/Faq.tsx +52 -0
- package/Faq/styles.ts +68 -0
- package/Found/Dates/Dates.tsx +95 -5
- package/Found/Dates/styles.ts +173 -9
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +207 -17
- package/Found/Group/Group.tsx +68 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +89 -27
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +75 -0
- package/Found/Picks/styles.ts +106 -0
- package/Found/Route/Route.tsx +211 -33
- package/Found/Route/styles.ts +491 -46
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +87 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +652 -0
- package/Links/Links.tsx +91 -0
- package/Links/styles.ts +75 -0
- package/RoutesOrder.tsx +239 -31
- package/Schedule/Schedule.tsx +123 -0
- package/Schedule/styles.ts +71 -0
- package/Sections/Schema.tsx +186 -0
- package/Sections/Sections.tsx +160 -0
- package/Sections/styles.ts +44 -0
- package/Step1/prepare.ts +21 -2
- package/Step2.tsx +34 -1
- package/Step4/Passenger/Passenger.tsx +253 -24
- package/Step4/Passenger/styles.ts +21 -1
- package/Step4/Passengers/Display.tsx +10 -2
- package/Step4/Passengers/Passengers.tsx +53 -4
- package/Step4/Saved/Choose.tsx +99 -0
- package/Step4/Saved/Remember.tsx +58 -0
- package/Step4/Saved/styles.ts +87 -0
- package/Step4/checkAge.ts +15 -4
- package/Step5/Addons/Addons.tsx +146 -4
- package/Step5/Addons/styles.ts +94 -0
- package/Step5/Billing/Billing.tsx +138 -70
- package/Step5/Coupons/Coupons.tsx +28 -18
- package/Step5/Coupons/styles.ts +41 -8
- package/Step5/Payments/Payments.tsx +36 -5
- package/Step5/Payments/styles.ts +16 -7
- package/Step5/Summary/About/About.tsx +99 -27
- package/Step5/Summary/About/styles.ts +118 -1
- package/Step5/Summary/styles.ts +12 -5
- package/Step5/utilities.ts +67 -12
- package/Steps/Steps.tsx +3 -1
- package/commerce.ts +24 -0
- package/package.json +1 -1
- package/services.ts +302 -13
- package/styles.ts +126 -0
- package/types.ts +84 -1
- package/Found/Booking/Booking.tsx +0 -61
- package/Found/Booking/styles.ts +0 -50
- package/Step4/Step4.tsx +0 -114
- package/Step5/Step5.tsx +0 -185
package/Found/Orders/Orders.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
<
|
|
46
|
+
<Empty type={ type } step1={ step1 } data={ alternatives } loading={ alternativesLoading } t={ t } />
|
|
31
47
|
);
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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;
|
package/Found/Orders/styles.ts
CHANGED
|
@@ -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
|
+
`;
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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 [
|
|
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.
|
|
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>
|
|
53
|
-
|
|
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
|
-
|
|
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>
|
|
63
|
-
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
294
|
+
<TitleDetail>{ t('routes_order.step2.route.information.title') }</TitleDetail>
|
|
107
295
|
|
|
108
|
-
<ButtonPolicy type="button" onClick={ () =>
|
|
296
|
+
<ButtonPolicy type="button" onClick={ () => setInformation(true) }>
|
|
109
297
|
<HiOutlineDocumentText />
|
|
110
|
-
{ t('routes_order.step2.route.
|
|
298
|
+
{ t('routes_order.step2.route.information.view') }
|
|
111
299
|
</ButtonPolicy>
|
|
112
300
|
</Detail>
|
|
113
301
|
) }
|
|
114
302
|
</Details>
|
|
115
303
|
|
|
116
|
-
{ data.
|
|
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
|
};
|