@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.
- package/CHANGELOG.md +673 -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 +59 -5
- package/Found/Dates/styles.ts +137 -7
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +10 -7
- package/Found/Filters/styles.ts +79 -8
- package/Found/Found.tsx +128 -29
- package/Found/Group/Group.tsx +3 -1
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +25 -4
- package/Found/Picks/Picks.tsx +10 -3
- package/Found/Picks/styles.ts +60 -9
- package/Found/Route/Route.tsx +189 -33
- package/Found/Route/styles.ts +488 -50
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/styles.ts +52 -7
- package/Found/Summary/Summary.tsx +1 -1
- package/Found/Summary/styles.ts +12 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +69 -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 +295 -12
- package/styles.ts +126 -0
- package/types.ts +78 -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/Header/styles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
51
|
+
flex: 0 0 120px;
|
|
36
52
|
`;
|
|
37
53
|
|
|
38
54
|
export const Price = styled(Item)`
|
|
39
|
-
flex
|
|
55
|
+
flex: 0 0 165px;
|
|
40
56
|
`;
|
package/Found/Orders/Orders.tsx
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
<
|
|
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 }
|
package/Found/Picks/Picks.tsx
CHANGED
|
@@ -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 !== '-' &&
|
|
59
|
-
|
|
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
|
);
|
package/Found/Picks/styles.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
19
|
-
padding:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
`;
|
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,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 [
|
|
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.
|
|
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>
|
|
58
|
-
|
|
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
|
-
|
|
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>
|
|
68
|
-
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
294
|
+
<TitleDetail>{ t('routes_order.step2.route.information.title') }</TitleDetail>
|
|
129
295
|
|
|
130
|
-
<ButtonPolicy type="button" onClick={ () =>
|
|
296
|
+
<ButtonPolicy type="button" onClick={ () => setInformation(true) }>
|
|
131
297
|
<HiOutlineDocumentText />
|
|
132
|
-
{ t('routes_order.step2.route.
|
|
298
|
+
{ t('routes_order.step2.route.information.view') }
|
|
133
299
|
</ButtonPolicy>
|
|
134
300
|
</Detail>
|
|
135
301
|
) }
|
|
136
302
|
</Details>
|
|
137
303
|
|
|
138
|
-
{ data.
|
|
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
|
};
|