@autobusal/routes-order 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +731 -0
  2. package/Checkout/Checkout.tsx +519 -0
  3. package/Checkout/Sidebar/Sidebar.tsx +124 -0
  4. package/Checkout/Sidebar/styles.ts +52 -0
  5. package/Facts/Facts.tsx +271 -0
  6. package/Facts/questions.ts +250 -0
  7. package/Facts/service.ts +111 -0
  8. package/Facts/services.ts +19 -0
  9. package/Facts/styles.ts +112 -0
  10. package/Facts/types.ts +167 -0
  11. package/Faq/Faq.tsx +52 -0
  12. package/Faq/styles.ts +68 -0
  13. package/Found/Dates/Dates.tsx +95 -5
  14. package/Found/Dates/styles.ts +173 -9
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +194 -0
  18. package/Found/Filters/styles.ts +188 -0
  19. package/Found/Found.tsx +207 -17
  20. package/Found/Group/Group.tsx +68 -0
  21. package/Found/Group/styles.ts +44 -0
  22. package/Found/Header/styles.ts +20 -4
  23. package/Found/Orders/Orders.tsx +89 -27
  24. package/Found/Orders/styles.ts +14 -0
  25. package/Found/Picks/Picks.tsx +75 -0
  26. package/Found/Picks/styles.ts +106 -0
  27. package/Found/Route/Route.tsx +211 -33
  28. package/Found/Route/styles.ts +491 -46
  29. package/Found/SameDay/SameDay.tsx +35 -0
  30. package/Found/SameDay/styles.ts +27 -0
  31. package/Found/Sort/Sort.tsx +40 -0
  32. package/Found/Sort/styles.ts +87 -0
  33. package/Found/Summary/Summary.tsx +49 -0
  34. package/Found/Summary/styles.ts +42 -0
  35. package/Found/Watch/Watch.tsx +187 -0
  36. package/Found/Watch/styles.ts +118 -0
  37. package/Found/refine.ts +652 -0
  38. package/Links/Links.tsx +91 -0
  39. package/Links/styles.ts +75 -0
  40. package/RoutesOrder.tsx +239 -31
  41. package/Schedule/Schedule.tsx +123 -0
  42. package/Schedule/styles.ts +71 -0
  43. package/Sections/Schema.tsx +186 -0
  44. package/Sections/Sections.tsx +160 -0
  45. package/Sections/styles.ts +44 -0
  46. package/Step1/prepare.ts +21 -2
  47. package/Step2.tsx +34 -1
  48. package/Step4/Passenger/Passenger.tsx +253 -24
  49. package/Step4/Passenger/styles.ts +21 -1
  50. package/Step4/Passengers/Display.tsx +10 -2
  51. package/Step4/Passengers/Passengers.tsx +53 -4
  52. package/Step4/Saved/Choose.tsx +99 -0
  53. package/Step4/Saved/Remember.tsx +58 -0
  54. package/Step4/Saved/styles.ts +87 -0
  55. package/Step4/checkAge.ts +15 -4
  56. package/Step5/Addons/Addons.tsx +146 -4
  57. package/Step5/Addons/styles.ts +94 -0
  58. package/Step5/Billing/Billing.tsx +138 -70
  59. package/Step5/Coupons/Coupons.tsx +28 -18
  60. package/Step5/Coupons/styles.ts +41 -8
  61. package/Step5/Payments/Payments.tsx +36 -5
  62. package/Step5/Payments/styles.ts +16 -7
  63. package/Step5/Summary/About/About.tsx +99 -27
  64. package/Step5/Summary/About/styles.ts +118 -1
  65. package/Step5/Summary/styles.ts +12 -5
  66. package/Step5/utilities.ts +67 -12
  67. package/Steps/Steps.tsx +3 -1
  68. package/commerce.ts +24 -0
  69. package/package.json +1 -1
  70. package/services.ts +302 -13
  71. package/styles.ts +126 -0
  72. package/types.ts +84 -1
  73. package/Found/Booking/Booking.tsx +0 -61
  74. package/Found/Booking/styles.ts +0 -50
  75. package/Step4/Step4.tsx +0 -114
  76. package/Step5/Step5.tsx +0 -185
@@ -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,87 @@
1
+ import styled, { css } from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
5
+ *
6
+ * A column on a phone, so the five options get the whole width for one row
7
+ * instead of losing a third of it to the "Sort by" label and wrapping onto
8
+ * three lines.
9
+ */
10
+ export const Container = styled.div`
11
+ display: flex;
12
+ flex-direction: column;
13
+ align-items: stretch;
14
+ gap: 6px;
15
+
16
+ @media (min-width: 640px) {
17
+ flex-direction: row;
18
+ align-items: center;
19
+ gap: 8px;
20
+ flex-wrap: wrap;
21
+ }
22
+ `;
23
+
24
+ export const Label = styled.span`
25
+ font-size: ${ props => props.theme.size.s };
26
+ font-weight: 700;
27
+ color: ${ props => props.theme.font.faded };
28
+ `;
29
+
30
+ /**
31
+ * One row, always. Five short options wrapping onto three lines took more
32
+ * height than the first result they were meant to reorder.
33
+ */
34
+ export const Options = styled.div`
35
+ display: flex;
36
+ gap: 3px;
37
+ flex-wrap: nowrap;
38
+
39
+ @media (min-width: 640px) {
40
+ gap: 6px;
41
+ flex-wrap: wrap;
42
+ }
43
+ `;
44
+
45
+ export const Option = styled.button<{ $active: boolean }>`
46
+ /**
47
+ * Edited: Ferjolt Ozuni - Date: 2026-08-03
48
+ *
49
+ * NATURAL width, allowed to shrink - not an equal share. Equal shares gave
50
+ * every option 59px, which four of the five did not need and the fifth
51
+ * did: "Recommended" wants 71px and was the only one ever truncated, to
52
+ * "Recom...", while its neighbours sat in whitespace. Left to size
53
+ * themselves the five come to 311px inside a 335px row and all fit.
54
+ *
55
+ * They can still shrink, so a language with longer words degrades to
56
+ * ellipsis rather than pushing an option off the edge.
57
+ */
58
+ flex: 0 1 auto;
59
+ min-width: 0;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ padding: 5px 2px;
63
+ font-size: calc(${ props => props.theme.size.xs } - 2px);
64
+ font-weight: 700;
65
+ white-space: nowrap;
66
+
67
+ @media (min-width: 640px) {
68
+ flex: initial;
69
+ padding: 6px 14px;
70
+ font-size: ${ props => props.theme.size.s };
71
+ }
72
+ border-radius: 100px;
73
+ border: 1px solid ${ props => props.theme.inputs.border };
74
+ background: ${ props => props.theme.background.normal };
75
+ color: ${ props => props.theme.font.normal };
76
+ transition: all 0.2s ease;
77
+
78
+ &:hover {
79
+ border-color: ${ props => props.theme.primary.normal };
80
+ }
81
+
82
+ ${ props => props.$active && css`
83
+ color: ${ props => props.theme.primary.contrast };
84
+ background: ${ props => props.theme.primary.normal };
85
+ border-color: ${ props => props.theme.primary.normal };
86
+ ` }
87
+ `;
@@ -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 className="box">
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,42 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Ferjolt Ozuni - Date: 2026-08-01
5
+ *
6
+ * Carries the `box` surface (padding is overridden to something tighter -
7
+ * this is a bar, not a panel) so the count and the sort control sit on the
8
+ * same card background as everything else on the page rather than floating
9
+ * loose on the page background.
10
+ */
11
+ export const Container = styled.div`
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 12px;
15
+ margin-bottom: 15px;
16
+
17
+ &.box {
18
+ padding: 12px 15px;
19
+ }
20
+
21
+ @media (min-width: 1024px) {
22
+ flex-direction: row;
23
+ align-items: center;
24
+ justify-content: space-between;
25
+ }
26
+ `;
27
+
28
+ export const Result = styled.div`
29
+ display: flex;
30
+ align-items: baseline;
31
+ gap: 8px;
32
+ flex-wrap: wrap;
33
+ `;
34
+
35
+ export const Total = styled.strong`
36
+ font-size: ${ props => props.theme.size.l };
37
+ `;
38
+
39
+ export const Range = styled.span`
40
+ font-size: ${ props => props.theme.size.s };
41
+ color: ${ props => props.theme.font.faded };
42
+ `;
@@ -0,0 +1,187 @@
1
+ import { useState, useId } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useForm } from 'react-hook-form';
4
+ import { AiOutlineBell, AiOutlineCheckCircle } from 'react-icons/ai';
5
+ import { Button } from '@autobusal/common';
6
+ import { Validate, Display } from '@autobusal/utilities';
7
+ import { useGetSuggestions } from '@autobusal/routes-search/services';
8
+ import { Container, Toggle, Panel, Lead, Journey, Fields, Field, Action, Privacy, Failure, Done } from './styles';
9
+ import { PriceAlertForm, PriceAlertData } from '../../types';
10
+ import { usePostPriceAlert } from '../../services';
11
+
12
+ interface Props {
13
+ from: string
14
+ to: string
15
+ departure: string
16
+ t: TFunction<'common'>
17
+ }
18
+
19
+ /**
20
+ * "Watch this fare" - tell me by email if the price for this journey drops.
21
+ *
22
+ * Edited: Claude - Date: 2026-08-20
23
+ *
24
+ * WHERE IT SITS is the whole design. This renders at the END of the results,
25
+ * from <Found>, which is the one component on this screen that is mounted in
26
+ * every state it can be in: trips listed, trips all hidden by a filter, and
27
+ * nothing found at all. A control placed inside the listing would have
28
+ * disappeared in exactly the state that most needs it - "no buses for this
29
+ * search" is when somebody most wants telling if that changes - and one
30
+ * placed above the trips would have argued with the results for attention
31
+ * on the page whose entire job is showing them.
32
+ *
33
+ * The pair and the date are the search that has already been run, so the
34
+ * only thing asked for is an address. Collapsed until pressed: this is for
35
+ * the minority who did not find their price, and an open form under every
36
+ * result set is a second thing to read past.
37
+ *
38
+ * THRESHOLD IS DELIBERATELY NOT COLLECTED. The endpoint accepts one, but
39
+ * "tell me if it goes down" needs no number from the traveller, and a
40
+ * second field is how a one-line control becomes a form nobody fills in.
41
+ */
42
+ const Watch = ({ from, to, departure, t }: Props): JSX.Element => {
43
+ const [ open, setOpen ] = useState<boolean>(false);
44
+ const [ done, setDone ] = useState<PriceAlertData | undefined>(undefined);
45
+ const [ failure, setFailure ] = useState<string | undefined>(undefined);
46
+
47
+ /**
48
+ * Edited: Claude - Date: 2026-08-20
49
+ * Same pairing @autobusal/auth and the checkout were just fixed to use: a
50
+ * real <label for> over an id nothing else can own. The results page also
51
+ * carries the footer's newsletter signup, so a bare id="email" would have
52
+ * been the second one on the page.
53
+ */
54
+ const uid = useId();
55
+
56
+ const id = (field: string): string => `${ uid }${ field }`;
57
+
58
+ const { register, handleSubmit, formState: { errors } } = useForm<PriceAlertForm>();
59
+
60
+ const { mutate: WatchFare, isPending } = usePostPriceAlert();
61
+
62
+ // the same cached city list the search form's autocomplete and the step
63
+ // heading already read, so naming the journey costs no extra request
64
+ const { data: cities } = useGetSuggestions();
65
+
66
+ const fromCity = cities.find(city => city.slug === from);
67
+ const toCity = cities.find(city => city.slug === to);
68
+
69
+ const names = {
70
+ from: fromCity?.name ?? from,
71
+ to: toCity?.name ?? to
72
+ };
73
+
74
+ /**
75
+ * Every answer this endpoint can give, turned into something readable.
76
+ *
77
+ * 404 is not an error the visitor made: nobody prices this pair, so there
78
+ * is genuinely no fare to watch, and saying that plainly is the only
79
+ * honest reply. 429 is the write-tier throttle (20/min). 422 also raises
80
+ * the global toast from apiClient's interceptor - it is repeated inline
81
+ * because a toast above the fold is easy to miss from the foot of a long
82
+ * result list, which is exactly where this control lives.
83
+ */
84
+ const message = (status?: number): string => {
85
+ if (status === 404) {
86
+ return t('routes_order.step2.watch.errors.not_found');
87
+ }
88
+
89
+ if (status === 422) {
90
+ return t('routes_order.step2.watch.errors.invalid');
91
+ }
92
+
93
+ if (status === 429) {
94
+ return t('routes_order.step2.watch.errors.throttled');
95
+ }
96
+
97
+ return t('routes_order.step2.watch.errors.failed');
98
+ };
99
+
100
+ const onSubmit = (data: PriceAlertForm): void => {
101
+ setFailure(undefined);
102
+
103
+ WatchFare({
104
+ email: data.email,
105
+ from,
106
+ to,
107
+ departure
108
+ }, {
109
+ onSuccess: (data) => {
110
+ setDone(data);
111
+ },
112
+ onError: (error) => {
113
+ setFailure(message(error.response?.status));
114
+ }
115
+ });
116
+ };
117
+
118
+ if (done !== undefined) {
119
+ return (
120
+ <Container className="box">
121
+ <Done>
122
+ <AiOutlineCheckCircle />
123
+
124
+ <span>
125
+ { t('routes_order.step2.watch.done', {
126
+ price: done.baseline_price_display,
127
+ from: names.from,
128
+ to: names.to
129
+ }) }
130
+ </span>
131
+ </Done>
132
+ </Container>
133
+ );
134
+ }
135
+
136
+ return (
137
+ <Container className="box">
138
+ <Toggle type="button" aria-expanded={ open } onClick={ () => setOpen(!open) }>
139
+ <AiOutlineBell />
140
+
141
+ <span>{ t('routes_order.step2.watch.toggle') }</span>
142
+ </Toggle>
143
+
144
+ { open && (
145
+ <Panel>
146
+ <Lead>{ t('routes_order.step2.watch.lead') }</Lead>
147
+
148
+ <Journey>
149
+ { t('routes_order.step2.watch.journey', {
150
+ from: names.from,
151
+ to: names.to,
152
+ date: departure
153
+ }) }
154
+ </Journey>
155
+
156
+ <form onSubmit={ handleSubmit(onSubmit) }>
157
+ <Fields>
158
+ <Field>
159
+ <label htmlFor={ id('email') }>{ t('routes_order.step2.watch.email') }</label>
160
+
161
+ <input type="email" id={ id('email') } { ...register('email', Validate('required|email|max_length:255', t)) } />
162
+ </Field>
163
+
164
+ <Action>
165
+ <Button
166
+ type="submit"
167
+ size="medium"
168
+ loading={ isPending }
169
+ text={ t('routes_order.step2.watch.submit') }
170
+ noMargin
171
+ />
172
+ </Action>
173
+ </Fields>
174
+
175
+ { Display(errors.email) }
176
+
177
+ { failure !== undefined && <Failure role="alert">{ failure }</Failure> }
178
+
179
+ <Privacy>{ t('routes_order.step2.watch.privacy') }</Privacy>
180
+ </form>
181
+ </Panel>
182
+ ) }
183
+ </Container>
184
+ );
185
+ };
186
+
187
+ export default Watch;
@@ -0,0 +1,118 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Edited: Claude - Date: 2026-08-20
5
+ *
6
+ * A strip under the results, not a panel over them. This sits at the end of
7
+ * the list - where somebody has read every price and none of them was the
8
+ * one they wanted - so it has to be findable without ever competing with
9
+ * the trips themselves for attention.
10
+ */
11
+ export const Container = styled.div`
12
+ margin-top: 15px;
13
+
14
+ &.box {
15
+ padding: 12px 15px;
16
+ }
17
+ `;
18
+
19
+ export const Toggle = styled.button`
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ width: 100%;
24
+ text-align: left;
25
+ font-size: ${ props => props.theme.size.s };
26
+ font-weight: 700;
27
+ color: ${ props => props.theme.font.normal };
28
+
29
+ & > svg {
30
+ flex: 0 0 auto;
31
+ font-size: ${ props => props.theme.size.l };
32
+ color: ${ props => props.theme.primary.normal };
33
+ }
34
+
35
+ &:hover {
36
+ color: ${ props => props.theme.primary.normal };
37
+ }
38
+ `;
39
+
40
+ export const Panel = styled.div`
41
+ margin-top: 12px;
42
+ `;
43
+
44
+ export const Lead = styled.p`
45
+ margin: 0 0 4px;
46
+ font-size: ${ props => props.theme.size.s };
47
+ `;
48
+
49
+ export const Journey = styled.p`
50
+ margin: 0 0 12px;
51
+ font-size: ${ props => props.theme.size.xs };
52
+ color: ${ props => props.theme.font.faded };
53
+ `;
54
+
55
+ /**
56
+ * The field and its action on one line from the tablet up, stacked on a
57
+ * phone - the same shape the coupon box uses, so the two read as siblings.
58
+ */
59
+ export const Fields = styled.div`
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 10px;
63
+
64
+ @media (min-width: 640px) {
65
+ flex-direction: row;
66
+ align-items: flex-end;
67
+ }
68
+ `;
69
+
70
+ export const Field = styled.div`
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 6px;
74
+ flex: 1;
75
+ min-width: 0;
76
+ font-weight: 700;
77
+ font-size: calc(${ props => props.theme.size.m } - 1px);
78
+ `;
79
+
80
+ export const Action = styled.div`
81
+ flex: 0 0 auto;
82
+ `;
83
+
84
+ export const Privacy = styled.p`
85
+ margin: 10px 0 0;
86
+ font-size: ${ props => props.theme.size.xs };
87
+ color: ${ props => props.theme.font.faded };
88
+ `;
89
+
90
+ /**
91
+ * Every answer the server can give, said out loud.
92
+ *
93
+ * Edited: Claude - Date: 2026-08-20
94
+ *
95
+ * The contact form swallowed submissions for months because a failure had
96
+ * nowhere to render. A 404 (nobody prices this pair), a 429 (throttled) and
97
+ * an unreachable API all end up here rather than in the console.
98
+ */
99
+ export const Failure = styled.p`
100
+ margin: 10px 0 0;
101
+ font-size: ${ props => props.theme.size.s };
102
+ color: ${ props => props.theme.font.error };
103
+ `;
104
+
105
+ export const Done = styled.div`
106
+ display: flex;
107
+ gap: 8px;
108
+ align-items: flex-start;
109
+ font-size: ${ props => props.theme.size.s };
110
+ line-height: 1.4;
111
+
112
+ & > svg {
113
+ flex: 0 0 auto;
114
+ margin-top: 2px;
115
+ font-size: ${ props => props.theme.size.l };
116
+ color: ${ props => props.theme.primary.normal };
117
+ }
118
+ `;