@autobusal/routes-order 1.34.5 → 1.36.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/Checkout/Checkout.tsx +18 -3
- package/Found/Found.tsx +3 -1
- package/Found/Header/Header.tsx +73 -12
- package/Found/Header/styles.ts +31 -1
- package/Found/refine.ts +90 -2
- package/Step2.tsx +11 -2
- package/Step4/Passenger/Passenger.tsx +15 -3
- package/Step4/Passengers/Display.tsx +3 -1
- package/Step4/Passengers/Passengers.tsx +5 -1
- package/Step5/Payments/Payments.tsx +24 -4
- package/commerce.ts +20 -0
- package/package.json +5 -2
- package/CHANGELOG.md +0 -870
package/Checkout/Checkout.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import { useUserStore } from '@autobusal/providers/stores/user';
|
|
|
27
27
|
import { useGetSettings } from '@autobusal/providers/services';
|
|
28
28
|
import { usePostOrder, useGetFlex, useGetPayments, useGetSavedPassengers, useGetComission } from '../services';
|
|
29
29
|
import { addon as commerceAddon, beginCheckout, stash } from '@autobusal/providers/Setup/commerce';
|
|
30
|
-
import { item as commerceItem } from '../commerce';
|
|
30
|
+
import { item as commerceItem, isCounterSale } from '../commerce';
|
|
31
31
|
import { error as notifyError } from '@autobusal/utilities';
|
|
32
32
|
|
|
33
33
|
interface Props {
|
|
@@ -572,7 +572,11 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
572
572
|
* counter-sold ticket reaches the passenger directly instead of
|
|
573
573
|
* landing solely with the agent and the operator.
|
|
574
574
|
*/
|
|
575
|
-
askEmail={
|
|
575
|
+
askEmail={ isCounterSale(UserData?.type) }
|
|
576
|
+
/* same population, second reason: a walk-in often has no number
|
|
577
|
+
to give either, and a clerk forced to fill the box types a
|
|
578
|
+
placeholder that looks real to everyone downstream */
|
|
579
|
+
phoneOptional={ isCounterSale(UserData?.type) }
|
|
576
580
|
busFrom={ busFrom }
|
|
577
581
|
busTo={ busTo }
|
|
578
582
|
saved={ saved }
|
|
@@ -654,7 +658,18 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
|
|
|
654
658
|
/>
|
|
655
659
|
) }
|
|
656
660
|
>
|
|
657
|
-
|
|
661
|
+
{/* Claude - 2026-08-30 (raised by Ferjolt: "why should an operator
|
|
662
|
+
have a coupon code while they do not pay for their own
|
|
663
|
+
tickets?"). A complimentary ticket costs nothing, so there is no
|
|
664
|
+
total for a discount to come off - the box was asking for a code
|
|
665
|
+
that could not do anything.
|
|
666
|
+
|
|
667
|
+
Only the FREE case is hidden, not coupons for operators. On
|
|
668
|
+
another operator's route an operator is an ordinary paying buyer
|
|
669
|
+
and a coupon applies to them like anyone else. */}
|
|
670
|
+
{ action !== 'complimentary' && (
|
|
671
|
+
<Coupons current={ coupon } step2={ step2 } total={ total } t={ t } onApplied={ onApplyCoupon } />
|
|
672
|
+
) }
|
|
658
673
|
</Sidebar>
|
|
659
674
|
</Results>
|
|
660
675
|
|
package/Found/Found.tsx
CHANGED
|
@@ -182,7 +182,9 @@ const Found = ({ type, loading, data, step1, preferredStop, t, onSearch, onSave
|
|
|
182
182
|
) }
|
|
183
183
|
|
|
184
184
|
<Listing>
|
|
185
|
-
|
|
185
|
+
{/* the same refinement the Sort dropdown drives, so the two stay
|
|
186
|
+
in step and the URL keeps carrying the choice (refine.ts) */}
|
|
187
|
+
<Header sort={ refinement.sort } t={ t } onSort={ sort => onRefine({ ...refinement, sort }) } />
|
|
186
188
|
|
|
187
189
|
<Orders
|
|
188
190
|
loading={ loading }
|
package/Found/Header/Header.tsx
CHANGED
|
@@ -2,20 +2,81 @@ import { TFunction } from 'i18next';
|
|
|
2
2
|
import { RiBus2Line } from 'react-icons/ri';
|
|
3
3
|
import { BiArrowFromLeft, BiHourglass } from 'react-icons/bi';
|
|
4
4
|
import { FaFlagCheckered, FaCoins } from 'react-icons/fa';
|
|
5
|
-
import {
|
|
5
|
+
import { SortKey } from '../refine';
|
|
6
|
+
import { Container, Company, Location, Time, Price, Direction } from './styles';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
9
|
+
sort: SortKey
|
|
8
10
|
t: TFunction<'public'>
|
|
11
|
+
onSort: (sort: SortKey) => void
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Claude - 2026-08-30 (asked for by Ferjolt): "the results header should be
|
|
16
|
+
* sortable on click".
|
|
17
|
+
*
|
|
18
|
+
* Each column owns a PAIR of sorts and clicking it walks between them, which
|
|
19
|
+
* is what a header that can only sort one way gets wrong - the second click
|
|
20
|
+
* looks broken. Landing on a column always starts with the direction people
|
|
21
|
+
* actually want (cheapest, fastest, earliest, first to arrive, A-Z); the
|
|
22
|
+
* reverse is only ever reached deliberately.
|
|
23
|
+
*
|
|
24
|
+
* `recommended` is not in any pair on purpose. It is the merit ordering the
|
|
25
|
+
* page opens with and it belongs to no single column, so clicking a header
|
|
26
|
+
* always leaves it - which is correct, the user just asked for something
|
|
27
|
+
* more specific.
|
|
28
|
+
*/
|
|
29
|
+
const COLUMNS: Record<string, [SortKey, SortKey]> = {
|
|
30
|
+
company: [ 'company', 'company_desc' ],
|
|
31
|
+
departs: [ 'earliest', 'latest' ],
|
|
32
|
+
duration: [ 'fastest', 'slowest' ],
|
|
33
|
+
arrives: [ 'arrives_first', 'arrives_last' ],
|
|
34
|
+
price: [ 'cheapest', 'dearest' ]
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const Header = ({ sort, t, onSort }: Props): JSX.Element => {
|
|
38
|
+
const next = (column: keyof typeof COLUMNS): SortKey => {
|
|
39
|
+
const [ first, second ] = COLUMNS[column];
|
|
40
|
+
|
|
41
|
+
// already on this column - flip it; otherwise start at its natural end
|
|
42
|
+
return sort === first ? second : first;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// the arrow shows only on the column in effect, so it says WHICH one it is
|
|
46
|
+
const arrow = (column: keyof typeof COLUMNS): (JSX.Element | null) => {
|
|
47
|
+
const [ first, second ] = COLUMNS[column];
|
|
48
|
+
|
|
49
|
+
if (sort !== first && sort !== second) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <Direction>{ sort === first ? '▲' : '▼' }</Direction>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Container className="box">
|
|
59
|
+
<Company type="button" onClick={ () => onSort(next('company')) }>
|
|
60
|
+
<RiBus2Line /> { t('routes_order.step2.header.company') } { arrow('company') }
|
|
61
|
+
</Company>
|
|
62
|
+
|
|
63
|
+
<Location type="button" onClick={ () => onSort(next('departs')) }>
|
|
64
|
+
<BiArrowFromLeft /> { t('routes_order.step2.header.departs') } { arrow('departs') }
|
|
65
|
+
</Location>
|
|
66
|
+
|
|
67
|
+
<Time type="button" onClick={ () => onSort(next('duration')) }>
|
|
68
|
+
<BiHourglass /> { t('routes_order.step2.header.duration') } { arrow('duration') }
|
|
69
|
+
</Time>
|
|
70
|
+
|
|
71
|
+
<Location type="button" onClick={ () => onSort(next('arrives')) }>
|
|
72
|
+
<FaFlagCheckered /> { t('routes_order.step2.header.arrives') } { arrow('arrives') }
|
|
73
|
+
</Location>
|
|
74
|
+
|
|
75
|
+
<Price type="button" onClick={ () => onSort(next('price')) }>
|
|
76
|
+
<FaCoins /> { t('routes_order.step2.header.price') } { arrow('price') }
|
|
77
|
+
</Price>
|
|
78
|
+
</Container>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default Header;
|
package/Found/Header/styles.ts
CHANGED
|
@@ -23,7 +23,16 @@ export const Container = styled.div`
|
|
|
23
23
|
}
|
|
24
24
|
`;
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Claude - 2026-08-30 (asked for by Ferjolt): each column sorts the list on
|
|
28
|
+
* click, so these are real <button>s - keyboard reachable and announced as
|
|
29
|
+
* controls, which a styled <div> with an onClick is not.
|
|
30
|
+
*
|
|
31
|
+
* `background: none; border: 0` because a button carries a chrome of its own
|
|
32
|
+
* that would otherwise break a row whose whole job is to line up invisibly
|
|
33
|
+
* over the cards below it. The geometry is unchanged.
|
|
34
|
+
*/
|
|
35
|
+
const Item = styled.button`
|
|
27
36
|
display: flex;
|
|
28
37
|
flex-direction: column;
|
|
29
38
|
gap: 4px;
|
|
@@ -31,11 +40,32 @@ const Item = styled.div`
|
|
|
31
40
|
align-items: center;
|
|
32
41
|
font-size: ${ props => props.theme.size.s };
|
|
33
42
|
|
|
43
|
+
background: none;
|
|
44
|
+
border: 0;
|
|
45
|
+
padding: 0;
|
|
46
|
+
font-family: inherit;
|
|
47
|
+
color: inherit;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
|
|
50
|
+
&:hover, &:focus-visible {
|
|
51
|
+
color: ${ props => props.theme.font.info };
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
& > svg {
|
|
35
55
|
font-size: ${ props => props.theme.size.xxl };
|
|
36
56
|
}
|
|
37
57
|
`;
|
|
38
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The arrow marking the column the list is currently sorted by, and which
|
|
61
|
+
* way. Only ever rendered on the active column - an arrow on every header
|
|
62
|
+
* says nothing about which one is in effect.
|
|
63
|
+
*/
|
|
64
|
+
export const Direction = styled.span`
|
|
65
|
+
font-size: ${ props => props.theme.size.xs };
|
|
66
|
+
line-height: 1;
|
|
67
|
+
`;
|
|
68
|
+
|
|
39
69
|
// these five must stay identical to their counterparts in
|
|
40
70
|
// Found/Route/styles.ts, or the labels stop sitting over their columns
|
|
41
71
|
export const Company = styled(Item)`
|
package/Found/refine.ts
CHANGED
|
@@ -18,12 +18,37 @@ import { FoundData } from '@autobusal/providers/types/routes';
|
|
|
18
18
|
* answer this question" as a pass, not a fail.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Claude - 2026-08-30 (asked for by Ferjolt): the results header is
|
|
23
|
+
* clickable, and a column header that sorts one way only is a header people
|
|
24
|
+
* click twice and think is broken. So every axis the header offers now has
|
|
25
|
+
* both directions.
|
|
26
|
+
*
|
|
27
|
+
* SORTS below is deliberately NOT extended - it drives the sort dropdown,
|
|
28
|
+
* and "Dearest first" is not something to offer as a considered choice. The
|
|
29
|
+
* reverse directions exist to serve a second click on a column, nothing else.
|
|
30
|
+
*/
|
|
31
|
+
export type SortKey = 'recommended' | 'cheapest' | 'fastest' | 'earliest' | 'latest'
|
|
32
|
+
| 'dearest' | 'slowest' | 'arrives_first' | 'arrives_last' | 'company' | 'company_desc';
|
|
22
33
|
|
|
23
34
|
export type BucketKey = '0' | '6' | '12' | '18';
|
|
24
35
|
|
|
25
36
|
export const SORTS: SortKey[] = [ 'recommended', 'cheapest', 'fastest', 'earliest', 'latest' ];
|
|
26
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Every sort the URL may legally carry - the dropdown's five plus the
|
|
40
|
+
* reverse directions the column headers reach.
|
|
41
|
+
*
|
|
42
|
+
* THIS IS A SEPARATE LIST ON PURPOSE, and getting it wrong is not visible
|
|
43
|
+
* from the code. Validation used to run against SORTS, so `?sort=dearest`
|
|
44
|
+
* parsed straight back to 'recommended': the header highlighted, the URL
|
|
45
|
+
* updated, and the list did not move. Found by clicking Price twice in a
|
|
46
|
+
* browser - a typecheck cannot see it, because both lists are SortKey[].
|
|
47
|
+
*/
|
|
48
|
+
export const VALID_SORTS: SortKey[] = [
|
|
49
|
+
...SORTS, 'dearest', 'slowest', 'arrives_first', 'arrives_last', 'company', 'company_desc'
|
|
50
|
+
];
|
|
51
|
+
|
|
27
52
|
export const BUCKETS: BucketKey[] = [ '0', '6', '12', '18' ];
|
|
28
53
|
|
|
29
54
|
export interface Refinement {
|
|
@@ -82,6 +107,25 @@ export const departureOf = (item: FoundData): number => (
|
|
|
82
107
|
minutesOf(item.locations.from.departure)
|
|
83
108
|
);
|
|
84
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Arrival as minutes from the DEPARTURE day's midnight, so an overnight trip
|
|
112
|
+
* landing at 06:00 tomorrow sorts after one landing at 23:00 tonight rather
|
|
113
|
+
* than before it. `arrival_offset` is the midnight count the timetable
|
|
114
|
+
* already carries - reading the clock face alone is exactly the mistake that
|
|
115
|
+
* made overnight routes look like the earliest arrivals on the page.
|
|
116
|
+
*/
|
|
117
|
+
export const arrivalOf = (item: FoundData): number => (
|
|
118
|
+
minutesOf(item.locations.to.arrival) + (item.arrival_offset ?? 0) * 1440
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Operator name, case-insensitive and locale-aware, so 'Ç' lands where an
|
|
123
|
+
* Albanian reader expects rather than after 'Z'.
|
|
124
|
+
*/
|
|
125
|
+
const byCompany = (a: FoundData, b: FoundData): number => (
|
|
126
|
+
(a.operator?.name ?? '').localeCompare(b.operator?.name ?? '', undefined, { sensitivity: 'base' })
|
|
127
|
+
);
|
|
128
|
+
|
|
85
129
|
/**
|
|
86
130
|
* Trip length in minutes, or null when obtapi could not compute one.
|
|
87
131
|
*
|
|
@@ -208,7 +252,7 @@ export const fromParams = (params: URLSearchParams): Refinement => {
|
|
|
208
252
|
const maxPrice = Number(params.get('max_price'));
|
|
209
253
|
|
|
210
254
|
return {
|
|
211
|
-
sort: sort &&
|
|
255
|
+
sort: sort && VALID_SORTS.includes(sort) ? sort : 'recommended',
|
|
212
256
|
operators: numbers('operators'),
|
|
213
257
|
buckets: (params.get('times') ?? '').split(',').filter(value => BUCKETS.includes(value as BucketKey)) as BucketKey[],
|
|
214
258
|
direct: params.get('direct') === '1',
|
|
@@ -351,6 +395,31 @@ const byDuration = (a: FoundData, b: FoundData): number => {
|
|
|
351
395
|
return first - second;
|
|
352
396
|
};
|
|
353
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Longest first - but a route with NO computable duration still sinks to the
|
|
400
|
+
* bottom. Negating byDuration would have floated those to the top instead,
|
|
401
|
+
* which is the same "-" sentinel bug byDuration exists to prevent, just at
|
|
402
|
+
* the other end of the list.
|
|
403
|
+
*/
|
|
404
|
+
const byDurationDesc = (a: FoundData, b: FoundData): number => {
|
|
405
|
+
const first = durationOf(a);
|
|
406
|
+
const second = durationOf(b);
|
|
407
|
+
|
|
408
|
+
if (first === null && second === null) {
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (first === null) {
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (second === null) {
|
|
417
|
+
return -1;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return second - first;
|
|
421
|
+
};
|
|
422
|
+
|
|
354
423
|
/**
|
|
355
424
|
* Edited: Ferjolt Ozuni - Date: 2026-08-01
|
|
356
425
|
*
|
|
@@ -373,6 +442,25 @@ const compare = (sort: SortKey) => (a: FoundData, b: FoundData): number => {
|
|
|
373
442
|
case 'fastest':
|
|
374
443
|
return byDuration(a, b) || (a.price.value - b.price.value);
|
|
375
444
|
|
|
445
|
+
// the reverse directions, reached by clicking a column header twice
|
|
446
|
+
case 'dearest':
|
|
447
|
+
return (b.price.value - a.price.value) || byDuration(a, b);
|
|
448
|
+
|
|
449
|
+
case 'slowest':
|
|
450
|
+
return byDurationDesc(a, b) || (a.price.value - b.price.value);
|
|
451
|
+
|
|
452
|
+
case 'arrives_first':
|
|
453
|
+
return (arrivalOf(a) - arrivalOf(b)) || (a.price.value - b.price.value);
|
|
454
|
+
|
|
455
|
+
case 'arrives_last':
|
|
456
|
+
return (arrivalOf(b) - arrivalOf(a)) || (a.price.value - b.price.value);
|
|
457
|
+
|
|
458
|
+
case 'company':
|
|
459
|
+
return byCompany(a, b) || (a.price.value - b.price.value);
|
|
460
|
+
|
|
461
|
+
case 'company_desc':
|
|
462
|
+
return -byCompany(a, b) || (a.price.value - b.price.value);
|
|
463
|
+
|
|
376
464
|
default:
|
|
377
465
|
return 0;
|
|
378
466
|
}
|
package/Step2.tsx
CHANGED
|
@@ -48,8 +48,17 @@ const Step2 = ({ step1, t, onSearch, onSave, onBack }: Props): JSX.Element => {
|
|
|
48
48
|
? t('routes_order.step2.title_pair', {
|
|
49
49
|
from: from.name,
|
|
50
50
|
to: to.name,
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/*
|
|
52
|
+
* Claude - 2026-08-30 (reported by Ferjolt): a COMMA before the
|
|
53
|
+
* country - "Tirana, Albania", not "Tirana Albania".
|
|
54
|
+
*
|
|
55
|
+
* The separator lives here rather than in the template because the
|
|
56
|
+
* country is dropped entirely when both cities share one, and a
|
|
57
|
+
* `{{from}}, {{from_country}}` template would leave the comma
|
|
58
|
+
* stranded on a domestic route.
|
|
59
|
+
*/
|
|
60
|
+
from_country: sameCountry ? '' : `, ${ from.country?.name ?? '' }`,
|
|
61
|
+
to_country: sameCountry ? '' : `, ${ to.country?.name ?? '' }`
|
|
53
62
|
})
|
|
54
63
|
: t('routes_order.step2.title');
|
|
55
64
|
|
|
@@ -24,6 +24,16 @@ interface Props {
|
|
|
24
24
|
* on purpose: a counter sale often has no email to give.
|
|
25
25
|
*/
|
|
26
26
|
askEmail?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Claude - 2026-08-30 (ruled by Ferjolt): the same counter sale may leave
|
|
29
|
+
* the passenger PHONE blank too. A walk-in often has no number to give,
|
|
30
|
+
* and a clerk forced to fill the box types a placeholder - which is worse
|
|
31
|
+
* than a blank, because it looks like a real number to everyone
|
|
32
|
+
* downstream. obtapi relaxes the matching rule for exactly this set of
|
|
33
|
+
* roles (RulesGeneral::COUNTER_SELLERS); if the two ever disagree the form
|
|
34
|
+
* stops asking for something the server still rejects.
|
|
35
|
+
*/
|
|
36
|
+
phoneOptional?: boolean
|
|
27
37
|
withReturn: boolean
|
|
28
38
|
busFrom: OccupiedData
|
|
29
39
|
busTo: OccupiedData
|
|
@@ -56,7 +66,7 @@ interface Props {
|
|
|
56
66
|
// telegram render ONLY when the route requires them - and are then
|
|
57
67
|
// required. Name and date of birth stay mandatory always. `fields` is the
|
|
58
68
|
// union of both legs on a return trip (see Step4).
|
|
59
|
-
const Passenger = ({ type, number, values, fields, askEmail, withReturn, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
|
|
69
|
+
const Passenger = ({ type, number, values, fields, askEmail, phoneOptional, withReturn, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element => {
|
|
60
70
|
const name = `${ type }${ number }`;
|
|
61
71
|
|
|
62
72
|
const names = {
|
|
@@ -331,14 +341,16 @@ const Passenger = ({ type, number, values, fields, askEmail, withReturn, busFrom
|
|
|
331
341
|
|
|
332
342
|
{ has('phone') && (
|
|
333
343
|
<div className="row">
|
|
334
|
-
|
|
344
|
+
{ phoneOptional
|
|
345
|
+
? <label htmlFor={ id(names.phone) }>{ t('routes_order.step4.phone') }</label>
|
|
346
|
+
: <Required htmlFor={ id(names.phone) }>{ t('routes_order.step4.phone') }</Required> }
|
|
335
347
|
|
|
336
348
|
<input
|
|
337
349
|
type="text"
|
|
338
350
|
key={ `${ names.phone }-${ applied }` }
|
|
339
351
|
id={ id(names.phone) }
|
|
340
352
|
defaultValue={ start(names.phone, record?.phone) }
|
|
341
|
-
{ ...refs(names.phone, Validate('required|min_length:5|max_length:50', t)) }
|
|
353
|
+
{ ...refs(names.phone, Validate(phoneOptional ? 'min_length:5|max_length:50' : 'required|min_length:5|max_length:50', t)) }
|
|
342
354
|
/>
|
|
343
355
|
|
|
344
356
|
{ Display(errors[names.phone]) }
|
|
@@ -10,6 +10,7 @@ interface Props {
|
|
|
10
10
|
fields: string[]
|
|
11
11
|
/** staff sellers are offered an optional passenger email (audit A4-05) */
|
|
12
12
|
askEmail?: boolean
|
|
13
|
+
phoneOptional?: boolean
|
|
13
14
|
withReturn: boolean
|
|
14
15
|
values?: PersonData
|
|
15
16
|
busFrom: OccupiedData
|
|
@@ -27,7 +28,7 @@ interface Props {
|
|
|
27
28
|
onUpdate: (name: string, value: (string | number)) => void
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
const Display = ({ type, amount, fields, askEmail, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
31
|
+
const Display = ({ type, amount, fields, askEmail, phoneOptional, withReturn, values, busFrom, busTo, pickedFrom, pickedTo, saved, savedMax, seatsOptional, seatsFee, t, errors, refs, onSeatSelect, onUpdate }: Props): JSX.Element[] => {
|
|
31
32
|
const passengers: JSX.Element[] = [];
|
|
32
33
|
|
|
33
34
|
for (let i = 1; i <= amount; i++) {
|
|
@@ -38,6 +39,7 @@ const Display = ({ type, amount, fields, askEmail, withReturn, values, busFrom,
|
|
|
38
39
|
number={ i }
|
|
39
40
|
fields={ fields }
|
|
40
41
|
askEmail={ askEmail }
|
|
42
|
+
phoneOptional={ phoneOptional }
|
|
41
43
|
withReturn={ withReturn }
|
|
42
44
|
values={ values }
|
|
43
45
|
busFrom={ busFrom }
|
|
@@ -13,6 +13,7 @@ interface Props {
|
|
|
13
13
|
fields: string[]
|
|
14
14
|
/** staff sellers are offered an optional passenger email (audit A4-05) */
|
|
15
15
|
askEmail?: boolean
|
|
16
|
+
phoneOptional?: boolean
|
|
16
17
|
busFrom: OccupiedData
|
|
17
18
|
busTo: OccupiedData
|
|
18
19
|
/**
|
|
@@ -40,7 +41,7 @@ interface Props {
|
|
|
40
41
|
onUpdate: (name: string, value: (string | number)) => void
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const Passengers = ({ values, step1, fields, askEmail, busFrom, busTo, saved, savedMax, seatsOptional, seatsFee, onPickedCount, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
44
|
+
const Passengers = ({ values, step1, fields, askEmail, phoneOptional, busFrom, busTo, saved, savedMax, seatsOptional, seatsFee, onPickedCount, t, errors, refs, onUpdate }: Props): JSX.Element => {
|
|
44
45
|
const [ pickedFrom, setPickedFrom ] = useState<number[]>([]);
|
|
45
46
|
const [ pickedTo, setPickedTo ] = useState<number[]>([]);
|
|
46
47
|
|
|
@@ -87,6 +88,7 @@ const Passengers = ({ values, step1, fields, askEmail, busFrom, busTo, saved, sa
|
|
|
87
88
|
amount={ step1.adults }
|
|
88
89
|
fields={ fields }
|
|
89
90
|
askEmail={ askEmail }
|
|
91
|
+
phoneOptional={ phoneOptional }
|
|
90
92
|
withReturn={ step1.type === 'return' }
|
|
91
93
|
values={ values }
|
|
92
94
|
busFrom={ busFrom }
|
|
@@ -109,6 +111,7 @@ const Passengers = ({ values, step1, fields, askEmail, busFrom, busTo, saved, sa
|
|
|
109
111
|
amount={ step1.children }
|
|
110
112
|
fields={ fields }
|
|
111
113
|
askEmail={ askEmail }
|
|
114
|
+
phoneOptional={ phoneOptional }
|
|
112
115
|
withReturn={ step1.type === 'return' }
|
|
113
116
|
values={ values }
|
|
114
117
|
busFrom={ busFrom }
|
|
@@ -131,6 +134,7 @@ const Passengers = ({ values, step1, fields, askEmail, busFrom, busTo, saved, sa
|
|
|
131
134
|
amount={ step1.babies }
|
|
132
135
|
fields={ fields }
|
|
133
136
|
askEmail={ askEmail }
|
|
137
|
+
phoneOptional={ phoneOptional }
|
|
134
138
|
withReturn={ step1.type === 'return' }
|
|
135
139
|
values={ values }
|
|
136
140
|
busFrom={ busFrom }
|
|
@@ -66,11 +66,31 @@ const Payments = ({ selected, isPartner, routeId, total, currency, t, onChange }
|
|
|
66
66
|
|
|
67
67
|
useEffect(() => {
|
|
68
68
|
if (isSuccess) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
/*
|
|
70
|
+
* Claude - 2026-08-30 (raised by Ferjolt: "why should an operator have
|
|
71
|
+
* to pay for its own routes?").
|
|
72
|
+
*
|
|
73
|
+
* IT NEVER HAD TO - BUT THE CHECKOUT PRESELECTED A PAID METHOD ANYWAY.
|
|
74
|
+
* This used to take the first key in the server's object that was
|
|
75
|
+
* true, and `funds` is written before `complimentary`, so an operator
|
|
76
|
+
* opening a ticket on their OWN route arrived with Funds (Online)
|
|
77
|
+
* already highlighted. One click on Make Payment and they had bought a
|
|
78
|
+
* seat on their own coach - the free option was sitting right there,
|
|
79
|
+
* looking like just another of four equal tiles.
|
|
80
|
+
*
|
|
81
|
+
* `complimentary` is only ever offered when the route really does
|
|
82
|
+
* belong to this operator (Actions::get re-checks that against the
|
|
83
|
+
* real order row, never the request), so whenever it is on the list it
|
|
84
|
+
* is the right default. Ruling BOOK-07: "Operators do not pay anything
|
|
85
|
+
* for their own routes - we do not charge them."
|
|
86
|
+
*
|
|
87
|
+
* The paid methods stay on the list on purpose: an operator also SELLS
|
|
88
|
+
* seats on its own routes, and those are real sales that have to be
|
|
89
|
+
* settled and recorded.
|
|
90
|
+
*/
|
|
91
|
+
const available = Object.keys(data).filter(item => data[item as keyof typeof data] === true);
|
|
92
|
+
const found = available.includes('complimentary') ? 'complimentary' : available[0];
|
|
72
93
|
|
|
73
|
-
// we assign the first available action
|
|
74
94
|
onChange(found ?? '');
|
|
75
95
|
}
|
|
76
96
|
}, [isSuccess, data, onChange]);
|
package/commerce.ts
CHANGED
|
@@ -22,3 +22,23 @@ export const item = (route: FoundData): CommerceItem => ({
|
|
|
22
22
|
quantity: 1
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Staff selling over a counter, as opposed to a customer buying for
|
|
28
|
+
* themselves.
|
|
29
|
+
*
|
|
30
|
+
* Claude - 2026-08-30. This list was written inline at the askEmail prop and
|
|
31
|
+
* is now needed twice - the optional passenger email and, from today, the
|
|
32
|
+
* optional passenger phone. Two copies of it would drift, and drifting here
|
|
33
|
+
* is not a cosmetic bug: the form would stop requiring a field the server
|
|
34
|
+
* still rejects, so the sale fails at submit with an error pointing at a box
|
|
35
|
+
* the seller was told they could leave blank.
|
|
36
|
+
*
|
|
37
|
+
* Must stay in step with RulesGeneral::COUNTER_SELLERS in obtapi, which is
|
|
38
|
+
* the half that actually decides.
|
|
39
|
+
*/
|
|
40
|
+
export const COUNTER_SELLERS = [ 'agent', 'subagent', 'operator', 'employee', 'admin' ];
|
|
41
|
+
|
|
42
|
+
export const isCounterSale = (type?: string): boolean => (
|
|
43
|
+
type !== undefined && COUNTER_SELLERS.includes(type)
|
|
44
|
+
);
|