@autobusal/routes-order 1.8.0 → 1.9.3
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 +112 -0
- package/Found/Dates/Dates.tsx +38 -2
- package/Found/Dates/styles.ts +37 -3
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +116 -16
- package/Found/Group/Group.tsx +66 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Orders/Orders.tsx +67 -26
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +68 -0
- package/Found/Picks/styles.ts +55 -0
- package/Found/Route/Route.tsx +46 -7
- package/Found/Route/styles.ts +55 -8
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +42 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/refine.ts +626 -0
- package/package.json +1 -1
- package/services.ts +7 -1
- package/styles.ts +36 -0
- package/types.ts +6 -0
package/Found/Found.tsx
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
1
3
|
import { TFunction } from 'i18next';
|
|
2
4
|
import Dates from './Dates/Dates';
|
|
3
5
|
import Booking from './Booking/Booking';
|
|
4
6
|
import Header from './Header/Header';
|
|
5
7
|
import Orders from './Orders/Orders';
|
|
8
|
+
import Sort from './Sort/Sort';
|
|
9
|
+
import Picks from './Picks/Picks';
|
|
10
|
+
import Filters from './Filters/Filters';
|
|
11
|
+
import Summary from './Summary/Summary';
|
|
12
|
+
import { Results, Sidebar, Listing } from '../styles';
|
|
6
13
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
7
14
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
15
|
+
import { Refinement, BucketKey, fromParams, toParams, filterItems, buildFacets, priceRange, currencyOf, isRefined } from './refine';
|
|
8
16
|
|
|
9
17
|
interface Props {
|
|
10
18
|
type: 'departure' | '_return'
|
|
@@ -17,23 +25,115 @@ interface Props {
|
|
|
17
25
|
onSave: (data: FoundData) => void
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
// every query parameter this component owns, so writing a new refinement
|
|
29
|
+
// clears the previous one without touching anything else on the URL
|
|
30
|
+
const OWNED = [ 'sort', 'operators', 'times', 'direct', 'from_stops', 'to_stops', 'features', 'max_price' ];
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave }: Props): JSX.Element => {
|
|
33
|
+
const [ searchParams, setSearchParams ] = useSearchParams();
|
|
34
|
+
const [ open, setOpen ] = useState<boolean>(false);
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
38
|
+
*
|
|
39
|
+
* The refinement lives in the URL, not in component state, so a filtered
|
|
40
|
+
* result set survives a reload and can be sent to somebody else - which is
|
|
41
|
+
* how people actually share a trip they found.
|
|
42
|
+
*/
|
|
43
|
+
const refinement = useMemo(() => fromParams(searchParams), [ searchParams ]);
|
|
27
44
|
|
|
28
|
-
|
|
29
|
-
loading={ loading }
|
|
30
|
-
data={ data }
|
|
31
|
-
type={ type }
|
|
32
|
-
preferredStop={ Number(preferredStop) }
|
|
33
|
-
t={ t }
|
|
34
|
-
onSave={ onSave }
|
|
35
|
-
/>
|
|
36
|
-
</>
|
|
37
|
-
);
|
|
45
|
+
const items = useMemo(() => data ?? [], [ data ]);
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
const bucketLabel = (key: BucketKey): string => t(`routes_order.step2.filters.buckets.${ key }`);
|
|
48
|
+
|
|
49
|
+
const facets = useMemo(() => buildFacets(items, refinement, bucketLabel), [ items, refinement, t ]);
|
|
50
|
+
|
|
51
|
+
const refined = useMemo(() => filterItems(items, refinement), [ items, refinement ]);
|
|
52
|
+
|
|
53
|
+
const range = useMemo(() => priceRange(refined.length > 0 ? refined : items), [ refined, items ]);
|
|
54
|
+
|
|
55
|
+
// the slider's bounds must come from the WHOLE result set, not the
|
|
56
|
+
// filtered one - bounds that shrink as you drag make it impossible to
|
|
57
|
+
// widen the range again
|
|
58
|
+
const bounds = useMemo(() => priceRange(items), [ items ]);
|
|
59
|
+
|
|
60
|
+
const currency = useMemo(() => currencyOf(items), [ items ]);
|
|
61
|
+
|
|
62
|
+
const onRefine = (next: Refinement): void => {
|
|
63
|
+
const params = new URLSearchParams(searchParams);
|
|
64
|
+
|
|
65
|
+
OWNED.forEach(name => params.delete(name));
|
|
66
|
+
|
|
67
|
+
Object.entries(toParams(next)).forEach(([ name, value ]) => params.set(name, value));
|
|
68
|
+
|
|
69
|
+
// `replace` so a back press returns to the search form rather than
|
|
70
|
+
// walking back through every checkbox the user just ticked
|
|
71
|
+
setSearchParams(params, { replace: true });
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const passengers = Number(step1.adults ?? 0) + Number(step1.children ?? 0) + Number(step1.babies ?? 0);
|
|
75
|
+
|
|
76
|
+
// controls are pointless while there is nothing (or only one thing) to
|
|
77
|
+
// refine, and they'd only add noise above an empty state
|
|
78
|
+
const showControls = !loading && items.length > 1;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<Dates type={ type } step1={ step1 } t={ t } onSearch={ onSearch } />
|
|
83
|
+
|
|
84
|
+
{ (data !== undefined && data.length > 0) && <Booking data={ data } step1={ step1 } t={ t } /> }
|
|
85
|
+
|
|
86
|
+
{ showControls && (
|
|
87
|
+
<>
|
|
88
|
+
<Summary shown={ refined.length } total={ items.length } range={ range } t={ t }>
|
|
89
|
+
<Sort value={ refinement.sort } t={ t } onChange={ sort => onRefine({ ...refinement, sort }) } />
|
|
90
|
+
</Summary>
|
|
91
|
+
|
|
92
|
+
<Picks
|
|
93
|
+
items={ refined }
|
|
94
|
+
sort={ refinement.sort }
|
|
95
|
+
t={ t }
|
|
96
|
+
onSelect={ sort => onRefine({ ...refinement, sort }) }
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
) }
|
|
100
|
+
|
|
101
|
+
<Results>
|
|
102
|
+
{ showControls && (
|
|
103
|
+
<Sidebar>
|
|
104
|
+
<Filters
|
|
105
|
+
open={ open }
|
|
106
|
+
refinement={ refinement }
|
|
107
|
+
facets={ facets }
|
|
108
|
+
range={ bounds }
|
|
109
|
+
currency={ currency }
|
|
110
|
+
t={ t }
|
|
111
|
+
onToggle={ () => setOpen(!open) }
|
|
112
|
+
onChange={ onRefine }
|
|
113
|
+
/>
|
|
114
|
+
</Sidebar>
|
|
115
|
+
) }
|
|
116
|
+
|
|
117
|
+
<Listing>
|
|
118
|
+
<Header t={ t } />
|
|
119
|
+
|
|
120
|
+
<Orders
|
|
121
|
+
loading={ loading }
|
|
122
|
+
data={ refined }
|
|
123
|
+
empty={ items.length === 0 }
|
|
124
|
+
refined={ isRefined(refinement) }
|
|
125
|
+
sort={ refinement.sort }
|
|
126
|
+
type={ type }
|
|
127
|
+
passengers={ passengers }
|
|
128
|
+
preferredStop={ Number(preferredStop) }
|
|
129
|
+
t={ t }
|
|
130
|
+
onReset={ () => onRefine({ ...refinement, ...{ operators: [], buckets: [], direct: false, fromStops: [], toStops: [], features: [], maxPrice: null } }) }
|
|
131
|
+
onSave={ onSave }
|
|
132
|
+
/>
|
|
133
|
+
</Listing>
|
|
134
|
+
</Results>
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export default Found;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import Route from '../Route/Route';
|
|
4
|
+
import { departureOf } from '../refine';
|
|
5
|
+
import { Container, Title, Times, Time } from './styles';
|
|
6
|
+
import { FoundData } from '@autobusal/providers/types/routes';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
items: FoundData[]
|
|
10
|
+
type: 'favorite' | 'normal'
|
|
11
|
+
passengers: number
|
|
12
|
+
t: TFunction<'common'>
|
|
13
|
+
onSave: (data: FoundData) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One card for a set of interchangeable departures.
|
|
18
|
+
*
|
|
19
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
20
|
+
*
|
|
21
|
+
* The card shows a real departure - never an abstract summary - and the
|
|
22
|
+
* other times sit under it as a picker. Whatever is selected is what gets
|
|
23
|
+
* booked, so there is no separate "now choose a time" step to forget: the
|
|
24
|
+
* card always represents a bookable trip, starting with the earliest.
|
|
25
|
+
*/
|
|
26
|
+
const Group = ({ items, type, passengers, t, onSave }: Props): JSX.Element => {
|
|
27
|
+
// chronological for the picker, regardless of how the list itself is
|
|
28
|
+
// sorted - a row of times out of time order is just confusing
|
|
29
|
+
const departures = useMemo(() => (
|
|
30
|
+
[ ...items ].sort((first, second) => departureOf(first) - departureOf(second))
|
|
31
|
+
), [ items ]);
|
|
32
|
+
|
|
33
|
+
const [ selected, setSelected ] = useState<number>(0);
|
|
34
|
+
|
|
35
|
+
const current = departures[selected] ?? departures[0];
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Route
|
|
39
|
+
type={ type }
|
|
40
|
+
data={ current }
|
|
41
|
+
passengers={ passengers }
|
|
42
|
+
t={ t }
|
|
43
|
+
onSave={ onSave }
|
|
44
|
+
>
|
|
45
|
+
<Container>
|
|
46
|
+
<Title>{ t('routes_order.step2.route.departures', { total: departures.length }) }</Title>
|
|
47
|
+
|
|
48
|
+
<Times>
|
|
49
|
+
{ departures.map((item, index) => (
|
|
50
|
+
<Time
|
|
51
|
+
key={ item.id }
|
|
52
|
+
type="button"
|
|
53
|
+
$active={ index === selected }
|
|
54
|
+
aria-pressed={ index === selected }
|
|
55
|
+
onClick={ () => setSelected(index) }
|
|
56
|
+
>
|
|
57
|
+
{ item.locations.from.departure }
|
|
58
|
+
</Time>
|
|
59
|
+
)) }
|
|
60
|
+
</Times>
|
|
61
|
+
</Container>
|
|
62
|
+
</Route>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default Group;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: 8px;
|
|
7
|
+
padding: 12px;
|
|
8
|
+
background: ${ props => props.theme.background.neutral };
|
|
9
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
export const Title = styled.h6`
|
|
13
|
+
margin-bottom: 0;
|
|
14
|
+
font-size: ${ props => props.theme.size.xs };
|
|
15
|
+
text-transform: uppercase;
|
|
16
|
+
color: ${ props => props.theme.font.faded };
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
export const Times = styled.div`
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-wrap: wrap;
|
|
22
|
+
gap: 6px;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const Time = styled.button<{ $active: boolean }>`
|
|
26
|
+
padding: 5px 12px;
|
|
27
|
+
font-size: ${ props => props.theme.size.s };
|
|
28
|
+
font-weight: 700;
|
|
29
|
+
border-radius: 100px;
|
|
30
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
31
|
+
background: ${ props => props.theme.background.normal };
|
|
32
|
+
color: ${ props => props.theme.font.normal };
|
|
33
|
+
transition: all 0.2s ease;
|
|
34
|
+
|
|
35
|
+
&:hover {
|
|
36
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
${ props => props.$active && css`
|
|
40
|
+
color: ${ props => props.theme.primary.contrast };
|
|
41
|
+
background: ${ props => props.theme.primary.normal };
|
|
42
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
43
|
+
` }
|
|
44
|
+
`;
|
package/Found/Orders/Orders.tsx
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
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';
|
|
4
5
|
import { getFavorites, getNormal } from './utilities';
|
|
5
|
-
import {
|
|
6
|
+
import { sortItems, groupItems, SortKey } from '../refine';
|
|
7
|
+
import { Container, ContainerNotFound, Reset } from './styles';
|
|
6
8
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
7
9
|
|
|
8
10
|
interface Props {
|
|
9
11
|
loading: boolean
|
|
10
|
-
data
|
|
12
|
+
data: FoundData[]
|
|
13
|
+
empty: boolean
|
|
14
|
+
refined: boolean
|
|
15
|
+
sort: SortKey
|
|
11
16
|
type: 'departure' | '_return'
|
|
17
|
+
passengers: number
|
|
12
18
|
preferredStop?: number
|
|
13
19
|
t: TFunction<'common'>
|
|
20
|
+
onReset: () => void
|
|
14
21
|
onSave: (data: FoundData) => void
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.Element => {
|
|
24
|
+
const Orders = ({ loading, data, empty, refined, sort, type, passengers, preferredStop, t, onReset, onSave }: Props): JSX.Element => {
|
|
18
25
|
if (loading) {
|
|
19
26
|
return (
|
|
20
27
|
<div className="box">
|
|
@@ -23,33 +30,67 @@ const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.E
|
|
|
23
30
|
);
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
//
|
|
27
|
-
// show the not-found message
|
|
28
|
-
if (
|
|
33
|
+
// no results at all - the search itself found nothing (or errored, e.g.
|
|
34
|
+
// same origin and destination) - show the not-found message
|
|
35
|
+
if (empty) {
|
|
29
36
|
return (
|
|
30
37
|
<ContainerNotFound className="box">{ t('routes_order.step2.not_found') }</ContainerNotFound>
|
|
31
38
|
);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
42
|
+
// Results exist but the user's own filters hid all of them. Saying
|
|
43
|
+
// "we couldn't find any tickets" there would be a lie that sends people
|
|
44
|
+
// away from a page that does have inventory - offer the way out instead.
|
|
45
|
+
if (data.length === 0 && refined) {
|
|
46
|
+
return (
|
|
47
|
+
<ContainerNotFound className="box">
|
|
48
|
+
{ t('routes_order.step2.filters.empty') }
|
|
49
|
+
|
|
50
|
+
<Reset type="button" onClick={ onReset }>{ t('routes_order.step2.filters.reset') }</Reset>
|
|
51
|
+
</ContainerNotFound>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
57
|
+
*
|
|
58
|
+
* Preferred-stop pinning stays the OUTER partition and each side is
|
|
59
|
+
* sorted within itself. Sorting the whole list first would have let
|
|
60
|
+
* "cheapest" quietly un-pin a stop the user deliberately saved.
|
|
61
|
+
*
|
|
62
|
+
* Grouping runs AFTER sorting: groupItems keeps input order, so a
|
|
63
|
+
* collapsed set of shuttles lands wherever its best member sorted to.
|
|
64
|
+
*/
|
|
65
|
+
const render = (list: FoundData[], kind: 'favorite' | 'normal'): JSX.Element[] => (
|
|
66
|
+
groupItems(sortItems(list, sort)).map(group => (
|
|
67
|
+
group.length > 1
|
|
68
|
+
? (
|
|
69
|
+
<Group
|
|
70
|
+
key={ group[0].id }
|
|
71
|
+
items={ group }
|
|
72
|
+
type={ kind }
|
|
73
|
+
passengers={ passengers }
|
|
74
|
+
t={ t }
|
|
75
|
+
onSave={ onSave }
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
: (
|
|
79
|
+
<Route
|
|
80
|
+
key={ group[0].id }
|
|
81
|
+
type={ kind }
|
|
82
|
+
data={ group[0] }
|
|
83
|
+
passengers={ passengers }
|
|
84
|
+
t={ t }
|
|
85
|
+
onSave={ onSave }
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
88
|
+
))
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const favorites = render(getFavorites(type, data, preferredStop), 'favorite');
|
|
92
|
+
|
|
93
|
+
const items = render(getNormal(type, data, preferredStop), 'normal');
|
|
53
94
|
|
|
54
95
|
return (
|
|
55
96
|
<Container>
|
|
@@ -59,4 +100,4 @@ const Orders = ({ loading, data, type, preferredStop, t, onSave }: Props): JSX.E
|
|
|
59
100
|
);
|
|
60
101
|
};
|
|
61
102
|
|
|
62
|
-
export default Orders;
|
|
103
|
+
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,68 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Pick, Label, Value, Meta } 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
|
+
{ best.operator.company }
|
|
60
|
+
</Meta>
|
|
61
|
+
</Pick>
|
|
62
|
+
);
|
|
63
|
+
}) }
|
|
64
|
+
</Container>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default Picks;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: 10px;
|
|
6
|
+
margin-bottom: 15px;
|
|
7
|
+
grid-template-columns: 1fr;
|
|
8
|
+
|
|
9
|
+
@media (min-width: 640px) {
|
|
10
|
+
grid-template-columns: repeat(3, 1fr);
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
export const Pick = styled.button<{ $active: boolean }>`
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
align-items: flex-start;
|
|
18
|
+
gap: 4px;
|
|
19
|
+
padding: 12px 15px;
|
|
20
|
+
text-align: left;
|
|
21
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
22
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
23
|
+
background: ${ props => props.theme.background.normal };
|
|
24
|
+
transition: all 0.2s ease;
|
|
25
|
+
|
|
26
|
+
&:hover {
|
|
27
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
${ props => props.$active && css`
|
|
31
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
32
|
+
box-shadow: inset 0 0 0 1px ${ props => props.theme.primary.normal };
|
|
33
|
+
` }
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export const Label = styled.span`
|
|
37
|
+
font-size: ${ props => props.theme.size.xs };
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
letter-spacing: .5px;
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
color: ${ props => props.theme.font.faded };
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export const Value = styled.strong`
|
|
45
|
+
font-size: ${ props => props.theme.size.l };
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export const Meta = styled.span`
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
max-width: 100%;
|
|
51
|
+
font-size: ${ props => props.theme.size.xs };
|
|
52
|
+
color: ${ props => props.theme.font.faded };
|
|
53
|
+
text-overflow: ellipsis;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
`;
|
package/Found/Route/Route.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { TFunction } from 'i18next';
|
|
3
3
|
import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } from 'react-icons/hi';
|
|
4
|
+
import { BiHourglass } from 'react-icons/bi';
|
|
4
5
|
import { RiBus2Line } from 'react-icons/ri';
|
|
6
|
+
import { formatDuration } from '../refine';
|
|
5
7
|
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';
|
|
8
|
+
import { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Stop, Iso, Time, Price, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, Features, TitleFeatures, FeaturesItems } from './styles';
|
|
7
9
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
8
10
|
|
|
9
11
|
// neutral placeholder so a missing/broken operator logo doesn't render the
|
|
@@ -13,11 +15,16 @@ const LOGO_FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
|
|
|
13
15
|
interface Props {
|
|
14
16
|
data: FoundData
|
|
15
17
|
type: 'favorite' | 'normal'
|
|
18
|
+
passengers: number
|
|
16
19
|
t: TFunction<'common'>
|
|
17
20
|
onSave: (data: FoundData) => void
|
|
21
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
22
|
+
// Slot under the trip row, used by Group to hang a departure-time picker
|
|
23
|
+
// on a card that stands for several interchangeable departures.
|
|
24
|
+
children?: React.ReactNode
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
27
|
+
const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Element => {
|
|
21
28
|
const [ policy, setPolicy ] = useState<boolean>(false);
|
|
22
29
|
|
|
23
30
|
const customs = Object.values(data.customs).join(', ');
|
|
@@ -49,23 +56,53 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
|
49
56
|
</Company>
|
|
50
57
|
|
|
51
58
|
<Location>
|
|
52
|
-
<h2>
|
|
53
|
-
|
|
59
|
+
<h2>
|
|
60
|
+
{ data.locations.from.city.name }
|
|
61
|
+
<Iso>({ data.locations.from.city.country?.iso_code })</Iso>
|
|
62
|
+
{ data.locations.from.departure }
|
|
63
|
+
</h2>
|
|
64
|
+
|
|
65
|
+
<Stop>{ data.locations.from.stop?.name }</Stop>
|
|
54
66
|
</Location>
|
|
55
67
|
|
|
56
68
|
<Time>
|
|
57
69
|
<HiOutlineArrowNarrowRight />
|
|
58
|
-
|
|
70
|
+
|
|
71
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
72
|
+
The clock is the point: this number sits between two real
|
|
73
|
+
departure times, and "08:30" gave nothing to distinguish a
|
|
74
|
+
trip length from a time of day. */ }
|
|
75
|
+
<span><BiHourglass />{ formatDuration(data.duration, t) }</span>
|
|
59
76
|
</Time>
|
|
60
77
|
|
|
61
78
|
<Location>
|
|
62
|
-
<h2>
|
|
63
|
-
|
|
79
|
+
<h2>
|
|
80
|
+
{ data.locations.to.city.name }
|
|
81
|
+
<Iso>({ data.locations.to.city.country?.iso_code })</Iso>
|
|
82
|
+
{ data.locations.to.arrival }
|
|
83
|
+
</h2>
|
|
84
|
+
|
|
85
|
+
<Stop>{ data.locations.to.stop?.name }</Stop>
|
|
64
86
|
</Location>
|
|
65
87
|
|
|
66
88
|
<Price>
|
|
67
89
|
<strong>{ data.price.display }</strong>
|
|
68
90
|
|
|
91
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
92
|
+
obtapi's price.value is the total for the WHOLE party
|
|
93
|
+
(adult * adults + child * children + baby * babies), not a
|
|
94
|
+
per-person fare - so this says how many people it covers
|
|
95
|
+
rather than the "per adult" line the competitors use, which
|
|
96
|
+
would be wrong here for any multi-passenger search. */ }
|
|
97
|
+
<PriceNotice>
|
|
98
|
+
{ t(
|
|
99
|
+
passengers === 1
|
|
100
|
+
? 'routes_order.step2.route.price_notice_single'
|
|
101
|
+
: 'routes_order.step2.route.price_notice_multiple',
|
|
102
|
+
{ total: passengers }
|
|
103
|
+
) }
|
|
104
|
+
</PriceNotice>
|
|
105
|
+
|
|
69
106
|
<Button
|
|
70
107
|
type="button"
|
|
71
108
|
size="medium"
|
|
@@ -81,6 +118,8 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
|
81
118
|
</Price>
|
|
82
119
|
</Trip>
|
|
83
120
|
|
|
121
|
+
{ children }
|
|
122
|
+
|
|
84
123
|
<Details>
|
|
85
124
|
<Detail>
|
|
86
125
|
<TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
|