@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.
@@ -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
+ `;
@@ -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 { Container, ContainerNotFound } from './styles';
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?: FoundData[]
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
- // undefined means the search errored (e.g. same origin and destination) -
27
- // show the not-found message instead of an empty shell
28
- if (data === undefined || data.length === 0) {
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
- const favorites = getFavorites(type, data, preferredStop).map(item => (
35
- <Route
36
- key={ item.id }
37
- type="favorite"
38
- data={ item }
39
- t={ t }
40
- onSave={ onSave }
41
- />
42
- ));
43
-
44
- const items = getNormal(type, data, preferredStop).map(item => (
45
- <Route
46
- key={ item.id }
47
- type="normal"
48
- data={ item }
49
- t={ t }
50
- onSave={ onSave }
51
- />
52
- ));
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;
@@ -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
+ `;
@@ -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>
@@ -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
- display: block;
135
- width: 100%;
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
+ `;