@autobusal/operator-routes 1.10.0 → 1.10.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 +4 -0
- package/Manage.tsx +20 -1
- package/Pricing/Manage.tsx +279 -0
- package/Pricing/services.ts +76 -0
- package/Pricing/styles.ts +166 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/types.ts +40 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.1 - 2026-08-30
|
|
4
|
+
|
|
5
|
+
- Staged dynamic pricing (roadmap P5): a per-route screen for the price rules - the opt-in, the operator's own caps, and the rules as a NUMBERED list read top to bottom, because the first rule whose band matches is the only one that applies. Each row says what it will actually do once the cap has had its say.
|
|
6
|
+
|
|
3
7
|
## 1.10.0 (2026-08-29)
|
|
4
8
|
|
|
5
9
|
- Editors offer Save & Close alongside Save & Stay, so correcting a record no longer bounces you back to its list every time (opt-in per page, editing only).
|
package/Manage.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { TFunction } from 'i18next';
|
|
|
4
4
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
5
5
|
import { RiMoneyEuroCircleLine } from 'react-icons/ri';
|
|
6
6
|
import { FaMapMarkerAlt } from 'react-icons/fa';
|
|
7
|
-
import { IoCalendarClearOutline } from 'react-icons/io5';
|
|
7
|
+
import { IoCalendarClearOutline, IoPricetagsOutline } from 'react-icons/io5';
|
|
8
8
|
import { Meta, BackWithTitle, Viewer } from '@autobusal/common';
|
|
9
9
|
import { usePage, useBreadcrumbs, useRouteFeatures, useOperatorDrivers } from '@autobusal/hooks';
|
|
10
10
|
import { success } from '@autobusal/utilities';
|
|
@@ -136,6 +136,25 @@ const Manage = ({ url, t }: Props): JSX.Element => {
|
|
|
136
136
|
</>
|
|
137
137
|
),
|
|
138
138
|
url: `/operator/routes/unavailable/${ id }`
|
|
139
|
+
}, {
|
|
140
|
+
/*
|
|
141
|
+
* Claude - 2026-08-30 (roadmap P5): staged dynamic pricing.
|
|
142
|
+
*
|
|
143
|
+
* A link, like the schedule and the unavailable dates above it: the
|
|
144
|
+
* rules are an ordered LIST whose order decides which one sets the
|
|
145
|
+
* fare, and a list that means something cannot be squeezed into one
|
|
146
|
+
* row of this form.
|
|
147
|
+
*/
|
|
148
|
+
label: t('operator_routes.manage.pricing.title', { ns: 'common' }),
|
|
149
|
+
name: 'pricing',
|
|
150
|
+
type: 'link',
|
|
151
|
+
value: (
|
|
152
|
+
<>
|
|
153
|
+
<IoPricetagsOutline />
|
|
154
|
+
{ t('operator_routes.manage.pricing.link', { ns: 'common' }) }
|
|
155
|
+
</>
|
|
156
|
+
),
|
|
157
|
+
url: `/operator/routes/pricing/${ id }`
|
|
139
158
|
}, {}, {
|
|
140
159
|
label: t('operator_routes.manage.features', { ns: 'common' }),
|
|
141
160
|
name: 'features',
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import { useParams, Navigate } from 'react-router-dom';
|
|
4
|
+
import { IoArrowDown, IoArrowUp } from 'react-icons/io5';
|
|
5
|
+
import { Meta, BackWithTitle, Box, Button } from '@autobusal/common';
|
|
6
|
+
import { usePage, useBreadcrumbs } from '@autobusal/hooks';
|
|
7
|
+
import { success } from '@autobusal/utilities';
|
|
8
|
+
import { Panel, Row, Note, Rules, Rule, Text, Percent, Clamped, Actions, Small, Field, Choice, Empty } from './styles';
|
|
9
|
+
import { PriceRuleData } from '../types';
|
|
10
|
+
import { useGetPricing, usePostPricingRule, useDeletePricingRule, useReorderPricingRules, useTogglePricing, usePostPricingCaps } from './services';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
url: string
|
|
14
|
+
t: TFunction<'common'>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BLANK: PriceRuleData = { signal: 'days', band_from: 0, band_to: 3, percent: 10, enabled: true };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Staged dynamic pricing for one route (roadmap P5, ruled by Ferjolt).
|
|
21
|
+
*
|
|
22
|
+
* Claude - 2026-08-30
|
|
23
|
+
*
|
|
24
|
+
* THE LIST IS THE FEATURE. The first rule whose band covers a search is the
|
|
25
|
+
* only one that applies - they do not stack - so this screen is an ordered
|
|
26
|
+
* list an operator reads top to bottom, numbered, with the move controls that
|
|
27
|
+
* decide precedence sitting on each row. Anything prettier would have hidden
|
|
28
|
+
* the single thing they need to understand about their own prices.
|
|
29
|
+
*
|
|
30
|
+
* The caps sit above the list rather than in a settings page elsewhere,
|
|
31
|
+
* because they are the sentence that makes a percentage mean something: a
|
|
32
|
+
* rule asking for 40% on an operator capped at 20 moves the fare by 20, and
|
|
33
|
+
* this screen says so on the row rather than leaving them to discover it in a
|
|
34
|
+
* search result.
|
|
35
|
+
*/
|
|
36
|
+
const Manage = ({ url, t }: Props): JSX.Element => {
|
|
37
|
+
const params = useParams();
|
|
38
|
+
|
|
39
|
+
const routeId = Number(params.id);
|
|
40
|
+
|
|
41
|
+
const baseUrl = url;
|
|
42
|
+
url = url.replace('[id]', String(routeId));
|
|
43
|
+
|
|
44
|
+
usePage('routes');
|
|
45
|
+
|
|
46
|
+
const { data, isLoading, refetch } = useGetPricing(routeId);
|
|
47
|
+
|
|
48
|
+
const title = t('operator_routes.pricing.title', { ns: 'common' });
|
|
49
|
+
|
|
50
|
+
// the route's own name, so the trail reads "... > Tirana - Athens > Dynamic
|
|
51
|
+
// pricing" rather than leaving `{{name}}` uninterpolated while it loads
|
|
52
|
+
useBreadcrumbs(title, [{
|
|
53
|
+
name: t('operator_routes.browse.title.manage', { ns: 'common' }),
|
|
54
|
+
url: baseUrl.replace('/pricing/[id]', '')
|
|
55
|
+
}, {
|
|
56
|
+
name: t('operator_routes.manage.title.update', { ns: 'common', name: data?.name ?? '' }),
|
|
57
|
+
url: baseUrl.replace('/pricing/[id]', `/manage/${ routeId }`)
|
|
58
|
+
}]);
|
|
59
|
+
|
|
60
|
+
const { mutate: Save, isPending: saving } = usePostPricingRule(routeId);
|
|
61
|
+
const { mutate: Delete } = useDeletePricingRule(routeId);
|
|
62
|
+
const { mutate: Reorder } = useReorderPricingRules(routeId);
|
|
63
|
+
const { mutate: Toggle } = useTogglePricing(routeId);
|
|
64
|
+
const { mutate: SaveCaps, isPending: savingCaps } = usePostPricingCaps(routeId);
|
|
65
|
+
|
|
66
|
+
const [ draft, setDraft ] = useState<PriceRuleData>(BLANK);
|
|
67
|
+
const [ caps, setCaps ] = useState<{ up: number, down: number } | null>(null);
|
|
68
|
+
|
|
69
|
+
if (routeId === 0) {
|
|
70
|
+
return <Navigate to={ url } />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isLoading || !data) {
|
|
74
|
+
return <Box />;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const live = caps ?? { up: data.caps.up, down: data.caps.down };
|
|
78
|
+
|
|
79
|
+
const onAdd = (): void => {
|
|
80
|
+
Save(draft, {
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
success(t('operator_routes.pricing.saved', { ns: 'common' }));
|
|
83
|
+
|
|
84
|
+
setDraft(BLANK);
|
|
85
|
+
|
|
86
|
+
refetch();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const onMove = (index: number, by: number): void => {
|
|
92
|
+
const order = data.rules.map(rule => Number(rule.id));
|
|
93
|
+
|
|
94
|
+
const target = index + by;
|
|
95
|
+
|
|
96
|
+
if (target < 0 || target >= order.length) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
101
|
+
|
|
102
|
+
Reorder(order, { onSuccess: () => refetch() });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a rule will actually do, once the operator's own cap has had its say.
|
|
107
|
+
*/
|
|
108
|
+
const effective = (percent: number): number => (
|
|
109
|
+
percent >= 0 ? Math.min(percent, live.up) : Math.max(percent, -live.down)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const describe = (rule: PriceRuleData): string => {
|
|
113
|
+
const band = rule.band_to === null
|
|
114
|
+
? t('operator_routes.pricing.band.from', { ns: 'common', from: rule.band_from ?? 0 })
|
|
115
|
+
: t('operator_routes.pricing.band.between', { ns: 'common', from: rule.band_from ?? 0, to: rule.band_to });
|
|
116
|
+
|
|
117
|
+
return t(`operator_routes.pricing.signal.${ rule.signal }`, { ns: 'common', band });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Meta title={ title }>
|
|
122
|
+
<BackWithTitle title={ title } to={ baseUrl.replace('/pricing/[id]', `/manage/${ routeId }`) } t={ t } />
|
|
123
|
+
|
|
124
|
+
{ /* `box` is the app's own card surface; Box from common is the loading
|
|
125
|
+
skeleton used above, not a container. */ }
|
|
126
|
+
<Panel className="box">
|
|
127
|
+
<Note>{ t('operator_routes.pricing.explain', { ns: 'common' }) }</Note>
|
|
128
|
+
|
|
129
|
+
<Row>
|
|
130
|
+
<label>
|
|
131
|
+
<input
|
|
132
|
+
type="checkbox"
|
|
133
|
+
checked={ data.enabled }
|
|
134
|
+
onChange={ event => Toggle(event.target.checked, { onSuccess: () => refetch() }) }
|
|
135
|
+
/>
|
|
136
|
+
{ ' ' }
|
|
137
|
+
{ t('operator_routes.pricing.enabled', { ns: 'common' }) }
|
|
138
|
+
</label>
|
|
139
|
+
</Row>
|
|
140
|
+
|
|
141
|
+
{ /* The ceiling, above the rules it governs. `platform_*` is the
|
|
142
|
+
limit an operator cannot raise - stated as the input's max so
|
|
143
|
+
the form refuses it rather than the server having to. */ }
|
|
144
|
+
<Row>
|
|
145
|
+
<span>{ t('operator_routes.pricing.caps.up', { ns: 'common' }) }</span>
|
|
146
|
+
|
|
147
|
+
<Field
|
|
148
|
+
type="number"
|
|
149
|
+
min={ 0 }
|
|
150
|
+
max={ data.caps.platform_up }
|
|
151
|
+
value={ live.up }
|
|
152
|
+
onChange={ event => setCaps({ ...live, up: Number(event.target.value) }) }
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
<span>{ t('operator_routes.pricing.caps.down', { ns: 'common' }) }</span>
|
|
156
|
+
|
|
157
|
+
<Field
|
|
158
|
+
type="number"
|
|
159
|
+
min={ 0 }
|
|
160
|
+
max={ data.caps.platform_down }
|
|
161
|
+
value={ live.down }
|
|
162
|
+
onChange={ event => setCaps({ ...live, down: Number(event.target.value) }) }
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
<Small
|
|
166
|
+
type="button"
|
|
167
|
+
disabled={ savingCaps || caps === null }
|
|
168
|
+
onClick={ () => SaveCaps(live, {
|
|
169
|
+
onSuccess: () => {
|
|
170
|
+
success(t('operator_routes.pricing.saved', { ns: 'common' }));
|
|
171
|
+
|
|
172
|
+
setCaps(null);
|
|
173
|
+
|
|
174
|
+
refetch();
|
|
175
|
+
}
|
|
176
|
+
}) }
|
|
177
|
+
>
|
|
178
|
+
{ t('operator_routes.pricing.caps.save', { ns: 'common' }) }
|
|
179
|
+
</Small>
|
|
180
|
+
</Row>
|
|
181
|
+
|
|
182
|
+
{ data.rules.length === 0 ? (
|
|
183
|
+
<Empty>{ t('operator_routes.pricing.empty', { ns: 'common' }) }</Empty>
|
|
184
|
+
) : (
|
|
185
|
+
<Rules>
|
|
186
|
+
{ data.rules.map((rule, index) => {
|
|
187
|
+
const applied = effective(rule.percent);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<Rule key={ rule.id } $disabled={ rule.enabled === false }>
|
|
191
|
+
<Text>{ describe(rule) }</Text>
|
|
192
|
+
|
|
193
|
+
<Percent $up={ rule.percent >= 0 }>
|
|
194
|
+
{ rule.percent > 0 ? '+' : '' }{ rule.percent }%
|
|
195
|
+
</Percent>
|
|
196
|
+
|
|
197
|
+
{ applied !== rule.percent && (
|
|
198
|
+
<Clamped>{ t('operator_routes.pricing.clamped', { ns: 'common', percent: applied }) }</Clamped>
|
|
199
|
+
) }
|
|
200
|
+
|
|
201
|
+
<Actions>
|
|
202
|
+
<Small type="button" disabled={ index === 0 } onClick={ () => onMove(index, -1) } aria-label={ t('operator_routes.pricing.up', { ns: 'common' }) }>
|
|
203
|
+
<IoArrowUp />
|
|
204
|
+
</Small>
|
|
205
|
+
|
|
206
|
+
<Small type="button" disabled={ index === data.rules.length - 1 } onClick={ () => onMove(index, 1) } aria-label={ t('operator_routes.pricing.down', { ns: 'common' }) }>
|
|
207
|
+
<IoArrowDown />
|
|
208
|
+
</Small>
|
|
209
|
+
|
|
210
|
+
<Small type="button" onClick={ () => Save({ ...rule, enabled: rule.enabled === false }, { onSuccess: () => refetch() }) }>
|
|
211
|
+
{ rule.enabled === false
|
|
212
|
+
? t('operator_routes.pricing.enable', { ns: 'common' })
|
|
213
|
+
: t('operator_routes.pricing.disable', { ns: 'common' }) }
|
|
214
|
+
</Small>
|
|
215
|
+
|
|
216
|
+
<Small type="button" onClick={ () => Delete(Number(rule.id), { onSuccess: () => refetch() }) }>
|
|
217
|
+
{ t('operator_routes.pricing.remove', { ns: 'common' }) }
|
|
218
|
+
</Small>
|
|
219
|
+
</Actions>
|
|
220
|
+
</Rule>
|
|
221
|
+
);
|
|
222
|
+
}) }
|
|
223
|
+
</Rules>
|
|
224
|
+
) }
|
|
225
|
+
|
|
226
|
+
{ /* One row to add a rule, rather than a separate screen: a rule is
|
|
227
|
+
four small values and an operator writing a set of them should
|
|
228
|
+
not be navigating between them. */ }
|
|
229
|
+
<Row>
|
|
230
|
+
<Choice
|
|
231
|
+
value={ draft.signal }
|
|
232
|
+
aria-label={ t('operator_routes.pricing.new.signal', { ns: 'common' }) }
|
|
233
|
+
onChange={ event => setDraft({ ...draft, signal: event.target.value as PriceRuleData['signal'] }) }
|
|
234
|
+
>
|
|
235
|
+
<option value="days">{ t('operator_routes.pricing.new.days', { ns: 'common' }) }</option>
|
|
236
|
+
<option value="occupancy">{ t('operator_routes.pricing.new.occupancy', { ns: 'common' }) }</option>
|
|
237
|
+
</Choice>
|
|
238
|
+
|
|
239
|
+
<Field
|
|
240
|
+
type="number"
|
|
241
|
+
min={ 0 }
|
|
242
|
+
value={ draft.band_from ?? 0 }
|
|
243
|
+
aria-label={ t('operator_routes.pricing.new.from', { ns: 'common' }) }
|
|
244
|
+
onChange={ event => setDraft({ ...draft, band_from: Number(event.target.value) }) }
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
<Field
|
|
248
|
+
type="number"
|
|
249
|
+
min={ 0 }
|
|
250
|
+
value={ draft.band_to ?? '' }
|
|
251
|
+
placeholder={ t('operator_routes.pricing.new.open', { ns: 'common' }) }
|
|
252
|
+
aria-label={ t('operator_routes.pricing.new.to', { ns: 'common' }) }
|
|
253
|
+
onChange={ event => setDraft({ ...draft, band_to: event.target.value === '' ? null : Number(event.target.value) }) }
|
|
254
|
+
/>
|
|
255
|
+
|
|
256
|
+
<Field
|
|
257
|
+
type="number"
|
|
258
|
+
min={ -data.caps.platform_down }
|
|
259
|
+
max={ data.caps.platform_up }
|
|
260
|
+
value={ draft.percent }
|
|
261
|
+
aria-label={ t('operator_routes.pricing.new.percent', { ns: 'common' }) }
|
|
262
|
+
onChange={ event => setDraft({ ...draft, percent: Number(event.target.value) }) }
|
|
263
|
+
/>
|
|
264
|
+
|
|
265
|
+
<Button
|
|
266
|
+
type="button"
|
|
267
|
+
size="medium"
|
|
268
|
+
noMargin
|
|
269
|
+
loading={ saving }
|
|
270
|
+
text={ t('operator_routes.pricing.new.add', { ns: 'common' }) }
|
|
271
|
+
onClick={ onAdd }
|
|
272
|
+
/>
|
|
273
|
+
</Row>
|
|
274
|
+
</Panel>
|
|
275
|
+
</Meta>
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export default Manage;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { UseMutationResult, UseQueryResult, useMutation, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { apiClient } from '@autobusal/providers';
|
|
3
|
+
import { PricingData, PriceRuleData } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Staged dynamic pricing for one route (roadmap P5).
|
|
7
|
+
*
|
|
8
|
+
* Claude - 2026-08-30. One query returns the whole screen's state - the
|
|
9
|
+
* opt-in, the rules in the order they are evaluated, and the caps they will be
|
|
10
|
+
* held to - because a percentage means nothing without the cap beside it: a
|
|
11
|
+
* form that lets an operator type 40 while their own ceiling is 20 has to say
|
|
12
|
+
* so as they type, not clamp silently at pricing time.
|
|
13
|
+
*/
|
|
14
|
+
export const useGetPricing = (routeId: number): UseQueryResult<PricingData> => (
|
|
15
|
+
useQuery({
|
|
16
|
+
queryKey: ['operator-route-pricing', { routeId }],
|
|
17
|
+
enabled: routeId > 0,
|
|
18
|
+
queryFn: async () => (
|
|
19
|
+
await apiClient
|
|
20
|
+
.get('/api/routes/operator/pricing/get', { params: { route_id: routeId } })
|
|
21
|
+
.then(response => (
|
|
22
|
+
response.data
|
|
23
|
+
))
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const usePostPricingRule = (routeId: number): UseMutationResult<PriceRuleData, Error, PriceRuleData, unknown> => (
|
|
29
|
+
useMutation({
|
|
30
|
+
mutationFn: async (data: PriceRuleData) => (
|
|
31
|
+
await apiClient
|
|
32
|
+
.post('/api/routes/operator/pricing/save', { ...data, route_id: routeId })
|
|
33
|
+
.then(response => (
|
|
34
|
+
response.data
|
|
35
|
+
))
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const useDeletePricingRule = (routeId: number): UseMutationResult<void, Error, number, unknown> => (
|
|
41
|
+
useMutation({
|
|
42
|
+
mutationFn: async (id: number) => (
|
|
43
|
+
await apiClient.delete('/api/routes/operator/pricing/delete', {
|
|
44
|
+
data: { route_id: routeId, id }
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reordering is not a display preference here: the FIRST matching rule is the
|
|
52
|
+
* only one that applies, so moving a row changes what a fare is.
|
|
53
|
+
*/
|
|
54
|
+
export const useReorderPricingRules = (routeId: number): UseMutationResult<void, Error, number[], unknown> => (
|
|
55
|
+
useMutation({
|
|
56
|
+
mutationFn: async (order: number[]) => (
|
|
57
|
+
await apiClient.post('/api/routes/operator/pricing/reorder', { route_id: routeId, order })
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export const useTogglePricing = (routeId: number): UseMutationResult<void, Error, boolean, unknown> => (
|
|
63
|
+
useMutation({
|
|
64
|
+
mutationFn: async (enabled: boolean) => (
|
|
65
|
+
await apiClient.post('/api/routes/operator/pricing/toggle', { route_id: routeId, enabled })
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export const usePostPricingCaps = (routeId: number): UseMutationResult<void, Error, { up: number, down: number }, unknown> => (
|
|
71
|
+
useMutation({
|
|
72
|
+
mutationFn: async (caps: { up: number, down: number }) => (
|
|
73
|
+
await apiClient.post('/api/routes/operator/pricing/caps', { route_id: routeId, ...caps })
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The staged-dynamic-pricing screen (roadmap P5).
|
|
5
|
+
*
|
|
6
|
+
* Claude - 2026-08-30. Laid out as a LIST THAT IS READ TOP TO BOTTOM, because
|
|
7
|
+
* that is what the rules mean: the first one whose band covers the search is
|
|
8
|
+
* the only one that applies. A grid of cards would have hidden the one thing
|
|
9
|
+
* an operator has to understand about their own pricing.
|
|
10
|
+
*/
|
|
11
|
+
export const Panel = styled.div`
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: 16px;
|
|
15
|
+
padding: 16px;
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
export const Row = styled.div`
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 10px;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export const Note = styled.p`
|
|
26
|
+
margin: 0;
|
|
27
|
+
max-width: 70ch;
|
|
28
|
+
font-size: ${ props => props.theme.size.s };
|
|
29
|
+
color: ${ props => props.theme.font.faded };
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
export const Rules = styled.ol`
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 8px;
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
list-style: none;
|
|
39
|
+
counter-reset: rule;
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A rule, numbered by its position in the evaluation order. The number is not
|
|
44
|
+
* decoration - it is the answer to "which one of these decided the fare".
|
|
45
|
+
*/
|
|
46
|
+
export const Rule = styled.li<{ $disabled: boolean }>`
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-wrap: wrap;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 10px;
|
|
51
|
+
padding: 10px 12px;
|
|
52
|
+
background: ${ props => props.theme.background.neutral };
|
|
53
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
54
|
+
|
|
55
|
+
${ props => props.$disabled && css`
|
|
56
|
+
opacity: .55;
|
|
57
|
+
` }
|
|
58
|
+
|
|
59
|
+
&::before {
|
|
60
|
+
counter-increment: rule;
|
|
61
|
+
content: counter(rule);
|
|
62
|
+
flex: 0 0 auto;
|
|
63
|
+
width: 22px;
|
|
64
|
+
height: 22px;
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
justify-content: center;
|
|
68
|
+
font-size: ${ props => props.theme.size.xs };
|
|
69
|
+
font-weight: 700;
|
|
70
|
+
color: ${ props => props.theme.primary.contrast };
|
|
71
|
+
background: ${ props => props.theme.primary.normal };
|
|
72
|
+
border-radius: 50%;
|
|
73
|
+
}
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
export const Text = styled.span`
|
|
77
|
+
flex: 1 1 260px;
|
|
78
|
+
font-size: ${ props => props.theme.size.s };
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The effect, coloured by direction - a rise and a cut are the two things an
|
|
83
|
+
* operator is scanning this list for.
|
|
84
|
+
*/
|
|
85
|
+
export const Percent = styled.strong<{ $up: boolean }>`
|
|
86
|
+
flex: 0 0 auto;
|
|
87
|
+
font-size: ${ props => props.theme.size.m };
|
|
88
|
+
color: ${ props => (props.$up ? props.theme.font.warning : props.theme.font.success) };
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Said out loud beside the percentage when the operator's own cap is lower
|
|
93
|
+
* than the rule asks for - the fare will move by the cap, and a screen that
|
|
94
|
+
* did not say so would leave them wondering why.
|
|
95
|
+
*/
|
|
96
|
+
export const Clamped = styled.span`
|
|
97
|
+
flex: 0 0 auto;
|
|
98
|
+
font-size: ${ props => props.theme.size.xs };
|
|
99
|
+
color: ${ props => props.theme.font.error };
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
export const Actions = styled.div`
|
|
103
|
+
display: flex;
|
|
104
|
+
gap: 6px;
|
|
105
|
+
margin-left: auto;
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A row control - it has to READ as a control. Without a border these sat on
|
|
110
|
+
* the panel as plain text, and "Save limits" in particular looked like a
|
|
111
|
+
* caption rather than the button that commits a change to what an operator may
|
|
112
|
+
* charge.
|
|
113
|
+
*/
|
|
114
|
+
export const Small = styled.button`
|
|
115
|
+
padding: 4px 10px;
|
|
116
|
+
font-size: ${ props => props.theme.size.xs };
|
|
117
|
+
color: ${ props => props.theme.font.normal };
|
|
118
|
+
background: ${ props => props.theme.background.box };
|
|
119
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
120
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
|
|
123
|
+
&:hover:not(:disabled) {
|
|
124
|
+
opacity: .8;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&:disabled {
|
|
128
|
+
opacity: .4;
|
|
129
|
+
cursor: not-allowed;
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The add-a-rule inputs are NARROW and sit on one line.
|
|
135
|
+
*
|
|
136
|
+
* `width` alone loses to the app's global `input { width: 100% }`, which is
|
|
137
|
+
* what stacked four small numbers into four full-width rows and made a
|
|
138
|
+
* four-field form look like a questionnaire. flex-basis with no grow, plus an
|
|
139
|
+
* explicit width, wins it back without touching the global rule.
|
|
140
|
+
*/
|
|
141
|
+
export const Field = styled.input`
|
|
142
|
+
&& {
|
|
143
|
+
flex: 0 0 auto;
|
|
144
|
+
width: 92px;
|
|
145
|
+
}
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
export const Choice = styled.select`
|
|
149
|
+
&& {
|
|
150
|
+
flex: 0 0 auto;
|
|
151
|
+
width: auto;
|
|
152
|
+
min-width: 180px;
|
|
153
|
+
padding: 6px 10px;
|
|
154
|
+
border-radius: ${ props => props.theme.borderRadius };
|
|
155
|
+
background: ${ props => props.theme.inputs.background };
|
|
156
|
+
border: 1px solid ${ props => props.theme.inputs.border };
|
|
157
|
+
color: ${ props => props.theme.font.normal };
|
|
158
|
+
font-size: ${ props => props.theme.size.s };
|
|
159
|
+
}
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
export const Empty = styled.p`
|
|
163
|
+
margin: 0;
|
|
164
|
+
font-size: ${ props => props.theme.size.s };
|
|
165
|
+
color: ${ props => props.theme.font.faded };
|
|
166
|
+
`;
|
package/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import Manage from './Manage';
|
|
|
3
3
|
import AlternatesBrowse from './Alternates/Browse';
|
|
4
4
|
import LocationsManage from './Locations/Manage';
|
|
5
5
|
import PricesManage from './Prices/Manage';
|
|
6
|
+
import PricingManage from './Pricing/Manage';
|
|
6
7
|
import SchedulesManage from './Schedule/Manage';
|
|
7
8
|
import TransitingManage from './Transiting/Manage';
|
|
8
9
|
import UnavailableBrowse from './Unavailable/Browse';
|
|
@@ -14,6 +15,7 @@ export {
|
|
|
14
15
|
AlternatesBrowse,
|
|
15
16
|
LocationsManage,
|
|
16
17
|
PricesManage,
|
|
18
|
+
PricingManage,
|
|
17
19
|
SchedulesManage,
|
|
18
20
|
TransitingManage,
|
|
19
21
|
UnavailableBrowse,
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -3,4 +3,43 @@ export interface SeatingOptionsData {
|
|
|
3
3
|
bus_id?: number
|
|
4
4
|
blocked_seats?: number
|
|
5
5
|
available_seats?: number
|
|
6
|
-
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* One staged-dynamic-pricing rule on a route (roadmap P5).
|
|
9
|
+
*
|
|
10
|
+
* Claude - 2026-08-30. `percent` is SIGNED: positive raises the fare, negative
|
|
11
|
+
* lowers it and shows the traveller the standard price struck through. The
|
|
12
|
+
* band is inclusive at both ends and an absent end is open, which is how an
|
|
13
|
+
* operator says it out loud ("0 to 2 days out", "over 80% full").
|
|
14
|
+
*/
|
|
15
|
+
export interface PriceRuleData {
|
|
16
|
+
id?: number
|
|
17
|
+
signal: 'days' | 'occupancy'
|
|
18
|
+
band_from: number | null
|
|
19
|
+
band_to: number | null
|
|
20
|
+
percent: number
|
|
21
|
+
position?: number
|
|
22
|
+
enabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PricingData {
|
|
26
|
+
/** the route these rules price, for the heading and the breadcrumb */
|
|
27
|
+
name: string
|
|
28
|
+
|
|
29
|
+
/** the route's opt-in - rules do nothing until this is on */
|
|
30
|
+
enabled: boolean
|
|
31
|
+
rules: PriceRuleData[]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the operator has allowed itself, and the platform's own ceiling
|
|
35
|
+
* above that. A rule beyond the operator's cap is clamped when a fare is
|
|
36
|
+
* moved rather than refused when it is saved, so lowering a cap binds every
|
|
37
|
+
* rule already written underneath it.
|
|
38
|
+
*/
|
|
39
|
+
caps: {
|
|
40
|
+
up: number
|
|
41
|
+
down: number
|
|
42
|
+
platform_up: number
|
|
43
|
+
platform_down: number
|
|
44
|
+
}
|
|
45
|
+
}
|