@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.
- package/CHANGELOG.md +731 -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 +95 -5
- package/Found/Dates/styles.ts +173 -9
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +194 -0
- package/Found/Filters/styles.ts +188 -0
- package/Found/Found.tsx +207 -17
- package/Found/Group/Group.tsx +68 -0
- package/Found/Group/styles.ts +44 -0
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +89 -27
- package/Found/Orders/styles.ts +14 -0
- package/Found/Picks/Picks.tsx +75 -0
- package/Found/Picks/styles.ts +106 -0
- package/Found/Route/Route.tsx +211 -33
- package/Found/Route/styles.ts +491 -46
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/Sort.tsx +40 -0
- package/Found/Sort/styles.ts +87 -0
- package/Found/Summary/Summary.tsx +49 -0
- package/Found/Summary/styles.ts +42 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +652 -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 +302 -13
- package/styles.ts +126 -0
- package/types.ts +84 -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
|
@@ -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
|
+
`;
|