@autobusal/routes-order 1.4.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/Step4/Passenger/Passenger.tsx +82 -31
- package/Step4/Passengers/Display.tsx +3 -1
- package/Step4/Passengers/Passengers.tsx +5 -1
- package/Step4/Step4.tsx +13 -0
- package/Step5/Addons/Addons.tsx +87 -0
- package/Step5/Addons/styles.ts +32 -0
- package/Step5/Step5.tsx +56 -2
- package/Step5/Summary/Amount/Amount.tsx +4 -3
- package/Step5/Summary/Amount/styles.ts +1 -1
- package/Step5/Summary/Summary.tsx +11 -1
- package/package.json +1 -1
- package/services.ts +15 -2
- package/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
All notable changes to this package are documented here. This project follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [1.6.0] - 2026-07-25
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Checkout addon selection (Step5).** Buyers can now add WhatsApp and/or
|
|
11
|
+
Telegram notification addons at checkout, if the label has them enabled
|
|
12
|
+
(`SettingsData.addons` from `@autobusal/providers`). WhatsApp collects a
|
|
13
|
+
phone number inline; Telegram is a "connect after purchase" flow (a bot
|
|
14
|
+
can't message a bare phone number cold). Selected addons render as line
|
|
15
|
+
items in the order summary and are folded into the grand total shown
|
|
16
|
+
before payment - pricing/availability is re-verified server-side, this is
|
|
17
|
+
only what the buyer requests. `usePostOrder` now takes an `AddonsSelection`
|
|
18
|
+
and posts `addon_whatsapp`/`addon_whatsapp_number`/`addon_telegram`.
|
|
19
|
+
|
|
20
|
+
## [1.5.0] - 2026-07-25
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Per-route passenger details in Step4.** The passenger form now renders
|
|
25
|
+
sex, passport, phone, and the new WhatsApp/Telegram contact fields ONLY
|
|
26
|
+
when the route's `trip.passenger_fields` requires them (then required);
|
|
27
|
+
name and date of birth stay always-mandatory. A return trip uses the
|
|
28
|
+
union of both legs' configs. Routes without a config keep the pre-feature
|
|
29
|
+
form (sex + passport). Request field suffixes: `_wa`, `_tg`.
|
|
30
|
+
|
|
6
31
|
## [1.4.2] - 2026-07-19
|
|
7
32
|
|
|
8
33
|
### Added
|
|
@@ -10,6 +10,7 @@ interface Props {
|
|
|
10
10
|
type: 'adult' | 'child' | 'baby'
|
|
11
11
|
number: number
|
|
12
12
|
values?: PersonData
|
|
13
|
+
fields: string[]
|
|
13
14
|
withReturn: boolean
|
|
14
15
|
busFrom: OccupiedData
|
|
15
16
|
busTo: OccupiedData
|
|
@@ -22,7 +23,13 @@ interface Props {
|
|
|
22
23
|
onUpdate: (name: string, value: (string | number)) => void
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
27
|
+
// The route now decides which passenger details it collects
|
|
28
|
+
// (routes_trip.passenger_fields): sex, passport, phone, whatsapp and
|
|
29
|
+
// telegram render ONLY when the route requires them - and are then
|
|
30
|
+
// required. Name and date of birth stay mandatory always. `fields` is the
|
|
31
|
+
// union of both legs on a return trip (see Step4).
|
|
32
|
+
const Passenger = ({ type, number, values, fields, withReturn, busFrom, busTo, pickedFrom, pickedTo, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
|
|
26
33
|
const name = `${ type }${ number }`;
|
|
27
34
|
|
|
28
35
|
const names = {
|
|
@@ -32,10 +39,14 @@ const Passenger = ({ type, number, values, withReturn, busFrom, busTo, pickedFro
|
|
|
32
39
|
sex: `${ name }_sex`,
|
|
33
40
|
passport: `${ name }_pass`,
|
|
34
41
|
phone: `${ name }_phone`,
|
|
42
|
+
whatsapp: `${ name }_wa`,
|
|
43
|
+
telegram: `${ name }_tg`,
|
|
35
44
|
seatDeparture: `${ name }_sd`,
|
|
36
45
|
seatReturn: `${ name }_sr`
|
|
37
46
|
};
|
|
38
47
|
|
|
48
|
+
const has = (field: string): boolean => fields.includes(field);
|
|
49
|
+
|
|
39
50
|
return (
|
|
40
51
|
<Container className="box">
|
|
41
52
|
<Title>
|
|
@@ -85,45 +96,85 @@ const Passenger = ({ type, number, values, withReturn, busFrom, busTo, pickedFro
|
|
|
85
96
|
{ Display(errors[names.dob]) }
|
|
86
97
|
</div>
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
{ has('sex') && (
|
|
100
|
+
<div className="row">
|
|
101
|
+
{ t('routes_order.step4.gender') }
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
<Gender
|
|
104
|
+
name={ names.sex }
|
|
105
|
+
defaultValue={ values !== undefined ? Number(values[names.sex]) : undefined }
|
|
106
|
+
t={ t }
|
|
107
|
+
refs={ refs }
|
|
108
|
+
/>
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
{ Display(errors[names.sex]) }
|
|
111
|
+
</div>
|
|
112
|
+
) }
|
|
100
113
|
</div>
|
|
101
|
-
|
|
102
|
-
<div className="column">
|
|
103
|
-
<div className="row">
|
|
104
|
-
{ t('routes_order.step4.passport') }
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
{ (has('passport') || has('phone')) && (
|
|
116
|
+
<div className="column">
|
|
117
|
+
{ has('passport') && (
|
|
118
|
+
<div className="row">
|
|
119
|
+
{ t('routes_order.step4.passport') }
|
|
120
|
+
|
|
121
|
+
<input
|
|
122
|
+
type="text"
|
|
123
|
+
defaultValue={ values !== undefined ? values[names.passport] : '' }
|
|
124
|
+
{ ...refs(names.passport, Validate('required|min_length:2|max_length:50', t)) }
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
{ Display(errors[names.passport]) }
|
|
128
|
+
</div>
|
|
129
|
+
) }
|
|
130
|
+
|
|
131
|
+
{ has('phone') && (
|
|
132
|
+
<div className="row">
|
|
133
|
+
{ t('routes_order.step4.phone') }
|
|
134
|
+
|
|
135
|
+
<input
|
|
136
|
+
type="text"
|
|
137
|
+
defaultValue={ values !== undefined ? values[names.phone] : '' }
|
|
138
|
+
{ ...refs(names.phone, Validate('required|min_length:5|max_length:50', t)) }
|
|
139
|
+
/>
|
|
111
140
|
|
|
112
|
-
|
|
141
|
+
{ Display(errors[names.phone]) }
|
|
142
|
+
</div>
|
|
143
|
+
) }
|
|
113
144
|
</div>
|
|
145
|
+
) }
|
|
114
146
|
|
|
115
|
-
|
|
116
|
-
|
|
147
|
+
{ (has('whatsapp') || has('telegram')) && (
|
|
148
|
+
<div className="column">
|
|
149
|
+
{ has('whatsapp') && (
|
|
150
|
+
<div className="row">
|
|
151
|
+
{ t('routes_order.step4.whatsapp') }
|
|
117
152
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
<input
|
|
154
|
+
type="text"
|
|
155
|
+
defaultValue={ values !== undefined ? values[names.whatsapp] : '' }
|
|
156
|
+
{ ...refs(names.whatsapp, Validate('required|min_length:5|max_length:50', t)) }
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
{ Display(errors[names.whatsapp]) }
|
|
160
|
+
</div>
|
|
161
|
+
) }
|
|
162
|
+
|
|
163
|
+
{ has('telegram') && (
|
|
164
|
+
<div className="row">
|
|
165
|
+
{ t('routes_order.step4.telegram') }
|
|
123
166
|
|
|
124
|
-
|
|
167
|
+
<input
|
|
168
|
+
type="text"
|
|
169
|
+
defaultValue={ values !== undefined ? values[names.telegram] : '' }
|
|
170
|
+
{ ...refs(names.telegram, Validate('required|min_length:2|max_length:100', t)) }
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
{ Display(errors[names.telegram]) }
|
|
174
|
+
</div>
|
|
175
|
+
) }
|
|
125
176
|
</div>
|
|
126
|
-
|
|
177
|
+
) }
|
|
127
178
|
|
|
128
179
|
{ type !== 'baby' && (
|
|
129
180
|
<div className="column">
|
|
@@ -170,4 +221,4 @@ const Passenger = ({ type, number, values, withReturn, busFrom, busTo, pickedFro
|
|
|
170
221
|
);
|
|
171
222
|
};
|
|
172
223
|
|
|
173
|
-
export default Passenger;
|
|
224
|
+
export default Passenger;
|
|
@@ -7,6 +7,7 @@ import { OccupiedData } from '@autobusal/providers/types/buses';
|
|
|
7
7
|
interface Props {
|
|
8
8
|
type: 'adult' | 'child' | 'baby'
|
|
9
9
|
amount: number
|
|
10
|
+
fields: string[]
|
|
10
11
|
withReturn: boolean
|
|
11
12
|
values?: PersonData
|
|
12
13
|
busFrom: OccupiedData
|
|
@@ -20,7 +21,7 @@ interface Props {
|
|
|
20
21
|
onUpdate: (name: string, value: (string | number)) => void
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const Display = ({ type, amount, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
24
|
+
const Display = ({ type, amount, fields, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
24
25
|
const passengers: JSX.Element[] = [];
|
|
25
26
|
|
|
26
27
|
for (let i = 1; i <= amount; i++) {
|
|
@@ -29,6 +30,7 @@ const Display = ({ type, amount, withReturn, values, busFrom, busTo, pickedFrom,
|
|
|
29
30
|
key={ i }
|
|
30
31
|
type={ type }
|
|
31
32
|
number={ i }
|
|
33
|
+
fields={ fields }
|
|
32
34
|
withReturn={ withReturn }
|
|
33
35
|
values={ values }
|
|
34
36
|
busFrom={ busFrom }
|
|
@@ -10,6 +10,7 @@ import { OccupiedData } from '@autobusal/providers/types/buses';
|
|
|
10
10
|
interface Props {
|
|
11
11
|
values?: PersonData
|
|
12
12
|
step1: RoutesSearchForm
|
|
13
|
+
fields: string[]
|
|
13
14
|
busFrom: OccupiedData
|
|
14
15
|
busTo: OccupiedData
|
|
15
16
|
t: TFunction<'public'>
|
|
@@ -18,7 +19,7 @@ interface Props {
|
|
|
18
19
|
onUpdate: (name: string, value: (string | number)) => void
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const Passengers = ({ values, step1, busFrom, busTo, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
22
|
+
const Passengers = ({ values, step1, fields, busFrom, busTo, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
22
23
|
const [ pickedFrom, setPickedFrom ] = useState<number[]>([]);
|
|
23
24
|
const [ pickedTo, setPickedTo ] = useState<number[]>([]);
|
|
24
25
|
|
|
@@ -45,6 +46,7 @@ const Passengers = ({ values, step1, busFrom, busTo, t, errors, refs, onUpdate }
|
|
|
45
46
|
<Display
|
|
46
47
|
type="adult"
|
|
47
48
|
amount={ step1.adults }
|
|
49
|
+
fields={ fields }
|
|
48
50
|
withReturn={ step1.type === 'return' }
|
|
49
51
|
values={ values }
|
|
50
52
|
busFrom={ busFrom }
|
|
@@ -61,6 +63,7 @@ const Passengers = ({ values, step1, busFrom, busTo, t, errors, refs, onUpdate }
|
|
|
61
63
|
<Display
|
|
62
64
|
type="child"
|
|
63
65
|
amount={ step1.children }
|
|
66
|
+
fields={ fields }
|
|
64
67
|
withReturn={ step1.type === 'return' }
|
|
65
68
|
values={ values }
|
|
66
69
|
busFrom={ busFrom }
|
|
@@ -77,6 +80,7 @@ const Passengers = ({ values, step1, busFrom, busTo, t, errors, refs, onUpdate }
|
|
|
77
80
|
<Display
|
|
78
81
|
type="baby"
|
|
79
82
|
amount={ step1.babies }
|
|
83
|
+
fields={ fields }
|
|
80
84
|
withReturn={ step1.type === 'return' }
|
|
81
85
|
values={ values }
|
|
82
86
|
busFrom={ busFrom }
|
package/Step4/Step4.tsx
CHANGED
|
@@ -25,9 +25,21 @@ interface Props {
|
|
|
25
25
|
onBack: (step: number) => void
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
29
|
+
// Routes without a passenger_fields config keep the pre-feature form
|
|
30
|
+
const DEFAULT_FIELDS = ['sex', 'passport'];
|
|
31
|
+
|
|
28
32
|
const Step4 = ({ values, step1, step2, step3, isPartner, t, onSave, onBack }: Props): JSX.Element => {
|
|
29
33
|
const { register, handleSubmit, formState: { errors }, setValue, setError } = useForm<PersonData>();
|
|
30
34
|
|
|
35
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
36
|
+
// one form serves both legs of a return trip, so it collects the UNION of
|
|
37
|
+
// what either route requires (mirrored by the backend rules)
|
|
38
|
+
const fields = Array.from(new Set([
|
|
39
|
+
...(step2.trip?.passenger_fields ?? DEFAULT_FIELDS),
|
|
40
|
+
...(step3 !== undefined ? (step3.trip?.passenger_fields ?? DEFAULT_FIELDS) : [])
|
|
41
|
+
]));
|
|
42
|
+
|
|
31
43
|
const busFrom = useBusOccupied(step1.departure, step2.id);
|
|
32
44
|
const busTo = useBusOccupied(step1._return, step3?.id);
|
|
33
45
|
|
|
@@ -63,6 +75,7 @@ const Step4 = ({ values, step1, step2, step3, isPartner, t, onSave, onBack }: Pr
|
|
|
63
75
|
<Passengers
|
|
64
76
|
values={ values }
|
|
65
77
|
step1={ step1 }
|
|
78
|
+
fields={ fields }
|
|
66
79
|
busFrom={ busFrom }
|
|
67
80
|
busTo={ busTo }
|
|
68
81
|
t={ t }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TFunction } from 'i18next';
|
|
2
|
+
import { RiWhatsappLine, RiTelegramLine } from 'react-icons/ri';
|
|
3
|
+
import { SubTitle } from '../../styles';
|
|
4
|
+
import { Container, Option, Notice } from './styles';
|
|
5
|
+
|
|
6
|
+
interface AddonsData {
|
|
7
|
+
whatsapp: { available: boolean, price: number }
|
|
8
|
+
telegram: { available: boolean, price: number }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
data?: AddonsData
|
|
13
|
+
currency: string
|
|
14
|
+
whatsapp: boolean
|
|
15
|
+
whatsappNumber: string
|
|
16
|
+
telegram: boolean
|
|
17
|
+
t: TFunction<'common'>
|
|
18
|
+
onChangeWhatsapp: (value: boolean) => void
|
|
19
|
+
onChangeWhatsappNumber: (value: string) => void
|
|
20
|
+
onChangeTelegram: (value: boolean) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
24
|
+
// Paid checkout addons - hidden entirely if the label has neither channel
|
|
25
|
+
// enabled (an empty box would be confusing). The WhatsApp addon collects
|
|
26
|
+
// the number here; Telegram is a "connect after purchase" flow (a bot
|
|
27
|
+
// 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)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Container className="box">
|
|
35
|
+
<SubTitle>
|
|
36
|
+
{ t('routes_order.step5.addons.title') }
|
|
37
|
+
</SubTitle>
|
|
38
|
+
|
|
39
|
+
{ data.whatsapp.available && (
|
|
40
|
+
<Option>
|
|
41
|
+
<label>
|
|
42
|
+
<input
|
|
43
|
+
type="checkbox"
|
|
44
|
+
checked={ whatsapp }
|
|
45
|
+
onChange={ (event) => onChangeWhatsapp(event.target.checked) }
|
|
46
|
+
/>
|
|
47
|
+
|
|
48
|
+
<RiWhatsappLine />
|
|
49
|
+
|
|
50
|
+
{ t('routes_order.step5.addons.whatsapp.label') } (+{ data.whatsapp.price } { currency })
|
|
51
|
+
</label>
|
|
52
|
+
|
|
53
|
+
<Notice>{ t('routes_order.step5.addons.whatsapp.description') }</Notice>
|
|
54
|
+
|
|
55
|
+
{ whatsapp && (
|
|
56
|
+
<input
|
|
57
|
+
type="text"
|
|
58
|
+
placeholder={ t('routes_order.step5.addons.whatsapp.phone_placeholder') }
|
|
59
|
+
value={ whatsappNumber }
|
|
60
|
+
onChange={ (event) => onChangeWhatsappNumber(event.target.value) }
|
|
61
|
+
/>
|
|
62
|
+
) }
|
|
63
|
+
</Option>
|
|
64
|
+
) }
|
|
65
|
+
|
|
66
|
+
{ data.telegram.available && (
|
|
67
|
+
<Option>
|
|
68
|
+
<label>
|
|
69
|
+
<input
|
|
70
|
+
type="checkbox"
|
|
71
|
+
checked={ telegram }
|
|
72
|
+
onChange={ (event) => onChangeTelegram(event.target.checked) }
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<RiTelegramLine />
|
|
76
|
+
|
|
77
|
+
{ t('routes_order.step5.addons.telegram.label') } (+{ data.telegram.price } { currency })
|
|
78
|
+
</label>
|
|
79
|
+
|
|
80
|
+
<Notice>{ t('routes_order.step5.addons.telegram.description') }</Notice>
|
|
81
|
+
</Option>
|
|
82
|
+
) }
|
|
83
|
+
</Container>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default Addons;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
margin-top: 25px;
|
|
5
|
+
`;
|
|
6
|
+
|
|
7
|
+
export const Option = styled.div`
|
|
8
|
+
margin-top: 15px;
|
|
9
|
+
|
|
10
|
+
&:first-child {
|
|
11
|
+
margin-top: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
label {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 6px;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
font-weight: 700;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
input[type="text"] {
|
|
23
|
+
margin-top: 10px;
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export const Notice = styled.div`
|
|
28
|
+
margin-top: 4px;
|
|
29
|
+
margin-left: 22px;
|
|
30
|
+
font-size: ${ props => props.theme.size.xs };
|
|
31
|
+
color: ${ props => props.theme.font.faded };
|
|
32
|
+
`;
|
package/Step5/Step5.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { Button } from '@autobusal/common';
|
|
|
9
9
|
import Steps from '../Steps/Steps';
|
|
10
10
|
import Summary from './Summary/Summary';
|
|
11
11
|
import Coupons from './Coupons/Coupons';
|
|
12
|
+
import Addons from './Addons/Addons';
|
|
12
13
|
import Billing from './Billing/Billing';
|
|
13
14
|
import Payments from './Payments/Payments';
|
|
14
15
|
import { getTotal, convertCoupon } from './utilities';
|
|
@@ -19,6 +20,7 @@ import { CouponData } from '@autobusal/providers/types/orders';
|
|
|
19
20
|
import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
20
21
|
import { TotalData } from '../types';
|
|
21
22
|
import { useUserStore } from '@autobusal/providers/stores/user';
|
|
23
|
+
import { useGetSettings } from '@autobusal/providers/services';
|
|
22
24
|
import { usePostOrder } from '../services';
|
|
23
25
|
|
|
24
26
|
interface Props {
|
|
@@ -38,13 +40,46 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
|
|
|
38
40
|
);
|
|
39
41
|
const [ action, setAction ] = useState<string | undefined>(undefined);
|
|
40
42
|
|
|
43
|
+
const [ addonWhatsapp, setAddonWhatsapp ] = useState<boolean>(false);
|
|
44
|
+
const [ addonWhatsappNumber, setAddonWhatsappNumber ] = useState<string>('');
|
|
45
|
+
const [ addonTelegram, setAddonTelegram ] = useState<boolean>(false);
|
|
46
|
+
|
|
41
47
|
const { data: UserData } = useUserStore();
|
|
48
|
+
const { data: SettingsData } = useGetSettings();
|
|
42
49
|
|
|
43
50
|
const navigate = useNavigate();
|
|
44
51
|
|
|
45
52
|
const { register, handleSubmit, formState: { errors } } = useForm<BillingData>();
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
55
|
+
// Paid addons are priced/verified server-side (see obtapi's
|
|
56
|
+
// App\Libraries\Orders\Make\Addons) - this is only for the checkout
|
|
57
|
+
// summary display and the grand total shown before payment.
|
|
58
|
+
const currency = total.display.split(' ')[1] ?? '';
|
|
59
|
+
|
|
60
|
+
const addonItems = [
|
|
61
|
+
addonWhatsapp && SettingsData?.addons?.whatsapp.available ? {
|
|
62
|
+
label: t('routes_order.step5.addons.whatsapp.label'),
|
|
63
|
+
price: SettingsData.addons.whatsapp.price
|
|
64
|
+
} : null,
|
|
65
|
+
addonTelegram && SettingsData?.addons?.telegram.available ? {
|
|
66
|
+
label: t('routes_order.step5.addons.telegram.label'),
|
|
67
|
+
price: SettingsData.addons.telegram.price
|
|
68
|
+
} : null
|
|
69
|
+
].filter((item): item is { label: string, price: number } => item !== null);
|
|
70
|
+
|
|
71
|
+
const addonsValue = addonItems.reduce((sum, item) => sum + item.price, 0);
|
|
72
|
+
|
|
73
|
+
const grandTotal: TotalData = {
|
|
74
|
+
display: `${ total.value + addonsValue } ${ currency }`,
|
|
75
|
+
value: total.value + addonsValue
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const { mutate: OrderSend, isPending } = usePostOrder(step1, step2, step3, step4, coupon, action, {
|
|
79
|
+
whatsapp: addonWhatsapp,
|
|
80
|
+
whatsappNumber: addonWhatsappNumber,
|
|
81
|
+
telegram: addonTelegram
|
|
82
|
+
});
|
|
48
83
|
|
|
49
84
|
const onApplyCoupon = (data: CouponData): void => {
|
|
50
85
|
setTotal(
|
|
@@ -59,6 +94,12 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
|
|
|
59
94
|
}, []);
|
|
60
95
|
|
|
61
96
|
const onSendForm = async (): Promise<void> => {
|
|
97
|
+
if (addonWhatsapp && addonWhatsappNumber.trim().length < 5) {
|
|
98
|
+
alert(t('routes_order.step5.addons.whatsapp.phone_error'));
|
|
99
|
+
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
await handleSubmit(onSubmit)();
|
|
63
104
|
};
|
|
64
105
|
|
|
@@ -91,12 +132,25 @@ const Step5 = ({ step1, step2, step3, step4, isPartner, t, onBack }: Props): JSX
|
|
|
91
132
|
step3={ step3 }
|
|
92
133
|
step4={ step4 }
|
|
93
134
|
coupon={ coupon }
|
|
94
|
-
|
|
135
|
+
addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
|
|
136
|
+
total={ grandTotal }
|
|
95
137
|
t={ t }
|
|
96
138
|
/>
|
|
97
139
|
|
|
98
140
|
<Coupons current={ coupon } step2={ step2 } total={ total } t={ t } onApplied={ onApplyCoupon } />
|
|
99
141
|
|
|
142
|
+
<Addons
|
|
143
|
+
data={ SettingsData?.addons }
|
|
144
|
+
currency={ currency }
|
|
145
|
+
whatsapp={ addonWhatsapp }
|
|
146
|
+
whatsappNumber={ addonWhatsappNumber }
|
|
147
|
+
telegram={ addonTelegram }
|
|
148
|
+
t={ t }
|
|
149
|
+
onChangeWhatsapp={ setAddonWhatsapp }
|
|
150
|
+
onChangeWhatsappNumber={ setAddonWhatsappNumber }
|
|
151
|
+
onChangeTelegram={ setAddonTelegram }
|
|
152
|
+
/>
|
|
153
|
+
|
|
100
154
|
{ UserData === undefined && <Billing t={ t } errors={ errors } refs={ register } /> }
|
|
101
155
|
|
|
102
156
|
<Payments
|
|
@@ -2,15 +2,16 @@ import { TFunction } from 'i18next';
|
|
|
2
2
|
import { Container, Value, Code } from './styles';
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
|
-
type: 'total' | 'discount'
|
|
5
|
+
type: 'total' | 'discount' | 'addon'
|
|
6
6
|
value: string
|
|
7
7
|
code?: string
|
|
8
|
+
label?: string
|
|
8
9
|
t: TFunction<'common'>
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const Amount = ({ type, value, code, t }: Props): JSX.Element => (
|
|
12
|
+
const Amount = ({ type, value, code, label, t }: Props): JSX.Element => (
|
|
12
13
|
<Container $type={ type }>
|
|
13
|
-
{ t(`routes_order.step5.summary.${ type }`) }
|
|
14
|
+
{ label ?? t(`routes_order.step5.summary.${ type }`) }
|
|
14
15
|
|
|
15
16
|
{ code && <Code>{ code }</Code> }
|
|
16
17
|
|
|
@@ -11,17 +11,23 @@ import { PersonData } from '@autobusal/providers/types/persons';
|
|
|
11
11
|
import { CouponData } from '@autobusal/providers/types/orders';
|
|
12
12
|
import { TotalData } from '../../types';
|
|
13
13
|
|
|
14
|
+
interface AddonItem {
|
|
15
|
+
label: string
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
interface Props {
|
|
15
20
|
step1: RoutesSearchForm
|
|
16
21
|
step2: FoundData
|
|
17
22
|
step3?: FoundData
|
|
18
23
|
step4: PersonData
|
|
19
24
|
coupon?: CouponData
|
|
25
|
+
addons?: AddonItem[]
|
|
20
26
|
total: TotalData
|
|
21
27
|
t: TFunction<'common'>
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
const Summary = ({ step1, step2, step3, step4, coupon, total, t }: Props): JSX.Element => (
|
|
30
|
+
const Summary = ({ step1, step2, step3, step4, coupon, addons, total, t }: Props): JSX.Element => (
|
|
25
31
|
<div className="box">
|
|
26
32
|
<SubTitle>
|
|
27
33
|
<AiOutlineInfoCircle />
|
|
@@ -35,6 +41,10 @@ const Summary = ({ step1, step2, step3, step4, coupon, total, t }: Props): JSX.E
|
|
|
35
41
|
|
|
36
42
|
<Tickets step1={ step1 } step4={ step4 } t={ t } />
|
|
37
43
|
|
|
44
|
+
{ addons?.map(item => (
|
|
45
|
+
<Amount key={ item.label } type="addon" label={ item.label } value={ item.value } t={ t } />
|
|
46
|
+
)) }
|
|
47
|
+
|
|
38
48
|
{ coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
|
|
39
49
|
|
|
40
50
|
<Amount type="total" value={ total.display } t={ t } />
|
package/package.json
CHANGED
package/services.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { RoutesSearchForm } from '@autobusal/routes-search/types';
|
|
|
4
4
|
import { FoundData } from '@autobusal/providers/types/routes';
|
|
5
5
|
import { CouponData } from '@autobusal/providers/types/orders';
|
|
6
6
|
import { BillingData, PersonData } from '@autobusal/providers/types/persons';
|
|
7
|
-
import { FoundDay, ActionData, SaveData, OrderedData, CouponForm } from './types';
|
|
7
|
+
import { FoundDay, ActionData, SaveData, OrderedData, CouponForm, AddonsSelection } from './types';
|
|
8
8
|
|
|
9
9
|
export const useGetDepartures = (data: RoutesSearchForm): UseQueryResult<FoundData[]> => (
|
|
10
10
|
useQuery({
|
|
@@ -74,7 +74,8 @@ export const usePostOrder = (
|
|
|
74
74
|
step3: (FoundData | undefined),
|
|
75
75
|
step4: PersonData,
|
|
76
76
|
coupon: (CouponData | undefined),
|
|
77
|
-
action: (string | undefined)
|
|
77
|
+
action: (string | undefined),
|
|
78
|
+
addons: AddonsSelection
|
|
78
79
|
): UseMutationResult<OrderedData, Error, BillingData, unknown> => (
|
|
79
80
|
useMutation({
|
|
80
81
|
mutationKey: ['save-order'],
|
|
@@ -95,6 +96,18 @@ export const usePostOrder = (
|
|
|
95
96
|
order.coupon = coupon.code;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
100
|
+
// Paid addons - obtapi re-verifies availability/price server-side
|
|
101
|
+
// against the label's own settings, this is just the buyer's request
|
|
102
|
+
if (addons.whatsapp && addons.whatsappNumber) {
|
|
103
|
+
order.addon_whatsapp = '1';
|
|
104
|
+
order.addon_whatsapp_number = addons.whatsappNumber;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (addons.telegram) {
|
|
108
|
+
order.addon_telegram = '1';
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
Object.keys(step4).forEach(index => {
|
|
99
112
|
order[index] = step4[index];
|
|
100
113
|
});
|
package/types.ts
CHANGED
|
@@ -33,4 +33,12 @@ export interface OrderedData {
|
|
|
33
33
|
export interface TotalData {
|
|
34
34
|
display: string
|
|
35
35
|
value: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Edited: Ferjolt Ozuni - Date: 2026-07-25
|
|
39
|
+
// The buyer's paid-addon selection at checkout - see Step5/Addons/Addons.tsx
|
|
40
|
+
export interface AddonsSelection {
|
|
41
|
+
whatsapp: boolean
|
|
42
|
+
whatsappNumber: string
|
|
43
|
+
telegram: boolean
|
|
36
44
|
}
|