@autobusal/routes-order 1.9.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 +673 -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 +59 -5
- package/Found/Dates/styles.ts +137 -7
- package/Found/Empty/Empty.tsx +136 -0
- package/Found/Empty/styles.ts +98 -0
- package/Found/Filters/Filters.tsx +10 -7
- package/Found/Filters/styles.ts +79 -8
- package/Found/Found.tsx +128 -29
- package/Found/Group/Group.tsx +3 -1
- package/Found/Header/styles.ts +20 -4
- package/Found/Orders/Orders.tsx +25 -4
- package/Found/Picks/Picks.tsx +10 -3
- package/Found/Picks/styles.ts +60 -9
- package/Found/Route/Route.tsx +189 -33
- package/Found/Route/styles.ts +488 -50
- package/Found/SameDay/SameDay.tsx +35 -0
- package/Found/SameDay/styles.ts +27 -0
- package/Found/Sort/styles.ts +52 -7
- package/Found/Summary/Summary.tsx +1 -1
- package/Found/Summary/styles.ts +12 -0
- package/Found/Watch/Watch.tsx +187 -0
- package/Found/Watch/styles.ts +118 -0
- package/Found/refine.ts +69 -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 +295 -12
- package/styles.ts +126 -0
- package/types.ts +78 -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,99 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { Container, Label, Missing, Hint } from './styles';
|
|
3
|
+
import { SavedPassenger } from '@autobusal/providers/types/persons';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** this account's saved travellers, already measured against this journey */
|
|
7
|
+
passengers: SavedPassenger[]
|
|
8
|
+
/** the row currently filling the fields below, 0 for "typing from scratch" */
|
|
9
|
+
picked: number
|
|
10
|
+
/** the id to give the select, so the visible label actually points at it */
|
|
11
|
+
id: string
|
|
12
|
+
pending: boolean
|
|
13
|
+
t: TFunction<'common'>
|
|
14
|
+
onPick: (id: number) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The option keys obtapi reports in `missing`, in the order the form asks
|
|
19
|
+
* for them, paired with the label the form already uses for each. One list,
|
|
20
|
+
* so the notice cannot name a field by a word that appears nowhere on the
|
|
21
|
+
* screen it is pointing at.
|
|
22
|
+
*/
|
|
23
|
+
const LABELS: Record<string, string> = {
|
|
24
|
+
sex: 'routes_order.step4.gender',
|
|
25
|
+
passport: 'routes_order.step4.passport',
|
|
26
|
+
phone: 'routes_order.step4.phone',
|
|
27
|
+
whatsapp: 'routes_order.step4.whatsapp',
|
|
28
|
+
telegram: 'routes_order.step4.telegram'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* "Book somebody you have booked before."
|
|
33
|
+
*
|
|
34
|
+
* Edited: Claude - Date: 2026-08-20
|
|
35
|
+
*
|
|
36
|
+
* WHAT IT IS FOR. The same families ride the same corridors several times a
|
|
37
|
+
* year, and every trip has been another round of typing the same passport
|
|
38
|
+
* number. This turns that into one choice.
|
|
39
|
+
*
|
|
40
|
+
* WHO SEES IT. Only a signed-in customer with at least one saved traveller.
|
|
41
|
+
* The list is not fetched at all for a guest or for staff (see
|
|
42
|
+
* useGetSavedPassengers), so this component is simply never rendered for
|
|
43
|
+
* them - the endpoint's 404 for those accounts never reaches a screen. An
|
|
44
|
+
* account that has saved nobody yet also sees nothing: an empty chooser
|
|
45
|
+
* above an empty form is one more thing to read and nothing to use.
|
|
46
|
+
*
|
|
47
|
+
* A NAMED PERSON IS NOT A COMPLETE ONE, and that is what `missing` is for.
|
|
48
|
+
* Routes collect different fields, so somebody saved on a route that only
|
|
49
|
+
* wanted a name has no passport and must still be offerable on a route that
|
|
50
|
+
* wants one. Rather than quietly filling four of five fields and letting
|
|
51
|
+
* validation explain the fifth after they press pay, the gap is named here,
|
|
52
|
+
* in the fields' own words, at the moment they pick.
|
|
53
|
+
*
|
|
54
|
+
* `missing` may be ABSENT rather than empty - obtapi omits it when no
|
|
55
|
+
* journey was named - so it is tested as an array. An undefined `missing`
|
|
56
|
+
* means "not measured", never "nothing needed", and this says nothing at
|
|
57
|
+
* all in that case rather than promising a completeness it has not checked.
|
|
58
|
+
*/
|
|
59
|
+
const Choose = ({ passengers, picked, id, pending, t, onPick }: Props): JSX.Element => {
|
|
60
|
+
const chosen = passengers.find(item => item.id === picked);
|
|
61
|
+
|
|
62
|
+
const missing = Array.isArray(chosen?.missing) ? chosen.missing : [];
|
|
63
|
+
|
|
64
|
+
const named = missing
|
|
65
|
+
.filter(field => LABELS[field] !== undefined)
|
|
66
|
+
.map(field => t(LABELS[field], { ns: 'common' }));
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Container>
|
|
70
|
+
<Label htmlFor={ id }>{ t('routes_order.step4.saved.choose', { ns: 'common' }) }</Label>
|
|
71
|
+
|
|
72
|
+
<select
|
|
73
|
+
id={ id }
|
|
74
|
+
value={ picked }
|
|
75
|
+
disabled={ pending }
|
|
76
|
+
onChange={ (event) => onPick(Number(event.target.value)) }
|
|
77
|
+
>
|
|
78
|
+
<option value={ 0 }>{ t('routes_order.step4.saved.none', { ns: 'common' }) }</option>
|
|
79
|
+
|
|
80
|
+
{ passengers.map(item => (
|
|
81
|
+
<option key={ item.id } value={ item.id }>
|
|
82
|
+
{ /* The date of birth is what tells two relatives with the same
|
|
83
|
+
name apart, and it is the one thing on a saved row that is
|
|
84
|
+
never blank. The passport tail is NOT shown here: the list
|
|
85
|
+
is a chooser on a shared screen, and it is enough that the
|
|
86
|
+
person can be named. */ }
|
|
87
|
+
{ `${ item.first_name } ${ item.last_name } (${ item.dob })` }
|
|
88
|
+
</option>
|
|
89
|
+
)) }
|
|
90
|
+
</select>
|
|
91
|
+
|
|
92
|
+
{ named.length > 0
|
|
93
|
+
? <Missing>{ t('routes_order.step4.saved.missing', { fields: named.join(', '), ns: 'common' }) }</Missing>
|
|
94
|
+
: <Hint>{ t('routes_order.step4.saved.hint', { ns: 'common' }) }</Hint> }
|
|
95
|
+
</Container>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default Choose;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { UseFormRegister } from 'react-hook-form';
|
|
3
|
+
import { Remember as Container } from './styles';
|
|
4
|
+
import { PersonData } from '@autobusal/providers/types/persons';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** the checkout field this posts as - adult1_save, child2_save, ... */
|
|
8
|
+
name: string
|
|
9
|
+
id: string
|
|
10
|
+
/** whether this account is already at its limit, and cannot store another */
|
|
11
|
+
full: boolean
|
|
12
|
+
t: TFunction<'common'>
|
|
13
|
+
refs: UseFormRegister<PersonData>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* "Remember this passenger for next time."
|
|
18
|
+
*
|
|
19
|
+
* Edited: Claude - Date: 2026-08-20
|
|
20
|
+
*
|
|
21
|
+
* OFF BY DEFAULT, and there is no version of this that is not. What the box
|
|
22
|
+
* stores is a passport number and a date of birth, frequently belonging to
|
|
23
|
+
* somebody who is not the buyer - a family books together, and grandmother
|
|
24
|
+
* did not agree to anything. Nobody is opted into keeping an identity
|
|
25
|
+
* document on file because they did not notice a tick.
|
|
26
|
+
*
|
|
27
|
+
* PER PASSENGER, for the same reason. "Remember me but not the person I am
|
|
28
|
+
* buying a ticket for" is an ordinary thing to want, and one checkbox for
|
|
29
|
+
* the whole order cannot say it. That is why this renders inside the
|
|
30
|
+
* passenger it belongs to rather than beside the paid add-ons.
|
|
31
|
+
*
|
|
32
|
+
* NOT REGISTERED WITH A `value`. react-hook-form hands back the value
|
|
33
|
+
* attribute for a ticked checkbox and `false` for an unticked one; with no
|
|
34
|
+
* value attribute it is a plain true/false, which is exactly the
|
|
35
|
+
* `nullable|boolean` obtapi validates this field as (Orders\Make\Rules) and
|
|
36
|
+
* exactly the set Libraries\Passengers\Capture treats as a yes.
|
|
37
|
+
*
|
|
38
|
+
* Rendered only for a signed-in customer - the caller decides that, from the
|
|
39
|
+
* same fact that decides whether the chooser above appears.
|
|
40
|
+
*/
|
|
41
|
+
const Remember = ({ name, id, full, t, refs }: Props): JSX.Element => (
|
|
42
|
+
<Container>
|
|
43
|
+
<label htmlFor={ id }>
|
|
44
|
+
<input
|
|
45
|
+
type="checkbox"
|
|
46
|
+
id={ id }
|
|
47
|
+
disabled={ full }
|
|
48
|
+
{ ...refs(name) }
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
{ full
|
|
52
|
+
? t('routes_order.step4.saved.full', { ns: 'common' })
|
|
53
|
+
: t('routes_order.step4.saved.remember', { ns: 'common' }) }
|
|
54
|
+
</label>
|
|
55
|
+
</Container>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export default Remember;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Book this person again" - the saved-passenger chooser.
|
|
5
|
+
*
|
|
6
|
+
* Edited: Claude - Date: 2026-08-20
|
|
7
|
+
*
|
|
8
|
+
* UNOBTRUSIVE ON PURPOSE. It sits above the fields it fills, and for a
|
|
9
|
+
* first-time buyer - which is most of them - it is not rendered at all. It
|
|
10
|
+
* is a shortcut past a form, not a step in one, so it is quieter than the
|
|
11
|
+
* form: no box of its own, no heading, and it never takes the visual weight
|
|
12
|
+
* of a required field away from the fields that are actually required.
|
|
13
|
+
*/
|
|
14
|
+
export const Container = styled.div`
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: 4px;
|
|
18
|
+
padding: 10px 12px;
|
|
19
|
+
margin-bottom: 16px;
|
|
20
|
+
border: 1px dashed ${ props => props.theme.background.neutral };
|
|
21
|
+
border-radius: 6px;
|
|
22
|
+
|
|
23
|
+
select {
|
|
24
|
+
max-width: 320px;
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export const Label = styled.label`
|
|
29
|
+
font-size: ${ props => props.theme.size.xs };
|
|
30
|
+
font-weight: 600;
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What this journey still needs from a traveller we already know.
|
|
35
|
+
*
|
|
36
|
+
* Amber rather than red: nothing has gone wrong and nothing has been
|
|
37
|
+
* refused - the person was saved on a route that never asked for a passport
|
|
38
|
+
* and this one does. It is an instruction, not an error, so it does not
|
|
39
|
+
* borrow the styling of the validation messages sitting a few pixels below
|
|
40
|
+
* it.
|
|
41
|
+
*/
|
|
42
|
+
export const Missing = styled.small`
|
|
43
|
+
display: block;
|
|
44
|
+
margin-top: 2px;
|
|
45
|
+
font-size: ${ props => props.theme.size.xxs };
|
|
46
|
+
font-weight: 400;
|
|
47
|
+
line-height: 1.4;
|
|
48
|
+
color: ${ props => props.theme.font.warning };
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
export const Hint = styled.small`
|
|
52
|
+
display: block;
|
|
53
|
+
font-size: ${ props => props.theme.size.xxs };
|
|
54
|
+
font-weight: 400;
|
|
55
|
+
line-height: 1.4;
|
|
56
|
+
color: ${ props => props.theme.font.faded };
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* "Remember this passenger for next time".
|
|
61
|
+
*
|
|
62
|
+
* Edited: Claude - Date: 2026-08-20
|
|
63
|
+
*
|
|
64
|
+
* At the FOOT of the passenger it applies to rather than beside the paid
|
|
65
|
+
* add-ons, because it is a question about this person and not about the
|
|
66
|
+
* order - and because a checkbox that stores somebody's passport number
|
|
67
|
+
* should be read in the same glance as the passport number it would store.
|
|
68
|
+
*/
|
|
69
|
+
export const Remember = styled.div`
|
|
70
|
+
margin-top: 12px;
|
|
71
|
+
padding-top: 12px;
|
|
72
|
+
border-top: 1px solid ${ props => props.theme.background.neutral };
|
|
73
|
+
|
|
74
|
+
label {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 8px;
|
|
78
|
+
font-size: ${ props => props.theme.size.xs };
|
|
79
|
+
font-weight: 400;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
input {
|
|
84
|
+
width: auto;
|
|
85
|
+
margin: 0;
|
|
86
|
+
}
|
|
87
|
+
`;
|
package/Step4/checkAge.ts
CHANGED
|
@@ -3,12 +3,23 @@ import { error, differenceYears, createDate } from '@autobusal/utilities';
|
|
|
3
3
|
import { PersonData } from '@autobusal/providers/types/persons';
|
|
4
4
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Edited: Claude - Date: 2026-08-28 (sweep finding, medium)
|
|
8
|
+
*
|
|
9
|
+
* ONE SET OF BANDS, the ones the picker advertises in all 15 languages:
|
|
10
|
+
* adults 12+, children 2-12, babies under 2. This check used to say
|
|
11
|
+
* adult>=18 / child 3-17 / baby<=2 while the server said something
|
|
12
|
+
* different again - a 2-year-old could not be booked in any category and
|
|
13
|
+
* a teenager the copy calls an adult was refused at pay time. The
|
|
14
|
+
* boundaries mirror obtapi's RulesGeneral::getDob exactly (2nd birthday
|
|
15
|
+
* makes a child, 12th an adult).
|
|
16
|
+
*/
|
|
6
17
|
const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'general'>): boolean => {
|
|
7
18
|
// check adults ages
|
|
8
19
|
for (let i = 1; i <= step1.adults; i++) {
|
|
9
20
|
const age = getAge(data[`adult${ i }_dob`]);
|
|
10
21
|
|
|
11
|
-
if (age <
|
|
22
|
+
if (age < 12) {
|
|
12
23
|
error(t('routes_order.step4.errors.age'));
|
|
13
24
|
return false;
|
|
14
25
|
}
|
|
@@ -18,7 +29,7 @@ const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'gener
|
|
|
18
29
|
for (let i = 1; i <= step1.children; i++) {
|
|
19
30
|
const age = getAge(data[`child${ i }_dob`])
|
|
20
31
|
|
|
21
|
-
if (age <
|
|
32
|
+
if (age < 2 || age >= 12) {
|
|
22
33
|
error(t('routes_order.step4.errors.age'));
|
|
23
34
|
return false;
|
|
24
35
|
}
|
|
@@ -28,7 +39,7 @@ const checkAge = (step1: RoutesSearchForm, data: PersonData, t: TFunction<'gener
|
|
|
28
39
|
for (let i = 1; i <= step1.babies; i++) {
|
|
29
40
|
const age = getAge(data[`baby${ i }_dob`]);
|
|
30
41
|
|
|
31
|
-
if (age
|
|
42
|
+
if (age >= 2) {
|
|
32
43
|
error(t('routes_order.step4.errors.age'));
|
|
33
44
|
return false;
|
|
34
45
|
}
|
|
@@ -45,4 +56,4 @@ const getAge = (date: string | number): number => {
|
|
|
45
56
|
return age;
|
|
46
57
|
};
|
|
47
58
|
|
|
48
|
-
export default checkAge;
|
|
59
|
+
export default checkAge;
|
package/Step5/Addons/Addons.tsx
CHANGED
|
@@ -1,23 +1,40 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { RiWhatsappLine, RiTelegramLine, RiMessage2Line, RiCalendarCheckLine, RiArrowUpSLine, RiArrowDownSLine } from 'react-icons/ri';
|
|
4
|
+
import { FlexOffer } from '@autobusal/providers/types/routes';
|
|
3
5
|
import { SubTitle } from '../../styles';
|
|
4
|
-
import { Container, Option, Notice } from './styles';
|
|
6
|
+
import { Container, Option, Notice, Terms, Term, FlexHeader, FlexTitle, FlexToggle, FlexPrice, FlexAdd, FlexDetails } from './styles';
|
|
5
7
|
|
|
6
8
|
interface AddonsData {
|
|
7
9
|
whatsapp: { available: boolean, price: number }
|
|
8
10
|
telegram: { available: boolean, price: number }
|
|
11
|
+
/**
|
|
12
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
13
|
+
* The paid per-order CONFIRMATION by SMS. Route alerts over SMS are free
|
|
14
|
+
* and reach every passenger who gave a phone number - this is only the
|
|
15
|
+
* text that confirms the booking. Optional so an older settings payload
|
|
16
|
+
* does not crash the checkout.
|
|
17
|
+
*/
|
|
18
|
+
sms?: { available: boolean, price: number }
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
interface Props {
|
|
12
22
|
data?: AddonsData
|
|
23
|
+
flexOffer?: FlexOffer
|
|
13
24
|
currency: string
|
|
14
25
|
whatsapp: boolean
|
|
15
26
|
whatsappNumber: string
|
|
16
27
|
telegram: boolean
|
|
28
|
+
sms: boolean
|
|
29
|
+
smsNumber: string
|
|
30
|
+
flex: boolean
|
|
17
31
|
t: TFunction<'common'>
|
|
18
32
|
onChangeWhatsapp: (value: boolean) => void
|
|
19
33
|
onChangeWhatsappNumber: (value: string) => void
|
|
20
34
|
onChangeTelegram: (value: boolean) => void
|
|
35
|
+
onChangeSms: (value: boolean) => void
|
|
36
|
+
onChangeSmsNumber: (value: string) => void
|
|
37
|
+
onChangeFlex: (value: boolean) => void
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
@@ -25,8 +42,24 @@ interface Props {
|
|
|
25
42
|
// enabled (an empty box would be confusing). The WhatsApp addon collects
|
|
26
43
|
// the number here; Telegram is a "connect after purchase" flow (a bot
|
|
27
44
|
// can't message a bare phone number cold), so it's just a checkbox.
|
|
28
|
-
const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChangeWhatsapp, onChangeWhatsappNumber, onChangeTelegram }: Props): (JSX.Element | null) => {
|
|
29
|
-
|
|
45
|
+
const Addons = ({ data, flexOffer, currency, whatsapp, whatsappNumber, telegram, sms, smsNumber, flex, t, onChangeWhatsapp, onChangeWhatsappNumber, onChangeTelegram, onChangeSms, onChangeSmsNumber, onChangeFlex }: Props): (JSX.Element | null) => {
|
|
46
|
+
// Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
47
|
+
// Flexible Ticket is only on offer where the operators behind the chosen
|
|
48
|
+
// routes have agreed to honour it, which is a per-booking answer the
|
|
49
|
+
// settings payload cannot give - hence the separate offer (see
|
|
50
|
+
// useGetFlex). When it is unavailable nothing renders for it at all,
|
|
51
|
+
// rather than a disabled row: a checkbox that unlocks nothing is worse
|
|
52
|
+
// than no checkbox.
|
|
53
|
+
const hasFlex = flexOffer?.available === true;
|
|
54
|
+
|
|
55
|
+
// Closed to begin with. The title, the price and the Add button are the
|
|
56
|
+
// decision; the windows and the small print are for somebody who wants to
|
|
57
|
+
// check before making it.
|
|
58
|
+
const [ open, setOpen ] = useState<boolean>(false);
|
|
59
|
+
|
|
60
|
+
const hasSms = data?.sms?.available === true;
|
|
61
|
+
|
|
62
|
+
if (!data || (!data.whatsapp.available && !data.telegram.available && !hasSms && !hasFlex)) {
|
|
30
63
|
return null;
|
|
31
64
|
}
|
|
32
65
|
|
|
@@ -52,9 +85,15 @@ const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChang
|
|
|
52
85
|
|
|
53
86
|
<Notice>{ t('routes_order.step5.addons.whatsapp.description') }</Notice>
|
|
54
87
|
|
|
88
|
+
{ /* Edited: Claude - Date: 2026-08-19
|
|
89
|
+
The number fields below have no visible label of their own -
|
|
90
|
+
they appear under the checkbox they belong to - so the
|
|
91
|
+
placeholder wording is repeated as an accessible name rather
|
|
92
|
+
than leaving them unnamed. */ }
|
|
55
93
|
{ whatsapp && (
|
|
56
94
|
<input
|
|
57
95
|
type="text"
|
|
96
|
+
aria-label={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
|
|
58
97
|
placeholder={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
|
|
59
98
|
value={ whatsappNumber }
|
|
60
99
|
onChange={ (event) => onChangeWhatsappNumber(event.target.value) }
|
|
@@ -80,6 +119,109 @@ const Addons = ({ data, currency, whatsapp, whatsappNumber, telegram, t, onChang
|
|
|
80
119
|
<Notice>{ t('routes_order.step5.addons.telegram.description') }</Notice>
|
|
81
120
|
</Option>
|
|
82
121
|
) }
|
|
122
|
+
|
|
123
|
+
{ /* Edited: Ferjolt Ozuni - Date: 2026-08-05
|
|
124
|
+
The CONFIRMATION text, not route alerts - those are free and go to
|
|
125
|
+
every passenger who left a phone number, whether or not anything
|
|
126
|
+
was bought here. The description says so, because "SMS
|
|
127
|
+
notifications" as a paid line would otherwise read as buying the
|
|
128
|
+
delay warnings too.
|
|
129
|
+
|
|
130
|
+
Prefilled from the passenger phone the form above already
|
|
131
|
+
collected, and still editable: somebody booking for a relative
|
|
132
|
+
wants the text to reach the traveller. */ }
|
|
133
|
+
{ hasSms && (
|
|
134
|
+
<Option>
|
|
135
|
+
<label>
|
|
136
|
+
<input
|
|
137
|
+
type="checkbox"
|
|
138
|
+
checked={ sms }
|
|
139
|
+
onChange={ (event) => onChangeSms(event.target.checked) }
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
<RiMessage2Line />
|
|
143
|
+
|
|
144
|
+
{ t('routes_order.step5.addons.sms.label') } (+{ data.sms?.price } { currency })
|
|
145
|
+
</label>
|
|
146
|
+
|
|
147
|
+
<Notice>{ t('routes_order.step5.addons.sms.description') }</Notice>
|
|
148
|
+
|
|
149
|
+
{ sms && (
|
|
150
|
+
<input
|
|
151
|
+
type="text"
|
|
152
|
+
aria-label={ t('routes_order.step5.addons.sms.phone_placeholder') }
|
|
153
|
+
placeholder={ t('routes_order.step5.addons.sms.phone_placeholder') }
|
|
154
|
+
value={ smsNumber }
|
|
155
|
+
onChange={ (event) => onChangeSmsNumber(event.target.value) }
|
|
156
|
+
/>
|
|
157
|
+
) }
|
|
158
|
+
</Option>
|
|
159
|
+
) }
|
|
160
|
+
|
|
161
|
+
{ hasFlex && (
|
|
162
|
+
<Option>
|
|
163
|
+
<FlexHeader>
|
|
164
|
+
<FlexTitle>
|
|
165
|
+
<RiCalendarCheckLine />
|
|
166
|
+
|
|
167
|
+
{ t('routes_order.step5.flex.title') }
|
|
168
|
+
|
|
169
|
+
<FlexPrice>{ flexOffer?.price_display }</FlexPrice>
|
|
170
|
+
</FlexTitle>
|
|
171
|
+
|
|
172
|
+
<FlexAdd type="button" $added={ flex } onClick={ () => onChangeFlex(!flex) }>
|
|
173
|
+
{ flex ? t('routes_order.step5.flex.remove') : t('routes_order.step5.flex.add') }
|
|
174
|
+
</FlexAdd>
|
|
175
|
+
</FlexHeader>
|
|
176
|
+
|
|
177
|
+
{/*
|
|
178
|
+
* Deliberately OUTSIDE the collapse. Everything else here is
|
|
179
|
+
* detail somebody can choose to read, but this one line is the
|
|
180
|
+
* material fact - a buyer is being asked to pay for a right they
|
|
181
|
+
* would otherwise not have, and hiding what they get for nothing
|
|
182
|
+
* behind a toggle is the shape of a dark pattern rather than a
|
|
183
|
+
* tidy layout.
|
|
184
|
+
*/}
|
|
185
|
+
<Notice>{ t('routes_order.step5.flex.default') }</Notice>
|
|
186
|
+
|
|
187
|
+
<FlexToggle type="button" aria-expanded={ open } onClick={ () => setOpen(!open) }>
|
|
188
|
+
{ open ? t('routes_order.step5.flex.less') : t('routes_order.step5.flex.more') }
|
|
189
|
+
|
|
190
|
+
{ open ? <RiArrowUpSLine /> : <RiArrowDownSLine /> }
|
|
191
|
+
</FlexToggle>
|
|
192
|
+
|
|
193
|
+
{ open && (
|
|
194
|
+
<FlexDetails>
|
|
195
|
+
<Terms>
|
|
196
|
+
{ flexOffer?.refund_hours != null && (
|
|
197
|
+
<Term>{ t('routes_order.step5.flex.refund', { hours: flexOffer.refund_hours }) }</Term>
|
|
198
|
+
) }
|
|
199
|
+
|
|
200
|
+
{/*
|
|
201
|
+
* Either half can be absent on its own. An operator who
|
|
202
|
+
* will move a booking but not refund it is a real case, and
|
|
203
|
+
* listing only what this booking actually includes is the
|
|
204
|
+
* difference between an offer and a misrepresentation.
|
|
205
|
+
*/}
|
|
206
|
+
{ flexOffer?.reschedule_hours != null && (
|
|
207
|
+
<Term>{ t('routes_order.step5.flex.reschedule', { hours: flexOffer.reschedule_hours }) }</Term>
|
|
208
|
+
) }
|
|
209
|
+
</Terms>
|
|
210
|
+
|
|
211
|
+
<Notice>{ t('routes_order.step5.flex.nonrefundable') }</Notice>
|
|
212
|
+
|
|
213
|
+
{/*
|
|
214
|
+
* Statutory rights when the OPERATOR cancels are untouched by
|
|
215
|
+
* this and by anything else sold here. Saying so is both
|
|
216
|
+
* accurate and reassuring; leaving it out would imply the
|
|
217
|
+
* add-on is what grants them.
|
|
218
|
+
*/}
|
|
219
|
+
<Notice>{ t('routes_order.step5.flex.carrier') }</Notice>
|
|
220
|
+
</FlexDetails>
|
|
221
|
+
) }
|
|
222
|
+
</Option>
|
|
223
|
+
) }
|
|
224
|
+
|
|
83
225
|
</Container>
|
|
84
226
|
);
|
|
85
227
|
};
|
package/Step5/Addons/styles.ts
CHANGED
|
@@ -30,3 +30,97 @@ export const Notice = styled.div`
|
|
|
30
30
|
font-size: ${ props => props.theme.size.xs };
|
|
31
31
|
color: ${ props => props.theme.font.faded };
|
|
32
32
|
`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What the add-on actually unlocks, as a short list.
|
|
36
|
+
*
|
|
37
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
38
|
+
* A list rather than a sentence because the two windows can differ, and
|
|
39
|
+
* because either can be absent - prose would have to be rewritten for each
|
|
40
|
+
* combination, a list simply gets shorter.
|
|
41
|
+
*/
|
|
42
|
+
export const Terms = styled.ul`
|
|
43
|
+
margin: 8px 0 10px;
|
|
44
|
+
padding: 0 0 0 18px;
|
|
45
|
+
list-style: disc;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export const Term = styled.li`
|
|
49
|
+
margin: 2px 0;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
line-height: 1.5;
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The Flexible Ticket row: what it is and its price on the left, the
|
|
56
|
+
* decision on the right.
|
|
57
|
+
*
|
|
58
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
59
|
+
*
|
|
60
|
+
* A button rather than a checkbox, because this is not the same kind of
|
|
61
|
+
* choice as the notification add-ons beside it - those are a cheap tick, this
|
|
62
|
+
* one changes what the ticket IS. The two halves are separate controls on
|
|
63
|
+
* purpose: reading the terms must never be a way to accidentally buy them.
|
|
64
|
+
*/
|
|
65
|
+
export const FlexHeader = styled.div`
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: space-between;
|
|
69
|
+
gap: 10px;
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export const FlexTitle = styled.span`
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 6px;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* "Read More", small and quiet, under the one line that always shows.
|
|
81
|
+
*
|
|
82
|
+
* Edited: Ferjolt Ozuni - Date: 2026-08-03
|
|
83
|
+
* Its own control rather than the title, so the thing you click to READ is
|
|
84
|
+
* never next to the thing you click to BUY.
|
|
85
|
+
*/
|
|
86
|
+
export const FlexToggle = styled.button`
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 2px;
|
|
90
|
+
margin-top: 4px;
|
|
91
|
+
margin-left: 22px;
|
|
92
|
+
padding: 0;
|
|
93
|
+
border: 0;
|
|
94
|
+
background: none;
|
|
95
|
+
color: ${ props => props.theme.primary.normal };
|
|
96
|
+
font-family: inherit;
|
|
97
|
+
font-size: ${ props => props.theme.size.xs };
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
text-decoration: underline;
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
export const FlexPrice = styled.span`
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
export const FlexAdd = styled.button<{ $added: boolean }>`
|
|
108
|
+
flex: 0 0 auto;
|
|
109
|
+
padding: 6px 16px;
|
|
110
|
+
border-radius: 4px;
|
|
111
|
+
border: 1px solid ${ props => props.theme.primary.normal };
|
|
112
|
+
background: ${ props => props.$added ? 'transparent' : props.theme.primary.normal };
|
|
113
|
+
color: ${ props => props.$added ? props.theme.primary.normal : props.theme.primary.contrast };
|
|
114
|
+
font-family: inherit;
|
|
115
|
+
font-size: ${ props => props.theme.size.xs };
|
|
116
|
+
font-weight: 700;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
|
|
119
|
+
&:hover {
|
|
120
|
+
opacity: 0.85;
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
export const FlexDetails = styled.div`
|
|
125
|
+
margin-left: 22px;
|
|
126
|
+
`;
|