@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +673 -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 +59 -5
  14. package/Found/Dates/styles.ts +137 -7
  15. package/Found/Empty/Empty.tsx +136 -0
  16. package/Found/Empty/styles.ts +98 -0
  17. package/Found/Filters/Filters.tsx +10 -7
  18. package/Found/Filters/styles.ts +79 -8
  19. package/Found/Found.tsx +128 -29
  20. package/Found/Group/Group.tsx +3 -1
  21. package/Found/Header/styles.ts +20 -4
  22. package/Found/Orders/Orders.tsx +25 -4
  23. package/Found/Picks/Picks.tsx +10 -3
  24. package/Found/Picks/styles.ts +60 -9
  25. package/Found/Route/Route.tsx +189 -33
  26. package/Found/Route/styles.ts +488 -50
  27. package/Found/SameDay/SameDay.tsx +35 -0
  28. package/Found/SameDay/styles.ts +27 -0
  29. package/Found/Sort/styles.ts +52 -7
  30. package/Found/Summary/Summary.tsx +1 -1
  31. package/Found/Summary/styles.ts +12 -0
  32. package/Found/Watch/Watch.tsx +187 -0
  33. package/Found/Watch/styles.ts +118 -0
  34. package/Found/refine.ts +69 -0
  35. package/Links/Links.tsx +91 -0
  36. package/Links/styles.ts +75 -0
  37. package/RoutesOrder.tsx +239 -31
  38. package/Schedule/Schedule.tsx +123 -0
  39. package/Schedule/styles.ts +71 -0
  40. package/Sections/Schema.tsx +186 -0
  41. package/Sections/Sections.tsx +160 -0
  42. package/Sections/styles.ts +44 -0
  43. package/Step1/prepare.ts +21 -2
  44. package/Step2.tsx +34 -1
  45. package/Step4/Passenger/Passenger.tsx +253 -24
  46. package/Step4/Passenger/styles.ts +21 -1
  47. package/Step4/Passengers/Display.tsx +10 -2
  48. package/Step4/Passengers/Passengers.tsx +53 -4
  49. package/Step4/Saved/Choose.tsx +99 -0
  50. package/Step4/Saved/Remember.tsx +58 -0
  51. package/Step4/Saved/styles.ts +87 -0
  52. package/Step4/checkAge.ts +15 -4
  53. package/Step5/Addons/Addons.tsx +146 -4
  54. package/Step5/Addons/styles.ts +94 -0
  55. package/Step5/Billing/Billing.tsx +138 -70
  56. package/Step5/Coupons/Coupons.tsx +28 -18
  57. package/Step5/Coupons/styles.ts +41 -8
  58. package/Step5/Payments/Payments.tsx +36 -5
  59. package/Step5/Payments/styles.ts +16 -7
  60. package/Step5/Summary/About/About.tsx +99 -27
  61. package/Step5/Summary/About/styles.ts +118 -1
  62. package/Step5/Summary/styles.ts +12 -5
  63. package/Step5/utilities.ts +67 -12
  64. package/Steps/Steps.tsx +3 -1
  65. package/commerce.ts +24 -0
  66. package/package.json +1 -1
  67. package/services.ts +295 -12
  68. package/styles.ts +126 -0
  69. package/types.ts +78 -1
  70. package/Found/Booking/Booking.tsx +0 -61
  71. package/Found/Booking/styles.ts +0 -50
  72. package/Step4/Step4.tsx +0 -114
  73. 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 < 18) {
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 < 3 || age > 17) {
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 > 2) {
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;
@@ -1,23 +1,40 @@
1
1
  import { TFunction } from 'i18next';
2
- import { RiWhatsappLine, RiTelegramLine } from 'react-icons/ri';
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
- if (!data || (!data.whatsapp.available && !data.telegram.available)) {
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
  };
@@ -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
+ `;