@autobusal/routes-order 1.7.0 → 1.9.0
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 +58 -0
- package/Found/Dates/Dates.tsx +38 -2
- package/Found/Dates/styles.ts +37 -3
- package/Found/Filters/Filters.tsx +191 -0
- package/Found/Filters/styles.ts +117 -0
- package/Found/Found.tsx +107 -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 +24 -2
- package/Found/Route/styles.ts +10 -2
- 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 +30 -0
- package/Found/refine.ts +583 -0
- package/package.json +1 -1
- package/services.ts +7 -1
- package/types.ts +6 -0
|
@@ -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
|
@@ -3,7 +3,7 @@ import { TFunction } from 'i18next';
|
|
|
3
3
|
import { HiOutlineArrowNarrowRight, HiOutlineDocumentText } from 'react-icons/hi';
|
|
4
4
|
import { RiBus2Line } from 'react-icons/ri';
|
|
5
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 { Container, SpecialOffer, FavoriteStop, Trip, Company, Location, Time, Price, PriceNotice, LinkCompany, Details, Detail, ButtonPolicy, TitleDetail, LinkRoute, Features, TitleFeatures, FeaturesItems } from './styles';
|
|
7
7
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
8
8
|
|
|
9
9
|
// neutral placeholder so a missing/broken operator logo doesn't render the
|
|
@@ -13,11 +13,16 @@ const LOGO_FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s
|
|
|
13
13
|
interface Props {
|
|
14
14
|
data: FoundData
|
|
15
15
|
type: 'favorite' | 'normal'
|
|
16
|
+
passengers: number
|
|
16
17
|
t: TFunction<'common'>
|
|
17
18
|
onSave: (data: FoundData) => void
|
|
19
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
20
|
+
// Slot under the trip row, used by Group to hang a departure-time picker
|
|
21
|
+
// on a card that stands for several interchangeable departures.
|
|
22
|
+
children?: React.ReactNode
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
25
|
+
const Route = ({ data, type, passengers, t, onSave, children }: Props): JSX.Element => {
|
|
21
26
|
const [ policy, setPolicy ] = useState<boolean>(false);
|
|
22
27
|
|
|
23
28
|
const customs = Object.values(data.customs).join(', ');
|
|
@@ -66,6 +71,21 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
|
66
71
|
<Price>
|
|
67
72
|
<strong>{ data.price.display }</strong>
|
|
68
73
|
|
|
74
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
75
|
+
obtapi's price.value is the total for the WHOLE party
|
|
76
|
+
(adult * adults + child * children + baby * babies), not a
|
|
77
|
+
per-person fare - so this says how many people it covers
|
|
78
|
+
rather than the "per adult" line the competitors use, which
|
|
79
|
+
would be wrong here for any multi-passenger search. */ }
|
|
80
|
+
<PriceNotice>
|
|
81
|
+
{ t(
|
|
82
|
+
passengers === 1
|
|
83
|
+
? 'routes_order.step2.route.price_notice_single'
|
|
84
|
+
: 'routes_order.step2.route.price_notice_multiple',
|
|
85
|
+
{ total: passengers }
|
|
86
|
+
) }
|
|
87
|
+
</PriceNotice>
|
|
88
|
+
|
|
69
89
|
<Button
|
|
70
90
|
type="button"
|
|
71
91
|
size="medium"
|
|
@@ -81,6 +101,8 @@ const Route = ({ data, type, t, onSave }: Props): JSX.Element => {
|
|
|
81
101
|
</Price>
|
|
82
102
|
</Trip>
|
|
83
103
|
|
|
104
|
+
{ children }
|
|
105
|
+
|
|
84
106
|
<Details>
|
|
85
107
|
<Detail>
|
|
86
108
|
<TitleDetail>{ t('routes_order.step2.route.name') }</TitleDetail>
|
package/Found/Route/styles.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import styled, { css } from 'styled-components';
|
|
2
2
|
import { Link } from 'react-router-dom';
|
|
3
|
+
import { logoBox } from '@autobusal/common/styles';
|
|
3
4
|
|
|
4
5
|
export const Container = styled.div<{ $type: 'favorite' | 'normal' }>`
|
|
5
6
|
position: relative;
|
|
@@ -125,14 +126,21 @@ export const Price = styled.div`
|
|
|
125
126
|
}
|
|
126
127
|
`;
|
|
127
128
|
|
|
129
|
+
export const PriceNotice = styled.span`
|
|
130
|
+
margin-top: -6px;
|
|
131
|
+
font-size: ${ props => props.theme.size.xs };
|
|
132
|
+
color: ${ props => props.theme.font.faded };
|
|
133
|
+
text-align: center;
|
|
134
|
+
`;
|
|
135
|
+
|
|
128
136
|
export const LinkCompany = styled(Link)`
|
|
129
137
|
display: block;
|
|
130
138
|
max-width: 165px;
|
|
131
139
|
margin: 0 auto;
|
|
132
140
|
|
|
133
141
|
& > img {
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
${ logoBox('100%', '60px') }
|
|
143
|
+
|
|
136
144
|
border-radius: 8px;
|
|
137
145
|
}
|
|
138
146
|
`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Label, Options, Option } from './styles';
|
|
3
|
+
import { SortKey, SORTS } from '../refine';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
value: SortKey
|
|
7
|
+
t: TFunction<'common'>
|
|
8
|
+
onChange: (value: SortKey) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sort control for the results list.
|
|
13
|
+
*
|
|
14
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
15
|
+
*
|
|
16
|
+
* Rendered as always-visible pills rather than a <select>: the options are
|
|
17
|
+
* few and short, and the point is that somebody who never opens a dropdown
|
|
18
|
+
* still sees that "cheapest" is one click away.
|
|
19
|
+
*/
|
|
20
|
+
const Sort = ({ value, t, onChange }: Props): JSX.Element => (
|
|
21
|
+
<Container>
|
|
22
|
+
<Label>{ t('routes_order.step2.sort.title') }</Label>
|
|
23
|
+
|
|
24
|
+
<Options>
|
|
25
|
+
{ SORTS.map(sort => (
|
|
26
|
+
<Option
|
|
27
|
+
key={ sort }
|
|
28
|
+
type="button"
|
|
29
|
+
$active={ value === sort }
|
|
30
|
+
aria-pressed={ value === sort }
|
|
31
|
+
onClick={ () => onChange(sort) }
|
|
32
|
+
>
|
|
33
|
+
{ t(`routes_order.step2.sort.${ sort }`) }
|
|
34
|
+
</Option>
|
|
35
|
+
)) }
|
|
36
|
+
</Options>
|
|
37
|
+
</Container>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export default Sort;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: 8px;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
`;
|
|
9
|
+
|
|
10
|
+
export const Label = styled.span`
|
|
11
|
+
font-size: ${ props => props.theme.size.s };
|
|
12
|
+
font-weight: 700;
|
|
13
|
+
color: ${ props => props.theme.font.faded };
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export const Options = styled.div`
|
|
17
|
+
display: flex;
|
|
18
|
+
gap: 6px;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const Option = styled.button<{ $active: boolean }>`
|
|
23
|
+
padding: 6px 14px;
|
|
24
|
+
font-size: ${ props => props.theme.size.s };
|
|
25
|
+
font-weight: 700;
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
border-radius: 100px;
|
|
28
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
29
|
+
background: ${ props => props.theme.background.normal };
|
|
30
|
+
color: ${ props => props.theme.font.normal };
|
|
31
|
+
transition: all 0.2s ease;
|
|
32
|
+
|
|
33
|
+
&:hover {
|
|
34
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
${ props => props.$active && css`
|
|
38
|
+
color: ${ props => props.theme.primary.contrast };
|
|
39
|
+
background: ${ props => props.theme.primary.normal };
|
|
40
|
+
border-color: ${ props => props.theme.primary.normal };
|
|
41
|
+
` }
|
|
42
|
+
`;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Result, Total, Range } from './styles';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
shown: number
|
|
6
|
+
total: number
|
|
7
|
+
range: { minDisplay: string, maxDisplay: string } | null
|
|
8
|
+
t: TFunction<'common'>
|
|
9
|
+
children?: React.ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "8 of 10 trips - from 38.00 EUR to 70.00 EUR".
|
|
14
|
+
*
|
|
15
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
16
|
+
*
|
|
17
|
+
* Cheap to render and it answers the two questions somebody has before they
|
|
18
|
+
* start reading rows: is there enough choice here, and what will this cost
|
|
19
|
+
* me. The shown/total split also makes it obvious when a filter is hiding
|
|
20
|
+
* results, which is otherwise easy to forget after scrolling.
|
|
21
|
+
*/
|
|
22
|
+
const Summary = ({ shown, total, range, t, children }: Props): JSX.Element => (
|
|
23
|
+
<Container>
|
|
24
|
+
<Result>
|
|
25
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
26
|
+
`total`, not `count` - i18next treats a `count` variable as a
|
|
27
|
+
request for plural resolution and would look for _one/_few/_other
|
|
28
|
+
suffixes we'd then owe in all fifteen languages, several of which
|
|
29
|
+
have more plural categories than English. */ }
|
|
30
|
+
<Total>
|
|
31
|
+
{ shown === total
|
|
32
|
+
? t('routes_order.step2.summary.count', { total })
|
|
33
|
+
: t('routes_order.step2.summary.filtered', { shown, total }) }
|
|
34
|
+
</Total>
|
|
35
|
+
|
|
36
|
+
{ range !== null && (
|
|
37
|
+
<Range>
|
|
38
|
+
{ range.minDisplay === range.maxDisplay
|
|
39
|
+
? range.minDisplay
|
|
40
|
+
: t('routes_order.step2.summary.range', { min: range.minDisplay, max: range.maxDisplay }) }
|
|
41
|
+
</Range>
|
|
42
|
+
) }
|
|
43
|
+
</Result>
|
|
44
|
+
|
|
45
|
+
{ children }
|
|
46
|
+
</Container>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export default Summary;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: 12px;
|
|
7
|
+
margin-bottom: 15px;
|
|
8
|
+
|
|
9
|
+
@media (min-width: 1024px) {
|
|
10
|
+
flex-direction: row;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: space-between;
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export const Result = styled.div`
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: baseline;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export const Total = styled.strong`
|
|
24
|
+
font-size: ${ props => props.theme.size.l };
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export const Range = styled.span`
|
|
28
|
+
font-size: ${ props => props.theme.size.s };
|
|
29
|
+
color: ${ props => props.theme.font.faded };
|
|
30
|
+
`;
|