@autobusal/operator-routes 1.9.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 CHANGED
@@ -1,5 +1,13 @@
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
+
7
+ ## 1.10.0 (2026-08-29)
8
+
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).
10
+
3
11
  ## 1.9.0 (2026-08-29)
4
12
 
5
13
  - Route schedules and unavailability periods can be planned five years ahead, not one - the picker's ceiling was the only limit.
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';
@@ -46,12 +46,14 @@ const Manage = ({ url, t }: Props): JSX.Element => {
46
46
 
47
47
  const drivers = useOperatorDrivers('id');
48
48
 
49
- const onSave = (data: RouteData): void => {
49
+ const onSave = (data: RouteData, stay?: boolean): void => {
50
50
  Update(data, {
51
51
  onSuccess: () => {
52
52
  success(t('operator_routes.manage.messages.saved', { ns: 'common' }));
53
53
 
54
- navigate(url);
54
+ if (!stay) {
55
+ navigate(url);
56
+ }
55
57
  }
56
58
  });
57
59
  };
@@ -134,6 +136,25 @@ const Manage = ({ url, t }: Props): JSX.Element => {
134
136
  </>
135
137
  ),
136
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 }`
137
158
  }, {}, {
138
159
  label: t('operator_routes.manage.features', { ns: 'common' }),
139
160
  name: 'features',
@@ -282,7 +303,7 @@ const Manage = ({ url, t }: Props): JSX.Element => {
282
303
  pending={ isPendingSave || isPendingDelete }
283
304
  t={ t }
284
305
  actions={ [
285
- 'save', 'delete'
306
+ 'save', 'save_stay', 'delete'
286
307
  ]}
287
308
  onSave={ onSave }
288
309
  onDelete={ onDelete }
@@ -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
+ `;
@@ -56,12 +56,14 @@ const Manage = ({ url, t }: Props): JSX.Element => {
56
56
  return <Navigate to={ url } />;
57
57
  }
58
58
 
59
- const onSave = (data: AvailableData): void => {
59
+ const onSave = (data: AvailableData, stay?: boolean): void => {
60
60
  Update(data, {
61
61
  onSuccess: () => {
62
62
  success(t('schedules_manage.messages.saved', { ns: 'common' }));
63
63
 
64
- navigate(url);
64
+ if (!stay) {
65
+ navigate(url);
66
+ }
65
67
  }
66
68
  });
67
69
  };
@@ -100,7 +102,7 @@ const Manage = ({ url, t }: Props): JSX.Element => {
100
102
  pending={ isPending }
101
103
  t={ t }
102
104
  actions={ [
103
- 'update'
105
+ 'update', 'save_stay'
104
106
  ]}
105
107
  onSave={ onSave }
106
108
  />
@@ -47,12 +47,14 @@ const Manage = ({ url, t }: Props): JSX.Element => {
47
47
  return <Navigate to={ url } />;
48
48
  }
49
49
 
50
- const onSave = (data: UnavailableData): void => {
50
+ const onSave = (data: UnavailableData, stay?: boolean): void => {
51
51
  Update(data, {
52
52
  onSuccess: () => {
53
53
  success(t('unavailable.manage.messages.saved', { ns: 'common' }));
54
54
 
55
- navigate(url);
55
+ if (!stay) {
56
+ navigate(url);
57
+ }
56
58
  }
57
59
  });
58
60
  };
@@ -97,7 +99,7 @@ const Manage = ({ url, t }: Props): JSX.Element => {
97
99
  pending={ isPendingSave || isPendingDelete }
98
100
  t={ t }
99
101
  actions={ [
100
- 'save', 'delete'
102
+ 'save', 'save_stay', 'delete'
101
103
  ]}
102
104
  onSave={ onSave }
103
105
  onDelete={ onDelete }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/operator-routes",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
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
+ }