@autobusal/operator-routes 1.10.0 → 1.10.2

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,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',
@@ -1,7 +1,9 @@
1
+ import { useMemo } from 'react';
1
2
  import { TFunction } from 'i18next';
2
- import Item from './Item';
3
- import { Items } from './styles';
3
+ import Group from './Group';
4
+ import { Items, Empty } from './styles';
4
5
  import { PriceData } from '@autobusal/providers/types/routes';
6
+ import { byOrigin } from '../utilities';
5
7
 
6
8
  interface Props {
7
9
  id: number
@@ -9,25 +11,56 @@ interface Props {
9
11
  data?: PriceData[]
10
12
  name: string
11
13
  t: TFunction<'normal'>
14
+ onReload: () => void
12
15
  }
13
16
 
14
- const Browse = ({ id, type, data, name, t }: Props): JSX.Element | JSX.Element[] | undefined => {
15
- const items = data?.map(item => (
16
- <Item
17
- key={ item.id }
18
- id={ id }
19
- type={ type }
20
- item={ item }
21
- name={ name }
22
- t={ t }
23
- />
24
- ));
17
+ /**
18
+ * A route's fares, one panel per departure point.
19
+ *
20
+ * Claude - 2026-08-30. The API sends a flat list ordered by `order_id`, which
21
+ * is travel order; grouping it here rather than server-side keeps the payload
22
+ * exactly as it was, so nothing else that reads a route's prices had to
23
+ * change to get this screen.
24
+ */
25
+ const Browse = ({ id, type, data, name, t, onReload }: Props): JSX.Element => {
26
+ /*
27
+ * MEMOISED, AND NOT AS AN OPTIMISATION. Each group hands its prices to a
28
+ * form that takes them as its baseline and resets when they change - which
29
+ * is how a server-side copy shows up without a stale "unsaved" state. Built
30
+ * fresh on every render, the arrays are new every time, so that reset fired
31
+ * on every keystroke and wiped what was being typed. Measured, not
32
+ * theorised: filling a column down put the old values straight back.
33
+ *
34
+ * react-query hands back the same `data` until it actually refetches, so
35
+ * this changes identity exactly when the fares really did.
36
+ */
37
+ const groups = useMemo(() => byOrigin(data ?? []), [ data ]);
38
+
39
+ if (!groups.length) {
40
+ return (
41
+ <Items>
42
+ <div className="box">
43
+ <Empty>{ t('prices_manage.empty', { ns: 'common' }) }</Empty>
44
+ </div>
45
+ </Items>
46
+ );
47
+ }
25
48
 
26
49
  return (
27
50
  <Items>
28
- { items }
51
+ { groups.map(group => (
52
+ <Group
53
+ key={ group.from.id }
54
+ id={ id }
55
+ type={ type }
56
+ group={ group }
57
+ name={ name }
58
+ t={ t }
59
+ onReload={ onReload }
60
+ />
61
+ )) }
29
62
  </Items>
30
63
  );
31
64
  };
32
65
 
33
- export default Browse;
66
+ export default Browse;
@@ -0,0 +1,207 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useForm } from 'react-hook-form';
4
+ import { BiEditAlt } from 'react-icons/bi';
5
+ import { IoArrowDown } from 'react-icons/io5';
6
+ import { FaRegCopy } from 'react-icons/fa';
7
+ import { Button } from '@autobusal/common';
8
+ import { Validate, Display, success } from '@autobusal/utilities';
9
+ import { Group as Container, Header, Origin, Country, Tools, Dirty, Copy, Columns, Column, Fill, Row, Destination, Changed } from './styles';
10
+ import { FareColumn, GroupForm, OriginGroup } from '../types';
11
+ import { COLUMNS, field, toForm, toPrices } from '../utilities';
12
+ import { usePostPrices, usePostCopy } from '../services';
13
+
14
+ interface Props {
15
+ id: number
16
+ type: 'normal' | 'alternate'
17
+ group: OriginGroup
18
+ name: string
19
+ t: TFunction<'normal'>
20
+ onReload: () => void
21
+ }
22
+
23
+ /**
24
+ * Everything the route sells leaving ONE stop.
25
+ *
26
+ * Claude - 2026-08-30 (ruled by Ferjolt: "the save should be per origin, and
27
+ * the copy should be based per origin too").
28
+ *
29
+ * The group is the unit of work because it is the unit an operator thinks in:
30
+ * fares are decided per departure point, not per city pair. So the six labels
31
+ * are said once at the top, each destination is one line, and one Save writes
32
+ * the lot in a single transaction.
33
+ *
34
+ * Save stays disabled until something in THIS group changes, and the rows
35
+ * that changed carry a dot - on a twelve-destination group, pressing Save
36
+ * should not be a guess about what you edited near the top.
37
+ */
38
+ const Group = ({ id, type, group, name, t, onReload }: Props): JSX.Element => {
39
+ const defaults = useMemo(() => toForm(group.prices), [ group.prices ]);
40
+
41
+ const { register, handleSubmit, setValue, getValues, reset, formState: { errors, isDirty, dirtyFields } } = useForm<GroupForm>({
42
+ defaultValues: defaults
43
+ });
44
+
45
+ /*
46
+ * The copy below writes on the server and the page refetches, so the form
47
+ * has to take the new fares as its baseline - otherwise every copied row
48
+ * stays marked as changed, and Save would write the values back over
49
+ * themselves.
50
+ */
51
+ useEffect(() => {
52
+ reset(defaults);
53
+ }, [ defaults, reset ]);
54
+
55
+ const { mutate: Save, isPending } = usePostPrices(id, type, name);
56
+ const { mutate: CopyPrices, isPending: copying } = usePostCopy(id, type, name);
57
+
58
+ const city = group.from.city;
59
+
60
+ const onSubmit = (data: GroupForm): void => {
61
+ /*
62
+ * Nothing changed, nothing to write. The button is dimmed in that state
63
+ * but the shared Button has no real disabled prop, and pressing Enter in
64
+ * any input submits the form whatever the button looks like - so the
65
+ * guard belongs here rather than on the control.
66
+ */
67
+ if (!isDirty) {
68
+ return;
69
+ }
70
+
71
+ Save({
72
+ location_from: group.from.id,
73
+ prices: toPrices(data, group.prices)
74
+ }, {
75
+ onSuccess: () => {
76
+ success(t('prices_manage.messages.saved_group', { city: city.name, ns: 'common' }));
77
+
78
+ // the values just written are the new baseline, so the group goes
79
+ // clean without a refetch
80
+ reset(data);
81
+ }
82
+ });
83
+ };
84
+
85
+ const onCopy = (): void => {
86
+ if (!window.confirm(t('prices_manage.group.copy_ask', { city: city.name, ns: 'common' }))) {
87
+ return;
88
+ }
89
+
90
+ CopyPrices({ location_from: group.from.id }, {
91
+ onSuccess: () => {
92
+ success(t('prices_manage.messages.copied', { ns: 'common' }));
93
+
94
+ onReload();
95
+ }
96
+ });
97
+ };
98
+
99
+ /**
100
+ * Fill one column down the group from its first destination.
101
+ *
102
+ * Marked dirty rather than written: this fills the boxes and leaves them
103
+ * unsaved, so getting it wrong costs nothing but not pressing Save.
104
+ */
105
+ const onFill = (column: FareColumn): void => {
106
+ const first = getValues(field(group.prices[0].id, column));
107
+
108
+ for (const price of group.prices.slice(1)) {
109
+ setValue(field(price.id, column), first, { shouldDirty: true });
110
+ }
111
+ };
112
+
113
+ const alone = group.prices.length < 2;
114
+
115
+ return (
116
+ <Container className="box">
117
+ <form onSubmit={ handleSubmit(onSubmit) }>
118
+ <Header>
119
+ <Origin>
120
+ { t('prices_manage.group.from', { ns: 'common' }) } { city.name }
121
+ <Country>{ city.country?.name }</Country>
122
+ </Origin>
123
+
124
+ <Tools>
125
+ { isDirty && (
126
+ <Dirty>{ t('prices_manage.group.unsaved', { ns: 'common' }) }</Dirty>
127
+ ) }
128
+
129
+ { !alone && (
130
+ <Copy
131
+ type="button"
132
+ disabled={ copying }
133
+ onClick={ onCopy }
134
+ title={ t('prices_manage.group.copy_hint', { ns: 'common' }) }
135
+ >
136
+ <FaRegCopy />
137
+ { t('prices_manage.group.copy', { ns: 'common' }) }
138
+ </Copy>
139
+ ) }
140
+
141
+ <Button
142
+ type="submit"
143
+ active={ isDirty }
144
+ loading={ isPending }
145
+ text={
146
+ <>
147
+ <BiEditAlt />
148
+ { t('table.actions.update', { ns: 'common' }) }
149
+ </>
150
+ }
151
+ />
152
+ </Tools>
153
+ </Header>
154
+
155
+ <Columns>
156
+ <div>{ t('prices_manage.group.to', { ns: 'common' }) }</div>
157
+
158
+ { COLUMNS.map(column => (
159
+ <Column key={ column }>
160
+ { t(`prices_manage.columns.${ column }`, { ns: 'common' }) }
161
+
162
+ { !alone && (
163
+ <Fill
164
+ type="button"
165
+ onClick={ () => onFill(column) }
166
+ title={ t('prices_manage.group.fill', { ns: 'common' }) }
167
+ >
168
+ <IoArrowDown />
169
+ </Fill>
170
+ ) }
171
+ </Column>
172
+ )) }
173
+ </Columns>
174
+
175
+ { group.prices.map(price => {
176
+ const changed = COLUMNS.some(column => dirtyFields[field(price.id, column)]);
177
+
178
+ return (
179
+ <Row key={ price.id } $changed={ changed }>
180
+ <Destination>
181
+ { changed && <Changed /> }
182
+
183
+ { price.to.city.name }
184
+ <Country>{ price.to.city.country?.name }</Country>
185
+ </Destination>
186
+
187
+ { COLUMNS.map(column => (
188
+ <div key={ column }>
189
+ <input
190
+ type="number"
191
+ step="0.01"
192
+ title={ t(`prices_manage.prices.${ column }`, { ns: 'common' }) }
193
+ { ...register(field(price.id, column), Validate('required', t)) }
194
+ />
195
+
196
+ { Display(errors[field(price.id, column)]) }
197
+ </div>
198
+ )) }
199
+ </Row>
200
+ );
201
+ }) }
202
+ </form>
203
+ </Container>
204
+ );
205
+ };
206
+
207
+ export default Group;
@@ -1,22 +1,250 @@
1
- import styled from 'styled-components';
1
+ import styled, { css } from 'styled-components';
2
2
 
3
+ /**
4
+ * The route price screen, grouped by where the leg LEAVES FROM.
5
+ *
6
+ * Claude - 2026-08-30 (ruled by Ferjolt: save per origin, copy per origin).
7
+ *
8
+ * What this replaces: one card per city pair, each with its own six labelled
9
+ * inputs and its own Save button. A real route runs to about 150 pairs, so
10
+ * that was 150 cards, 900 inputs, 900 repeated labels and 150 separate saves
11
+ * - a screen you scrolled rather than read.
12
+ *
13
+ * The shape here is a table per origin: the six labels are said ONCE at the
14
+ * top of the group, and every destination below is a single line of numbers.
15
+ * That is roughly half the height, and it lets a column be read as a column -
16
+ * which is how an operator checks their own fares ("is anything out of
17
+ * Shkoder wrong?"), and what makes the fill-down control legible.
18
+ */
3
19
  export const Items = styled.div`
4
20
  display: flex;
5
21
  flex-direction: column;
6
22
  gap: 15px;
7
23
  `;
8
24
 
9
- export const Prices = styled.div`
25
+ export const Group = styled.div`
10
26
  display: flex;
11
27
  flex-direction: column;
28
+ `;
29
+
30
+ /**
31
+ * The origin, its copy action and its Save button.
32
+ *
33
+ * Sticky because a group runs to a dozen destinations: the button that
34
+ * commits what you are typing should not be somewhere above the fold while
35
+ * you type it.
36
+ */
37
+ export const Header = styled.div`
38
+ position: sticky;
39
+ top: 0;
40
+ z-index: 2;
41
+
42
+ display: flex;
43
+ flex-wrap: wrap;
44
+ align-items: center;
45
+ justify-content: space-between;
46
+ gap: 10px;
47
+
48
+ padding: 12px 14px;
49
+
50
+ background: ${ props => props.theme.background.neutral };
51
+ border-bottom: 1px solid ${ props => props.theme.inputs.border };
52
+ `;
53
+
54
+ export const Origin = styled.h4`
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 8px;
58
+ margin: 0;
59
+ `;
60
+
61
+ export const Country = styled.span`
62
+ font-weight: normal;
63
+ color: ${ props => props.theme.font.faded };
64
+ `;
12
65
 
13
- @media (min-width: 540px) {
14
- flex-direction: row;
15
- gap: 15px;
66
+ export const Tools = styled.div`
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ align-items: center;
70
+ gap: 10px;
71
+ `;
72
+
73
+ /**
74
+ * "This group has unsaved edits", said once where the Save button is.
75
+ */
76
+ export const Dirty = styled.span`
77
+ font-size: ${ props => props.theme.size.s };
78
+ color: ${ props => props.theme.font.info };
79
+ `;
80
+
81
+ export const Copy = styled.button`
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 5px;
85
+
86
+ padding: 0;
87
+
88
+ font-size: ${ props => props.theme.size.s };
89
+ color: ${ props => props.theme.font.faded };
90
+
91
+ background: none;
92
+ border: 0;
93
+ cursor: pointer;
94
+
95
+ &:hover {
96
+ color: ${ props => props.theme.font.normal };
97
+ }
98
+
99
+ &:disabled {
100
+ opacity: 0.5;
101
+ cursor: default;
16
102
  }
17
103
  `;
18
104
 
19
- export const Actions = styled.div`
105
+ /**
106
+ * One line per destination, and one line of labels above them all.
107
+ *
108
+ * The seventh column is the destination itself; the two groups of three are
109
+ * the one-way and return fares, separated by a rule rather than by repeating
110
+ * the word "return" on every input.
111
+ */
112
+ const grid = css`
113
+ display: grid;
114
+ grid-template-columns: minmax(150px, 1.6fr) repeat(6, minmax(64px, 1fr));
115
+ align-items: center;
116
+ gap: 8px;
117
+
118
+ padding: 8px 14px;
119
+
120
+ /* the first return column opens the second half of the row */
121
+ > *:nth-child(5) {
122
+ border-left: 1px solid ${ props => props.theme.inputs.border };
123
+ padding-left: 8px;
124
+ }
125
+
126
+ @media (max-width: 900px) {
127
+ grid-template-columns: repeat(3, minmax(64px, 1fr));
128
+
129
+ > *:nth-child(5) {
130
+ border-left: 0;
131
+ padding-left: 0;
132
+ }
133
+ }
134
+ `;
135
+
136
+ export const Columns = styled.div`
137
+ ${ grid };
138
+
139
+ position: sticky;
140
+ top: 47px;
141
+ z-index: 1;
142
+
143
+ font-size: ${ props => props.theme.size.xs };
144
+ color: ${ props => props.theme.font.faded };
145
+
146
+ background: ${ props => props.theme.background.box };
147
+ border-bottom: 1px solid ${ props => props.theme.inputs.border };
148
+
149
+ /*
150
+ * The "To" label takes a line of its own once the six fares fold into two
151
+ * rows of three - the same line the destination name takes on a row below.
152
+ * Without it the labels sit one cell to the right of the inputs they name,
153
+ * which on a price screen is worse than having no labels at all.
154
+ */
155
+ @media (max-width: 900px) {
156
+ > *:first-child {
157
+ grid-column: 1 / -1;
158
+ }
159
+ }
160
+ `;
161
+
162
+ export const Column = styled.div`
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: space-between;
166
+ gap: 4px;
167
+ `;
168
+
169
+ /**
170
+ * Fills a column down every destination in the group, from the first row.
171
+ *
172
+ * Client-side on purpose: it fills the boxes and leaves them unsaved, so a
173
+ * mistake is undone by not pressing Save. The server-side copy in the group
174
+ * header writes immediately, which is the right tool for "these really are
175
+ * all the same" and the wrong one for "let me try".
176
+ */
177
+ export const Fill = styled.button`
178
+ display: flex;
179
+ align-items: center;
180
+
181
+ padding: 0;
182
+
183
+ color: ${ props => props.theme.font.faded };
184
+
185
+ background: none;
186
+ border: 0;
187
+ cursor: pointer;
188
+
189
+ &:hover {
190
+ color: ${ props => props.theme.primary.normal };
191
+ }
192
+ `;
193
+
194
+ export const Row = styled.div<{ $changed: boolean }>`
195
+ ${ grid };
196
+
197
+ border-bottom: 1px solid ${ props => props.theme.inputs.border };
198
+
199
+ &:last-child {
200
+ border-bottom: 0;
201
+ }
202
+
203
+ ${ props => props.$changed && css`
204
+ background: ${ props.theme.background.neutral };
205
+ ` };
206
+
207
+ input {
208
+ text-align: right;
209
+ }
210
+ `;
211
+
212
+ /**
213
+ * The destination cell. On a narrow screen the six fare columns fold into
214
+ * two rows of three, so this takes a line of its own above them.
215
+ */
216
+ export const Destination = styled.div`
20
217
  display: flex;
21
- justify-content: center;
22
- `;
218
+ align-items: center;
219
+ gap: 6px;
220
+
221
+ @media (max-width: 900px) {
222
+ grid-column: 1 / -1;
223
+ }
224
+ `;
225
+
226
+ /**
227
+ * A dot on the rows this group is about to write, so pressing Save is not a
228
+ * guess about what you changed ten destinations ago.
229
+ */
230
+ export const Changed = styled.span`
231
+ flex: 0 0 auto;
232
+
233
+ width: 7px;
234
+ height: 7px;
235
+
236
+ border-radius: 50%;
237
+ background: ${ props => props.theme.font.info };
238
+ `;
239
+
240
+ export const Small = styled.span`
241
+ font-size: ${ props => props.theme.size.xs };
242
+ color: ${ props => props.theme.font.faded };
243
+ `;
244
+
245
+ export const Empty = styled.p`
246
+ margin: 0;
247
+ padding: 20px;
248
+ text-align: center;
249
+ color: ${ props => props.theme.font.faded };
250
+ `;
package/Prices/Manage.tsx CHANGED
@@ -60,7 +60,7 @@ const Manage = ({ type, url, t }: Props): JSX.Element => {
60
60
  />
61
61
  ) }
62
62
 
63
- <Browse id={ id } type={ type } data={ prices } name={ name } t={ t } />
63
+ <Browse id={ id } type={ type } data={ prices } name={ name } t={ t } onReload={ onReload } />
64
64
  </div>
65
65
  </Meta>
66
66
  );
@@ -41,12 +41,51 @@ export const usePostPrice = (id: number, price_id: number, type: ('normal' | 'al
41
41
  })
42
42
  );
43
43
 
44
- export const usePostCopy = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void> => (
44
+ /**
45
+ * Save every fare leaving one origin, in one request.
46
+ *
47
+ * Claude - 2026-08-30. `usePostPrice` above is untouched and still posts a
48
+ * single row - a tab left open on the previous screen keeps working, and the
49
+ * two endpoints validate identically.
50
+ *
51
+ * A whole route saved a row at a time would be about 150 requests, and
52
+ * `api/prices/` sits in obtapi's 120/min general tier: the save would 429
53
+ * half way through and leave the operator no way to tell which half had been
54
+ * written. One request per group is also one TRANSACTION per group, so a
55
+ * group is saved or it is not.
56
+ */
57
+ export const usePostPrices = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void, Error, { location_from: number, prices: unknown[] }, unknown> => (
58
+ useMutation({
59
+ mutationKey: ['operator-prices-save-many', { id, type, name }],
60
+ mutationFn: async (data: { location_from: number, prices: unknown[] }) => (
61
+ await apiClient
62
+ .post('/api/prices/operator/update-many', {
63
+ ...data,
64
+ id,
65
+ type,
66
+ name
67
+ })
68
+ .then(response => (
69
+ response.data
70
+ ))
71
+ )
72
+ })
73
+ );
74
+
75
+ /**
76
+ * Copy the first fare over the rest.
77
+ *
78
+ * `location_from` narrows that to one origin group, which is the action in
79
+ * each group header; omitted, this is the route-wide copy that has always
80
+ * lived in the Options menu.
81
+ */
82
+ export const usePostCopy = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void, Error, { location_from?: number } | undefined, unknown> => (
45
83
  useMutation({
46
84
  mutationKey: ['operator-prices-copy', { id, type, name }],
47
- mutationFn: async () => (
85
+ mutationFn: async (data?: { location_from?: number }) => (
48
86
  await apiClient
49
87
  .post('/api/prices/operator/copy', {
88
+ ...data,
50
89
  id,
51
90
  type,
52
91
  name
package/Prices/types.ts CHANGED
@@ -1,3 +1,26 @@
1
+ import { LocationData } from '@autobusal/providers/types/locations';
2
+ import { PriceData } from '@autobusal/providers/types/routes';
3
+
4
+ /** the six fare columns a price row carries */
5
+ export type FareColumn = 'adult' | 'child' | 'baby' | 'adult_return' | 'child_return' | 'baby_return';
6
+
7
+ /**
8
+ * Everything the route sells LEAVING one stop.
9
+ *
10
+ * Claude - 2026-08-30. The unit the screen saves and copies in - see
11
+ * utilities.byOrigin for why it is keyed on the location and not the city.
12
+ */
13
+ export interface OriginGroup {
14
+ from: LocationData
15
+ prices: PriceData[]
16
+ }
17
+
18
+ /**
19
+ * One origin group's form: flat `p{price_id}_{column}` keys holding strings.
20
+ * See utilities.field for why the names have no dots in them.
21
+ */
22
+ export type GroupForm = Record<string, string>;
23
+
1
24
  export interface PriceForm {
2
25
  adult: number
3
26
  child: number