@7365admin1/layer-common 3.2.8-staging.210 → 3.3.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 +549 -0
- package/components/HidQrCodeConfiguration.vue +1 -1
- package/components/HidUserEnrollment.vue +1 -1
- package/composables/useHidAmico.ts +5 -12
- package/composables/usePromoCode.ts +0 -57
- package/package.json +1 -1
- package/utils/promo-code-form.test.ts +0 -437
- package/utils/promo-code-form.ts +0 -246
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,554 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 3.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b92f73f: Remove a duplicate `:site` attribute on `HidReaderManagement.vue`.
|
|
8
|
+
|
|
9
|
+
`HidReaderForm` was given `:site` twice - `props.site` and then a bare `site`, which is
|
|
10
|
+
not a name that exists in that component's scope. Vite's Vue plugin treats a duplicate
|
|
11
|
+
attribute as a compile error, so every consuming application's `nuxt build` failed on
|
|
12
|
+
this file. The layer's own playground build does not import the component, which is why
|
|
13
|
+
it was not caught at publish time.
|
|
14
|
+
|
|
15
|
+
## 3.3.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 078521d: Add a dedicated HID Cards module with PACS and CSN assignment, card enrollment
|
|
20
|
+
at an Amico reader, removal, and device-backed card status. HID Users keeps the
|
|
21
|
+
same card action, and the Card identification toggle now updates the reader
|
|
22
|
+
configuration as well as site metadata.
|
|
23
|
+
- f368275: Add reader capability and mode configuration, site-linked HID identities,
|
|
24
|
+
reader-profile physical card handling, and device-backed pagination for HID
|
|
25
|
+
users and access logs.
|
|
26
|
+
- 330e103: Draw icons with the redesign's Material Symbols Outlined font instead of Material Design Icons.
|
|
27
|
+
|
|
28
|
+
Call sites are unchanged - a component still asks for `mdi-close`. The Vuetify icon set
|
|
29
|
+
translates 233 names (the 204 used here plus Vuetify's own internal aliases) into the
|
|
30
|
+
Material Symbols ligature; anything unmapped still renders through Iconify as before, so no
|
|
31
|
+
icon can come out as a blank box. Every consuming application picks this up with no change
|
|
32
|
+
of its own.
|
|
33
|
+
|
|
34
|
+
Also in this release:
|
|
35
|
+
|
|
36
|
+
- `TableWithButton`, `ScheduleTaskAreaUpdateMoreAction` and `ScheduleTaskAreaFormDialog` are
|
|
37
|
+
**removed** - no repository in the organisation referenced them.
|
|
38
|
+
- The published tarball now carries only what a consumer loads (`files`), not the changesets,
|
|
39
|
+
CI workflows, tests and render harness.
|
|
40
|
+
- The check-out label in `VisitorsReportPreview` asked for a `--error` token that does not
|
|
41
|
+
exist and inherited its colour; it now uses `--err`.
|
|
42
|
+
|
|
43
|
+
- bb18a57: Make the Pending tab's invitation actions reachable, and say which state an invitation is really in
|
|
44
|
+
|
|
45
|
+
The row menu in the service-provider list was drawn only for provider rows, so a
|
|
46
|
+
pending invitation offered no actions at all — including the cancel that was
|
|
47
|
+
already wired up behind it. Invitation rows now carry Cancel, Resend and Delete,
|
|
48
|
+
each behind a confirmation that names the company and the site, and Resend is
|
|
49
|
+
disabled with the reason while Seven365 has not approved the invitation yet.
|
|
50
|
+
|
|
51
|
+
Every invitation also used to read "Pending" whatever had happened to it. Rows
|
|
52
|
+
now show the state they are actually in — Waiting for Seven365, Waiting for the
|
|
53
|
+
provider, Not approved, Declined, Cancelled, Expired — in those words, with
|
|
54
|
+
Seven365's reason shown underneath a rejected one.
|
|
55
|
+
|
|
56
|
+
Also here: a new ServiceProviderInvitationPrompt, which is how an existing
|
|
57
|
+
provider answers an invitation from inside the app they already use, with no
|
|
58
|
+
sign-up and no one-time code.
|
|
59
|
+
|
|
60
|
+
Three contrast fixes found while rendering these screens: the list's tab strip
|
|
61
|
+
was a hardcoded light grey that turned into a white slab in dark mode and took
|
|
62
|
+
the tab labels with it; the service type column read the raw stored slug
|
|
63
|
+
("security_agency") instead of "Security"; and `on-warning`, `on-info` and
|
|
64
|
+
dark's `on-error` are now set explicitly, because Vuetify derives white for them
|
|
65
|
+
and that measures 2.37:1, 3.12:1 and 3.60:1 on those fills — every filled
|
|
66
|
+
warning, info and error chip, alert and button in the product.
|
|
67
|
+
|
|
68
|
+
### Patch Changes
|
|
69
|
+
|
|
70
|
+
- 4ee5fb9: Fix the attach-file icon rendering too faint in `FileInputWithList.vue`.
|
|
71
|
+
|
|
72
|
+
The `mdi-paperclip` prepend icon was drawing at Vuetify's default
|
|
73
|
+
`.v-input__prepend > .v-icon { opacity: var(--v-medium-emphasis-opacity) }`
|
|
74
|
+
(0.64) - the same field-icon dimming roughly 200 other call sites across this
|
|
75
|
+
package intentionally rely on (see `utils/theme.ts`), so this is fixed only
|
|
76
|
+
inside `FileInputWithList.vue`, not in the shared token.
|
|
77
|
+
|
|
78
|
+
The icon now renders at full opacity in `--text2`, the same colour
|
|
79
|
+
`.screen-icon-btn` already uses, so it reads at the same weight as a sibling
|
|
80
|
+
icon drawn outside a Vuetify field (which was never subject to this opacity
|
|
81
|
+
rule in the first place). No other icon in this component sits inside
|
|
82
|
+
`.v-input__prepend`/`.v-input__append`, so nothing else here was affected.
|
|
83
|
+
|
|
84
|
+
- 84d6a1f: Top bar breadcrumb: name the page, not the folder in front of the context
|
|
85
|
+
parameter. Every screen of the organization application read "Org", because the
|
|
86
|
+
route name alone cannot tell that `org` in `/org/:organization/...` is a folder
|
|
87
|
+
and not a parameter. The rule moves to `utils/breadcrumb.ts` with unit tests
|
|
88
|
+
covering each application's route shapes; the site applications are unchanged.
|
|
89
|
+
- dd0849e: Draw the camera wall from the gated wall endpoint, and show the server's own
|
|
90
|
+
reason on a tile.
|
|
91
|
+
|
|
92
|
+
`CameraWall` fetched its cameras from `GET /api/site-cameras` - the Settings
|
|
93
|
+
panel's paginated CRUD list, which took its site from an optional query
|
|
94
|
+
parameter and checked nothing about the caller. Any signed-in user from any
|
|
95
|
+
organisation could read every camera in the estate through it, `host` and
|
|
96
|
+
`username` included.
|
|
97
|
+
|
|
98
|
+
It now calls `GET /api/site-cameras/site/:siteId/wall`, which already exists,
|
|
99
|
+
which the React Native monitoring app was built against, and which is
|
|
100
|
+
authorised server-side: the caller must be a member of the site, of its owning
|
|
101
|
+
organisation, or work for an organisation actively engaged to serve it. That
|
|
102
|
+
endpoint also returns `type: "ip"` cameras only and each camera's capability
|
|
103
|
+
descriptor, so a wall cannot be handed an ANPR unit and a tile can explain
|
|
104
|
+
itself.
|
|
105
|
+
|
|
106
|
+
Two consequences worth stating:
|
|
107
|
+
|
|
108
|
+
- **The pager is gone.** The wall endpoint returns the site's cameras in one
|
|
109
|
+
answer. Paging a video wall was an artefact of borrowing the CRUD list.
|
|
110
|
+
- **A tile now prefers the server's `unavailableReason`** over the reason it
|
|
111
|
+
used to work out itself. The server knows things the browser cannot - chiefly
|
|
112
|
+
"No recorder is configured for this camera's relay", which is the true answer
|
|
113
|
+
for every camera in the estate until the API host is configured, and which the
|
|
114
|
+
wall used to replace with its own guess. Offline still beats everything, since
|
|
115
|
+
it explains every tile at once.
|
|
116
|
+
|
|
117
|
+
No permission gate was invented here. Each application still gates its own menu
|
|
118
|
+
entry; this component draws what it is given, and the server decides.
|
|
119
|
+
|
|
120
|
+
Two smaller things in the same area:
|
|
121
|
+
|
|
122
|
+
- **A refused wall now says so.** The catch-all message told every failure to
|
|
123
|
+
"check your connection", which sends somebody who simply may not see that
|
|
124
|
+
site off to debug their wifi. A 401/403/404 now reads "You do not have access
|
|
125
|
+
to this site's cameras." The server answers "not yours" and "does not exist"
|
|
126
|
+
identically, so this wording does not distinguish them either.
|
|
127
|
+
- **`middleware/member.ts` is removed.** It read a cookie into an unused
|
|
128
|
+
variable and did nothing else - a file named like a membership gate that was
|
|
129
|
+
not one. No page in any of the eleven web apps referenced it. The real check
|
|
130
|
+
is `plugins/secure-member.client.ts`, driven by `memberOnly` page meta, which
|
|
131
|
+
is untouched.
|
|
132
|
+
|
|
133
|
+
- f3fb808: Rewrite the camera wall's detail panel for the people who read it, and stop
|
|
134
|
+
repeating the same sentence four times.
|
|
135
|
+
|
|
136
|
+
The panel under the tiles was written for a developer. On the live wall it
|
|
137
|
+
read:
|
|
138
|
+
|
|
139
|
+
> BBQ AREA
|
|
140
|
+
> The video page loaded, but this browser cannot confirm a picture is arriving.
|
|
141
|
+
> The player belongs to the video service and a browser cannot look inside it.
|
|
142
|
+
> Judge the tile by what you can see, and report a blank or frozen picture.
|
|
143
|
+
> ● Recorder responding Last picture this server received: none yet
|
|
144
|
+
> Firmware and device clock are not available: this recorder is reachable over
|
|
145
|
+
> video only.
|
|
146
|
+
|
|
147
|
+
Every one of those statements is true, and the screen was still wrong: it
|
|
148
|
+
explained cross-origin isolation to a guard, repeated the camera's name the
|
|
149
|
+
tile already showed, put a green light beside the words "none yet", and printed
|
|
150
|
+
the same reason on four capability cards in a row.
|
|
151
|
+
|
|
152
|
+
Changes, all copy and layout — **no behaviour, no capability gating and no
|
|
153
|
+
permission check is touched**:
|
|
154
|
+
|
|
155
|
+
- **The badge reads "Not confirmed"**, not "Not verified". "Not verified"
|
|
156
|
+
describes a check we failed to make; "Not confirmed" describes the picture,
|
|
157
|
+
which is the thing the reader cares about. Same claim, and the wall still
|
|
158
|
+
refuses to say "Live" until the video service reports a decoded frame.
|
|
159
|
+
- **The sentence behind it is now an instruction**: "This page cannot confirm
|
|
160
|
+
the picture is arriving, so check the tile yourself. If it is blank or frozen,
|
|
161
|
+
report the camera." Why the software cannot confirm it is our problem, and it
|
|
162
|
+
has been removed from the product.
|
|
163
|
+
- **The camera's name is no longer a heading of its own.** It is the subject of
|
|
164
|
+
the status line — "BBQ AREA · Recorder responding" — so a reader on a 3x3 wall
|
|
165
|
+
still knows which tile the panel describes, without a second caption under the
|
|
166
|
+
first one.
|
|
167
|
+
- **"Last picture" is drawn only when there has been one.** A green "Recorder
|
|
168
|
+
responding" beside "none yet" read as a contradiction. They were never in
|
|
169
|
+
conflict, but a screen that has to be explained is wrong. The tile's own
|
|
170
|
+
health line has always behaved this way; the panel now matches it.
|
|
171
|
+
- **The firmware-and-device-clock sentence is gone**, from the panel and from
|
|
172
|
+
the tile's tooltip. This screen never showed either value, so explaining their
|
|
173
|
+
absence gave the reader nothing to do. The server still sends
|
|
174
|
+
`detailUnavailableReason`; nothing draws it.
|
|
175
|
+
- **One reason for the group instead of four copies.** When every switched-off
|
|
176
|
+
control is off for the same reason, that reason and its next step are stated
|
|
177
|
+
once above the row and each card keeps only its title and its state. When the
|
|
178
|
+
reasons differ — "the recorder is unreachable" and "this camera cannot move"
|
|
179
|
+
are two problems with two different people to ask — the group note is refused
|
|
180
|
+
and each card carries its own again. New rule 7, in `camera-wall.ts`, with
|
|
181
|
+
four tests.
|
|
182
|
+
- Shorter next-step and no-signal wording, and a subject on the recorder's
|
|
183
|
+
status labels ("Recorder not checked yet" rather than "Health not checked
|
|
184
|
+
yet").
|
|
185
|
+
|
|
186
|
+
Rules 3 and 6 are intact: the server's own sentence is still shown verbatim and
|
|
187
|
+
never rewritten, and the recorder's state is still a separate fact from the
|
|
188
|
+
tile's badge.
|
|
189
|
+
|
|
190
|
+
- e177e9e: Mark the selected camera on the wall, and stop telling the reader to raise a
|
|
191
|
+
ticket without saying what for.
|
|
192
|
+
|
|
193
|
+
Two things the owner found on the live wall after the last copy pass. Both are
|
|
194
|
+
copy and presentation only — **no behaviour, no capability gating, no permission
|
|
195
|
+
check and no request is changed.**
|
|
196
|
+
|
|
197
|
+
**1. The camera's name was still printed twice.**
|
|
198
|
+
|
|
199
|
+
The tile said `BBQ AREA` and the panel underneath said it again. The last pass
|
|
200
|
+
merged the panel's heading into its status line, which changed the layout and
|
|
201
|
+
left the repetition exactly where it was.
|
|
202
|
+
|
|
203
|
+
The name was there to answer "which of these nine tiles is this panel about?".
|
|
204
|
+
That is a real question and a name is the wrong answer to it — it makes the
|
|
205
|
+
reader scan the wall for a matching caption. So:
|
|
206
|
+
|
|
207
|
+
- **the selected tile is now visibly selected**: a 3 px accent ring with a scrim
|
|
208
|
+
hairline inside it, so the ring holds against a bright picture as well as a
|
|
209
|
+
dark one, plus the accent underline on the name chip that the toolbar already
|
|
210
|
+
uses to mark a selected tool. Both read at 1x1, 2x2 and 3x3;
|
|
211
|
+
- **the camera's name is removed from the panel entirely**. The panel opens on
|
|
212
|
+
the recorder's state — "● Recorder responding";
|
|
213
|
+
- `aria-current` marks the same tile for anyone not looking at the ring.
|
|
214
|
+
|
|
215
|
+
The highlight had never worked in the most common case, which is why the name
|
|
216
|
+
was load-bearing: it was bound to the raw clicked id, and before anyone clicks —
|
|
217
|
+
or after paging — nothing on the wall was marked while the panel was already
|
|
218
|
+
describing the first tile. It is now bound to the tile the panel actually
|
|
219
|
+
resolves to, so the two can never disagree.
|
|
220
|
+
|
|
221
|
+
**The panel can never be about a tile that is off screen**, so it never needs to
|
|
222
|
+
name one: the focused camera is resolved out of the current PAGE, so paging away
|
|
223
|
+
from a selection re-resolves to the first tile of the page in front of you and
|
|
224
|
+
the ring follows. Paging back restores the original selection. Extracted as
|
|
225
|
+
`focusedCamera()` in `camera-wall.ts` with four tests, including the 5-cameras
|
|
226
|
+
-across-2-pages case.
|
|
227
|
+
|
|
228
|
+
**2. "Ask your Seven365 administrator" was a dead end.**
|
|
229
|
+
|
|
230
|
+
It was the whole of the advice under every switched-off control, and it tells
|
|
231
|
+
the administrator nothing and a property manager only to go and wait. The screen
|
|
232
|
+
never said what the request should ask for.
|
|
233
|
+
|
|
234
|
+
The advice now names the two changes that are actually missing, because they are
|
|
235
|
+
two different jobs for two different people: **the site's network has to let our
|
|
236
|
+
server reach the camera recorder**, and **that recorder then has to be added to
|
|
237
|
+
the server's configuration**. Both are stated in plain words, with who arranges
|
|
238
|
+
them, and with the fact that neither is switchable from this page.
|
|
239
|
+
|
|
240
|
+
No environment variable name appears in any of it — those belong in
|
|
241
|
+
`iservice365-core/docs/camera-integration-config.md`, not on a property
|
|
242
|
+
manager's screen — and there is a test that keeps it that way. The other reason
|
|
243
|
+
codes (`device-http-disabled`, `device-http-unreachable`, `device-http-locked-out`,
|
|
244
|
+
`control-not-enabled`, `no-recorder-configured`, and the unknown-code fallback)
|
|
245
|
+
get the same treatment; the Site Settings ones already said what to do and are
|
|
246
|
+
unchanged.
|
|
247
|
+
|
|
248
|
+
The truthful-status rules are untouched: the wall still refuses to say "Live"
|
|
249
|
+
until the video service reports a decoded frame, and the recorder's state is
|
|
250
|
+
still a separate fact from the tile's badge.
|
|
251
|
+
|
|
252
|
+
- cf29add: Show each client's nature of business on the super-admin Client List, so the
|
|
253
|
+
one screen says who a client is, what kind of business they are, how many sites
|
|
254
|
+
they have and where their subscription stands.
|
|
255
|
+
|
|
256
|
+
The Client List and the admin app's Organizations list are the same screen for
|
|
257
|
+
the same job, and the Client List is the one that stays. Organizations showed
|
|
258
|
+
one thing the Client List did not: the organisation's **nature** — Property
|
|
259
|
+
Management Agency, Security Agency, Cleaning Services, and so on. That is what
|
|
260
|
+
a client actually _is_, and the list had no way to say it.
|
|
261
|
+
|
|
262
|
+
The value was already arriving. `GET /api/organizations/v2/orgs/subscriptions`
|
|
263
|
+
has always projected `nature`; the screen simply never read it. So this is one
|
|
264
|
+
column, no backend change, printed with the same `formatNature` helper the
|
|
265
|
+
Organizations list uses, so both screens say a nature the same way while both
|
|
266
|
+
exist.
|
|
267
|
+
|
|
268
|
+
Two changes pay for the width, because the table was already 1389px in a
|
|
269
|
+
1386px viewport and a new column would have pushed it to 1405px:
|
|
270
|
+
|
|
271
|
+
- **The email moved under the organisation name.** It identifies the same
|
|
272
|
+
client the name does and had a full column to itself.
|
|
273
|
+
- **The Status column is gone.** The table is fetched with the status the
|
|
274
|
+
chosen tab asks for, so on the Active tab every row said "active" and on
|
|
275
|
+
Suspended every row said "suspended" — a column that could only ever repeat
|
|
276
|
+
the tab the reader was standing on. The status is still on the screen: it is
|
|
277
|
+
the tab, and the row menu offers Suspend or Activate accordingly.
|
|
278
|
+
|
|
279
|
+
Measured after the change: **1332px at a 1386px viewport and 1386px at 1440px,
|
|
280
|
+
with no horizontal overflow at either** — the first time this table has fitted
|
|
281
|
+
its own page.
|
|
282
|
+
|
|
283
|
+
A client whose record carries no nature reads "Not recorded" rather than
|
|
284
|
+
leaving a blank cell. `nature` is a required enum on the organisation model, so
|
|
285
|
+
that is defensive cover for older records, not an expected state.
|
|
286
|
+
|
|
287
|
+
No filter was added for it. The list's `$match` has no `nature` term, so the
|
|
288
|
+
only filter that could be built here would filter the ten rows on screen and
|
|
289
|
+
silently ignore the rest — the same kind of control that was removed from this
|
|
290
|
+
screen for pretending to work. A real one needs the aggregation to accept
|
|
291
|
+
`nature` first.
|
|
292
|
+
|
|
293
|
+
- e100fc9: Make the super-admin Client List say what a client's subscription actually is,
|
|
294
|
+
instead of printing "-".
|
|
295
|
+
|
|
296
|
+
`ClientMain.vue` showed a dash in Plan Type, Billing Cycle, Subscription Start
|
|
297
|
+
and Subscription End for every client with no subscription document - which is
|
|
298
|
+
most of them. Four dashes read as a broken screen. It is one ordinary state:
|
|
299
|
+
nobody has set that client up yet.
|
|
300
|
+
|
|
301
|
+
`utils/client-subscription.ts` derives the state from the row the endpoint
|
|
302
|
+
actually returns and never claims more than the record proves:
|
|
303
|
+
|
|
304
|
+
| Record says | Screen says |
|
|
305
|
+
| ------------------------------ | --------------------------- |
|
|
306
|
+
| no subscription document | No subscription set up |
|
|
307
|
+
| running | Active |
|
|
308
|
+
| `billingMode: "paid"` | Active (paying) |
|
|
309
|
+
| `billingMode: "complimentary"` | Complimentary (not charged) |
|
|
310
|
+
| `status: "suspended"` | Suspended |
|
|
311
|
+
| `status` ended / canceled | Ended |
|
|
312
|
+
| end date already passed | Ended (needs attention) |
|
|
313
|
+
|
|
314
|
+
Nothing sends a charging arrangement today, so a live record reads as plain
|
|
315
|
+
"Active" - not "paying", because nothing in the data proves that.
|
|
316
|
+
|
|
317
|
+
Removed as dead or misleading:
|
|
318
|
+
|
|
319
|
+
- The "Filter by plan type" dropdown. It was fed organization _nature_ values
|
|
320
|
+
(`security_agency`, `cleaning_services`, ...) and sent as `type`, which the
|
|
321
|
+
aggregation applies to `subscription.type` - whose only legal values are
|
|
322
|
+
`organization` and `affiliate`. Every option but "All" returned zero rows.
|
|
323
|
+
- `planTypeLabel`'s `sub.paidType` branch (the field exists in no repo) and its
|
|
324
|
+
`sub.description` branch (not in the endpoint's projection).
|
|
325
|
+
- `getSubscriptionByOrgId`, destructured and never called.
|
|
326
|
+
|
|
327
|
+
`ClientDetailForm.vue`'s Subscription Information section was six dropdowns and
|
|
328
|
+
two date boxes that looked editable and saved nothing - the plan list was two
|
|
329
|
+
invented names, and Start/End were always blank because nothing ever loaded
|
|
330
|
+
them. It now shows the same derived values as the list, read-only. The write
|
|
331
|
+
path is unchanged: Submit still saves the organisation name and contact.
|
|
332
|
+
|
|
333
|
+
Two smaller display fixes on the list: the two date column headers are "Start
|
|
334
|
+
Date" and "End Date" rather than "Subscription Start"/"Subscription End", and
|
|
335
|
+
the Sites badge is the count rather than "3 Sites" under a "Sites" header
|
|
336
|
+
(which also used to read "1 Sites"). Both were needed to keep the Actions
|
|
337
|
+
column on screen at 1440px.
|
|
338
|
+
|
|
339
|
+
- c256755: Let a Seven365 staff member actually set up and edit a client's subscription
|
|
340
|
+
from the super-admin Client List.
|
|
341
|
+
|
|
342
|
+
Phase 1 made that list tell the truth: a client nobody had set up read "No
|
|
343
|
+
subscription set up" instead of four dashes. There was still nothing anywhere
|
|
344
|
+
that could set one up. The API endpoints now exist
|
|
345
|
+
(`POST`/`PUT`/`GET /api/subscriptions/console/:id`, staff-only, `:id` being the
|
|
346
|
+
client's organisation), so this is the screen in front of them.
|
|
347
|
+
|
|
348
|
+
**What a person can now do**
|
|
349
|
+
|
|
350
|
+
- From a row that has no subscription: **Set up subscription**, straight off
|
|
351
|
+
the row.
|
|
352
|
+
- From the row menu, or from the client's own drawer: **Edit subscription** -
|
|
353
|
+
plan, start date, end date, and whether the client is complimentary or
|
|
354
|
+
paying.
|
|
355
|
+
- The list re-reads itself after a save, so the row shows the new state
|
|
356
|
+
immediately, using the same Phase 1 derivation (`utils/client-subscription.ts`).
|
|
357
|
+
|
|
358
|
+
**Four questions, and nothing else asked**
|
|
359
|
+
|
|
360
|
+
Plan, charging (complimentary or paying), start date, end date. Everything
|
|
361
|
+
else is worked out: a plan is priced per site, per month, so the form shows the
|
|
362
|
+
per-site price, this client's live site count and the monthly value that comes
|
|
363
|
+
out of the two - already multiplied, with "not charged - complimentary" beside
|
|
364
|
+
it when the client is free. A client with no sites yet is 0 and is said so
|
|
365
|
+
plainly rather than refused.
|
|
366
|
+
|
|
367
|
+
**Complimentary is the default**, because every client today is complimentary.
|
|
368
|
+
It is a real arrangement with real dates Seven365 monitors, not an absence of
|
|
369
|
+
one, and it is exactly as quick to set as a paying subscription.
|
|
370
|
+
|
|
371
|
+
**Refused in the form, before the request goes out** - in the API's own words,
|
|
372
|
+
so the two can never disagree: no plan chosen; a plan that does not exist or is
|
|
373
|
+
no longer active (named); no end date ("Every client has one, free or not."); an
|
|
374
|
+
end date on or before the start date; an unreadable date; a complimentary
|
|
375
|
+
subscription carrying a price. When the server refuses anyway, its message is
|
|
376
|
+
what the screen shows.
|
|
377
|
+
|
|
378
|
+
**Deliberately not here:** no card field, no payment step, no gateway - Red Dot
|
|
379
|
+
takes payment, and none of it belongs on a staff screen. No suspend and no
|
|
380
|
+
reactivate: that is Phase 3, and the API's edit endpoint does not accept a
|
|
381
|
+
status, so there is no control here that could set one. The drawer's
|
|
382
|
+
Subscription Information stays read-only; the button beside it opens this form.
|
|
383
|
+
|
|
384
|
+
`useSubscription` gains `getConsoleSubscriptionByOrgId`,
|
|
385
|
+
`setUpConsoleSubscription` and `updateConsoleSubscription`. Nothing existing
|
|
386
|
+
changed shape.
|
|
387
|
+
|
|
388
|
+
- 6b008c4: Split the dark theme's `primary` into a foreground colour and a fill, so text
|
|
389
|
+
using it is readable again.
|
|
390
|
+
|
|
391
|
+
Dark `primary` was set to the brand navy `#17506F` so the navigation drawer
|
|
392
|
+
would read as a surface. That is the right value for a fill — a white label on
|
|
393
|
+
it measures 8.70:1 — but Vuetify emits `.text-primary` from the same token, so
|
|
394
|
+
every `class="text-primary"` sentence and every `color="primary"` icon, spinner
|
|
395
|
+
and text button rendered navy on a dark background:
|
|
396
|
+
|
|
397
|
+
| | before | now |
|
|
398
|
+
| ----------------------------------------------- | ------ | ---------- |
|
|
399
|
+
| `primary` as text on the dark page `#0E1319` | 2.14:1 | **6.40:1** |
|
|
400
|
+
| `primary` as text on a dark card `#1B242F` | 1.80:1 | **5.38:1** |
|
|
401
|
+
| `primary` as text on `surface-bright` `#26313E` | 1.52:1 | **4.53:1** |
|
|
402
|
+
|
|
403
|
+
Dark `primary` is now `#5B9BE0`. That is not a new colour: it is the blue the
|
|
404
|
+
camera wall already draws its accents in (`--vms-accent`), so the two dark
|
|
405
|
+
surfaces in this product agree on one blue.
|
|
406
|
+
|
|
407
|
+
The navigation drawer no longer depends on `primary`. Both themes gain a
|
|
408
|
+
`brand-surface` colour — `#042134` light (identical to light `primary`, so
|
|
409
|
+
light mode renders exactly as before) and `#17506F` dark — and
|
|
410
|
+
`Layout/NavigationDrawer.vue` is painted with it. Its white label still reads
|
|
411
|
+
16.51:1 light and 8.70:1 dark. `floating` was dropped from the drawer so it
|
|
412
|
+
draws its own edge: the fill is 2.14:1 against the dark page, under the 3:1 a
|
|
413
|
+
boundary wants, and the border at `border-opacity` 0.35 is 3.22:1.
|
|
414
|
+
|
|
415
|
+
Where `primary` is still used as a fill, Vuetify derives `on-primary` from it
|
|
416
|
+
and picks black, which reads at 7.21:1. What a light `primary` cannot carry is
|
|
417
|
+
a hardcoded white label, and a handful of app-side sites still set one — see
|
|
418
|
+
the pull request for the list.
|
|
419
|
+
|
|
420
|
+
The light theme is unchanged. The colours moved to `utils/theme.ts` and
|
|
421
|
+
`utils/theme.test.ts` now measures them, so a token cannot be moved for one use
|
|
422
|
+
and quietly broken for the other again.
|
|
423
|
+
|
|
424
|
+
- 626d1d7: Dashboard and filled-primary surfaces now follow the theme in dark mode.
|
|
425
|
+
|
|
426
|
+
- `DashboardMain` painted its panels, list rows and text with fixed hex values,
|
|
427
|
+
so the ACTIVE PATROL / STAFF STATUS / Feedbacks cards rendered white on the
|
|
428
|
+
dark page and the heading and KPI figures were dark grey on near-black
|
|
429
|
+
(1.19:1). They now use the theme's surface and foreground tokens.
|
|
430
|
+
- `DARK_THEME` had no `on-primary`, so Vuetify derived white for every filled
|
|
431
|
+
`primary` button, chip, alert, tab bar and snackbar - 2.91:1. It is now set
|
|
432
|
+
explicitly and measures 6.40:1.
|
|
433
|
+
|
|
434
|
+
The print / PDF / Word export styles are unchanged and stay ink-on-white.
|
|
435
|
+
|
|
436
|
+
- a39ce7f: Fix four hard-coded route names that do not exist in the apps that render them.
|
|
437
|
+
|
|
438
|
+
This layer is extended by apps whose pages sit at different depths, so a route
|
|
439
|
+
name written literally here is correct in some consumers and unknown in the
|
|
440
|
+
rest - and `router.push({ name })` on an unknown name throws, taking the click
|
|
441
|
+
(and, in a nav list, its neighbours) with it. Route names are now resolved
|
|
442
|
+
against the running app's own router via `utils/route-name.ts`
|
|
443
|
+
(`pickRouteName`), which returns the first candidate the app actually has, or
|
|
444
|
+
`null` so the caller can hide the control rather than navigate somewhere wrong.
|
|
445
|
+
|
|
446
|
+
- `QrTemplate/PrintDialog.vue` pushed `keys-visitor-pass-add`, which no
|
|
447
|
+
consumer has; both apps that render this dialog nest the page under
|
|
448
|
+
`[org]/[site]`. It also sent `site` as a query parameter where the
|
|
449
|
+
destination reads `org` and `site` as path parameters, so neither was
|
|
450
|
+
filled. Generating a pass from the print dialog threw.
|
|
451
|
+
- `ServiceProviderMain.vue` always rendered a Billing button
|
|
452
|
+
(`showBillingButton` defaults to `true`) pointing at
|
|
453
|
+
`org-site-service-provider-mgmt-billing`, a page only
|
|
454
|
+
web-app-property-management has. The button now renders only where the page
|
|
455
|
+
exists; property-management is unchanged.
|
|
456
|
+
- `require-customer.vue` used `org-organizations-customers-add`; the org app's
|
|
457
|
+
page is `pages/org/[organization]/customers/add.vue`, whose name is
|
|
458
|
+
`org-organization-customers-add` - singular. The only way off that dead-end
|
|
459
|
+
page threw. Every other app already took the cross-app URL branch and was
|
|
460
|
+
unaffected.
|
|
461
|
+
- `WorkOrder/Main.vue` used `work-order-details`, which no consumer has; the
|
|
462
|
+
detail screen is `org-site-work-orders-id`. No app currently renders this
|
|
463
|
+
component, so nothing was user-visible.
|
|
464
|
+
|
|
465
|
+
`require-organization-membership.vue` was reported alongside these and is NOT
|
|
466
|
+
changed: `organizations-create` is a real route in web-app-org, and that branch
|
|
467
|
+
only runs there.
|
|
468
|
+
|
|
469
|
+
- ae59ec4: Say when the Service Providers list and the Attendance Settings dialog failed to load, instead of showing an empty screen
|
|
470
|
+
|
|
471
|
+
Both surfaces treated a failed request as "there is nothing here". The Service Providers table defaulted a bad response to `[]` and drew the standard "No data available" row; the Attendance Settings dialog read its fields off whatever came back and, when that was not an object, simply opened with geofencing off and every field blank. Neither told the operator anything had gone wrong.
|
|
472
|
+
|
|
473
|
+
The table now renders the shared `DashboardEmptyState` in its error tone when the last load failed, and the dialog shows an alert saying what is on screen is not what is saved.
|
|
474
|
+
|
|
475
|
+
- 82d0091: Send the site with HID reader discovery. `HidReaderForm` now receives the site
|
|
476
|
+
from `HidReaderManagement` (which already has it from the page route) and
|
|
477
|
+
includes it in the `readers/discover` request, so the endpoint can authorize the
|
|
478
|
+
caller against that site. The request falls back to the old body once, and only
|
|
479
|
+
on the unknown-field validation error, so discovery keeps working against an API
|
|
480
|
+
that has not picked the change up yet.
|
|
481
|
+
- c887977: Add a Notification Settings screen, shared by every web app.
|
|
482
|
+
|
|
483
|
+
A person can now choose, per module and per delivery route, what they hear
|
|
484
|
+
about: in the app, on their phone, or by email. The screen only lists modules
|
|
485
|
+
their role actually gives them, and a route a module cannot use is shown dimmed
|
|
486
|
+
with the reason in plain words rather than hidden.
|
|
487
|
+
|
|
488
|
+
Modules are collapsed by default with a one-line summary of what is on, so
|
|
489
|
+
somebody with many modules gets a short list instead of thirty switches. There
|
|
490
|
+
is a master control for everything and one per module.
|
|
491
|
+
|
|
492
|
+
The choices are enforced where notifications are sent, in `@7365admin1/core` and
|
|
493
|
+
in the API, so a switch here actually stops the message.
|
|
494
|
+
|
|
495
|
+
- 5089b71: Group the sidebar's sections and give the list screens the design's page
|
|
496
|
+
scaffold.
|
|
497
|
+
|
|
498
|
+
- `withSections()` walked each application's menu in the order it was given and
|
|
499
|
+
printed a heading whenever the section changed, so Property Management drew
|
|
500
|
+
SERVICE DESK, PROPERTY and SECURITY OPERATIONS **twice each**. Items are now
|
|
501
|
+
grouped under one heading per section, in the order the design lists them,
|
|
502
|
+
with the application's own order kept inside each section. No menu item is
|
|
503
|
+
added, hidden, renamed, re-routed or re-gated.
|
|
504
|
+
- `ServiceProviderMain`, `FeedbackMain`, `InvitationMain` and `WorkOrder/Main`
|
|
505
|
+
had no page title, put their primary action top-left, floated the search
|
|
506
|
+
outside the card, and stacked the refresh/pager on a bar above the tabs. They
|
|
507
|
+
now use `PageHeader` / `AppCard` / `CardToolbar` / `AppButton` / `AppField`:
|
|
508
|
+
a 22px title with the action on the right, then one in-card row with the tabs
|
|
509
|
+
on the left and search, count and pager on the right.
|
|
510
|
+
- `SwitchContext` and the rail's application name print in full instead of
|
|
511
|
+
truncating to "Seventh Cond…" / "Property Managem…".
|
|
512
|
+
- The security dashboard's `ACTIVE PATROL` / `STAFF STATUS` panel headings are
|
|
513
|
+
written the way the design writes them, matching their sibling panels.
|
|
514
|
+
|
|
515
|
+
Placement and wording only: the same buttons open the same dialogs under the
|
|
516
|
+
same permissions, the same tabs load the same lists, and the tables, columns,
|
|
517
|
+
filters and row menus are untouched.
|
|
518
|
+
|
|
519
|
+
- 8813b26: Work Orders and Feedbacks: say why the list is empty instead of quoting an
|
|
520
|
+
internal id. A service-provider account viewing a site its company is not linked
|
|
521
|
+
to got an empty table and the toast "Service provider with siteId
|
|
522
|
+
68f0...dadada not found" - an internal sentence, naming no action, on a screen
|
|
523
|
+
that then looked broken. The sidebar offers every site in the organisation, not
|
|
524
|
+
only the ones the account covers, so this is an ordinary mis-click rather than a
|
|
525
|
+
fault. `findServiceProviderIdBySite` still refuses (returning null would drop the
|
|
526
|
+
provider filter and show another provider's work orders) but now refuses with a
|
|
527
|
+
sentence the reader can act on, and no longer dies with a TypeError when the
|
|
528
|
+
account carries no provider list at all. Unit tests added in `utils/data.test.ts`.
|
|
529
|
+
Affects the six service-provider applications that call it: security, hygiene,
|
|
530
|
+
landscape, mechanical-electrical, pest-control and pool-mgmt.
|
|
531
|
+
- 309d0e3: Fix the duplicate `:headers` binding on `<v-data-table>` in `TableMain.vue`
|
|
532
|
+
that broke every consuming app's build.
|
|
533
|
+
|
|
534
|
+
The merge of `staging` into `feature/DV-0119` kept both sides' `:headers`
|
|
535
|
+
line: the branch's older `:headers="headers"` and staging's newer
|
|
536
|
+
`:headers="tableHeaders"`. Vue's template compiler rejects a duplicate
|
|
537
|
+
attribute outright ("Duplicate attribute."), so any app that compiles this
|
|
538
|
+
component fails to build. This package's own `yarn build` only builds
|
|
539
|
+
`.playground`, which never imports `TableMain.vue`, so the fault published
|
|
540
|
+
as `3.2.8-staging.201` and surfaced only in the eleven web apps.
|
|
541
|
+
|
|
542
|
+
`:headers="tableHeaders"` is the correct one and is kept.
|
|
543
|
+
`tableHeaders` returns `props.headers` unchanged unless a caller has put
|
|
544
|
+
`weight` on a column, so it is a strict superset of the old binding - no
|
|
545
|
+
caller behaviour changes, and the weighted-column support added in
|
|
546
|
+
"Bring the shared table card up to the design's table spec" stays working.
|
|
547
|
+
|
|
548
|
+
Also adds `test/sfc-compile.test.mjs`, which compiles every `.vue` template in
|
|
549
|
+
the package and fails on any template syntax error. It fails on the broken
|
|
550
|
+
file and passes on the fixed one.
|
|
551
|
+
|
|
3
552
|
## 3.2.7
|
|
4
553
|
|
|
5
554
|
### Patch Changes
|
|
@@ -616,7 +616,7 @@ async function openPermissionDialog(category: THidPermissionCategory | "intercom
|
|
|
616
616
|
page: 1,
|
|
617
617
|
limit: 500,
|
|
618
618
|
});
|
|
619
|
-
permissionCandidates.value = response.items
|
|
619
|
+
permissionCandidates.value = response.items || [];
|
|
620
620
|
selectedPermissionIds.value = new Set(
|
|
621
621
|
permissionCandidates.value
|
|
622
622
|
.filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
|
|
@@ -936,7 +936,7 @@ async function loadSubjectCandidates() {
|
|
|
936
936
|
page: 1,
|
|
937
937
|
limit: 500,
|
|
938
938
|
});
|
|
939
|
-
permissionCandidates.value = response.items
|
|
939
|
+
permissionCandidates.value = response.items || [];
|
|
940
940
|
} finally {
|
|
941
941
|
loadingSubjects.value = false;
|
|
942
942
|
}
|
|
@@ -80,18 +80,11 @@ type HidVisitorQrData = {
|
|
|
80
80
|
type HidVisitorQrResponse = HidVisitorQrData | { data: HidVisitorQrData };
|
|
81
81
|
|
|
82
82
|
type HidPermissionListResponse = {
|
|
83
|
-
items
|
|
84
|
-
page
|
|
85
|
-
pages
|
|
86
|
-
pageRange
|
|
87
|
-
limit
|
|
88
|
-
data?: {
|
|
89
|
-
items?: THidPermissionCandidate[];
|
|
90
|
-
page?: number;
|
|
91
|
-
pages?: number;
|
|
92
|
-
pageRange?: string;
|
|
93
|
-
limit?: number;
|
|
94
|
-
};
|
|
83
|
+
items: THidPermissionCandidate[];
|
|
84
|
+
page: number;
|
|
85
|
+
pages: number;
|
|
86
|
+
pageRange: string;
|
|
87
|
+
limit: number;
|
|
95
88
|
};
|
|
96
89
|
|
|
97
90
|
type HidFacialEnrollmentResult = {
|
|
@@ -28,66 +28,9 @@ export default function usePromoCode() {
|
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** One record, by its id. The console's detail screen opens on this. */
|
|
32
|
-
function getById(id: string) {
|
|
33
|
-
return useNuxtApp().$api<Record<string, any>>(
|
|
34
|
-
`/api/promo-codes/id/${id}`,
|
|
35
|
-
{
|
|
36
|
-
method: "GET",
|
|
37
|
-
}
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Edit a code.
|
|
43
|
-
*
|
|
44
|
-
* `code` is NOT sent. It is immutable on the server - a subscription records
|
|
45
|
-
* the promo code it was bought with as text, so renaming one rewrites every
|
|
46
|
-
* invoice that already quotes it. The API accepts a `code` key and ignores
|
|
47
|
-
* it; leaving it out of the body means the screen can never look as though
|
|
48
|
-
* it changed something it did not.
|
|
49
|
-
*/
|
|
50
|
-
function update(id: string, value: Partial<TPromoCode>) {
|
|
51
|
-
const { code, ...body } = value as Record<string, any>;
|
|
52
|
-
|
|
53
|
-
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
54
|
-
method: "PUT",
|
|
55
|
-
body,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Turn a code off, or back on. The only two states staff may set. */
|
|
60
|
-
function updateStatus(id: string, status: "active" | "disabled") {
|
|
61
|
-
return useNuxtApp().$api<Record<string, any>>(
|
|
62
|
-
`/api/promo-codes/${id}/status`,
|
|
63
|
-
{
|
|
64
|
-
method: "PATCH",
|
|
65
|
-
body: { status },
|
|
66
|
-
}
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Remove a code from the console.
|
|
72
|
-
*
|
|
73
|
-
* A SOFT delete on the server: the record stays and is marked disabled and
|
|
74
|
-
* deleted, because subscriptions and invoices quote the code they were
|
|
75
|
-
* bought with. It leaves the list and stops being redeemable; it is not
|
|
76
|
-
* erased.
|
|
77
|
-
*/
|
|
78
|
-
function remove(id: string) {
|
|
79
|
-
return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
|
|
80
|
-
method: "DELETE",
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
31
|
return {
|
|
85
32
|
add,
|
|
86
33
|
getPromoCodes,
|
|
87
34
|
getByCode,
|
|
88
|
-
getById,
|
|
89
|
-
update,
|
|
90
|
-
updateStatus,
|
|
91
|
-
remove,
|
|
92
35
|
};
|
|
93
36
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.3.1",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
promoExpiryDate,
|
|
6
|
-
promoCodeFormValues,
|
|
7
|
-
promoCodeUpdatePayload,
|
|
8
|
-
validatePromoCodeEdit,
|
|
9
|
-
promoCodeApiError,
|
|
10
|
-
} from "./promo-code-form.ts";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* THE PROMO CODE EDITOR AND THE THREE WRITE CALLS BEHIND IT.
|
|
14
|
-
*
|
|
15
|
-
* Two halves, both pinned here:
|
|
16
|
-
*
|
|
17
|
-
* 1. the rules the edit form applies before it sends anything - taken from
|
|
18
|
-
* the MERGED backend (`promoCodeUpdateSchema` in `@7365admin1/core`), not
|
|
19
|
-
* from a guess about it;
|
|
20
|
-
* 2. what `usePromoCode()` actually puts on the wire. The composable is three
|
|
21
|
-
* `$api` calls, and the thing that can silently be wrong about it is the
|
|
22
|
-
* verb, the path or the body - a `PUT` to the wrong path, or a body that
|
|
23
|
-
* carries `code` and so looks as though it renamed a code it cannot.
|
|
24
|
-
*
|
|
25
|
-
* The composable reaches for `useNuxtApp()`, which only exists inside a Nuxt
|
|
26
|
-
* app, so one stub stands in for it and records the call.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
const NOW = new Date(2026, 7, 20, 12, 0, 0); // 20 Aug 2026, midday
|
|
30
|
-
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
// The expiry rule
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
test("an expiry date is read as the END of its day, in both stored formats", () => {
|
|
36
|
-
// A code dated today is good for all of today, so a check at midday passes.
|
|
37
|
-
const usFormat = promoExpiryDate("08/20/2026");
|
|
38
|
-
assert.ok(usFormat);
|
|
39
|
-
assert.equal(usFormat!.getHours(), 23);
|
|
40
|
-
assert.ok(usFormat!.getTime() > NOW.getTime());
|
|
41
|
-
|
|
42
|
-
const iso = promoExpiryDate("2026-08-20");
|
|
43
|
-
assert.ok(iso);
|
|
44
|
-
assert.equal(iso!.getTime(), usFormat!.getTime());
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("a date that is not a date is no expiry at all, never an Invalid Date", () => {
|
|
48
|
-
assert.equal(promoExpiryDate(""), null);
|
|
49
|
-
assert.equal(promoExpiryDate(null), null);
|
|
50
|
-
assert.equal(promoExpiryDate("whenever"), null);
|
|
51
|
-
// Month 13 rolls into the next year in JavaScript rather than failing, so
|
|
52
|
-
// it is caught explicitly - otherwise 13/01/2026 would read as Jan 2027.
|
|
53
|
-
assert.equal(promoExpiryDate("13/01/2026"), null);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Loading a record into the form, and building the body back out
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
test("the form loads from the record and does NOT carry the code", () => {
|
|
61
|
-
const values = promoCodeFormValues({
|
|
62
|
-
_id: "652f1a2b3c4d5e6f70819293",
|
|
63
|
-
code: "WELCOME2026",
|
|
64
|
-
description: "Launch offer",
|
|
65
|
-
type: "fixed",
|
|
66
|
-
fixed_rate: 12,
|
|
67
|
-
expiresAt: "12/31/2026",
|
|
68
|
-
status: "active",
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
assert.equal((values as any).code, undefined);
|
|
72
|
-
assert.deepEqual(values, {
|
|
73
|
-
description: "Launch offer",
|
|
74
|
-
type: "fixed",
|
|
75
|
-
fixed_rate: 12,
|
|
76
|
-
expiresAt: "12/31/2026",
|
|
77
|
-
tiers: [],
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("a tiered record keeps its bands; a fixed one is given none", () => {
|
|
82
|
-
const tiered = promoCodeFormValues({
|
|
83
|
-
type: "tiered",
|
|
84
|
-
tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
|
|
85
|
-
});
|
|
86
|
-
assert.equal(tiered.tiers.length, 2);
|
|
87
|
-
assert.deepEqual(tiered.tiers[1], { min: 11, max: 0, price: 7 });
|
|
88
|
-
|
|
89
|
-
// A code stored as fixed but carrying leftover bands must not send them:
|
|
90
|
-
// `promoCodeUpdateSchema` FORBIDS `tiers` on a fixed code and would 400.
|
|
91
|
-
const fixed = promoCodeFormValues({
|
|
92
|
-
type: "fixed",
|
|
93
|
-
fixed_rate: 5,
|
|
94
|
-
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
95
|
-
});
|
|
96
|
-
assert.deepEqual(fixed.tiers, []);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("an empty record gives a usable, valid, fixed-price form", () => {
|
|
100
|
-
const values = promoCodeFormValues(null);
|
|
101
|
-
assert.deepEqual(values, {
|
|
102
|
-
description: "",
|
|
103
|
-
type: "fixed",
|
|
104
|
-
fixed_rate: 0,
|
|
105
|
-
expiresAt: "",
|
|
106
|
-
tiers: [],
|
|
107
|
-
});
|
|
108
|
-
assert.equal(validatePromoCodeEdit(promoCodeUpdatePayload(values), NOW), null);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("the payload never contains code, _id or status", () => {
|
|
112
|
-
const payload = promoCodeUpdatePayload({
|
|
113
|
-
description: "Launch offer",
|
|
114
|
-
type: "fixed",
|
|
115
|
-
fixed_rate: 12,
|
|
116
|
-
expiresAt: "12/31/2026",
|
|
117
|
-
tiers: [],
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
assert.deepEqual(Object.keys(payload).sort(), [
|
|
121
|
-
"description",
|
|
122
|
-
"expiresAt",
|
|
123
|
-
"fixed_rate",
|
|
124
|
-
"type",
|
|
125
|
-
]);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("a tiered payload sends bands and no fixed_rate, and the reverse", () => {
|
|
129
|
-
const tiered = promoCodeUpdatePayload({
|
|
130
|
-
description: "",
|
|
131
|
-
type: "tiered",
|
|
132
|
-
fixed_rate: 99,
|
|
133
|
-
expiresAt: "",
|
|
134
|
-
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
135
|
-
});
|
|
136
|
-
assert.deepEqual(tiered.tiers, [{ min: 1, max: 10, price: 9 }]);
|
|
137
|
-
assert.equal("fixed_rate" in tiered, false);
|
|
138
|
-
|
|
139
|
-
const fixed = promoCodeUpdatePayload({
|
|
140
|
-
description: "",
|
|
141
|
-
type: "fixed",
|
|
142
|
-
fixed_rate: 9,
|
|
143
|
-
expiresAt: "",
|
|
144
|
-
tiers: [{ min: 1, max: 10, price: 9 }],
|
|
145
|
-
});
|
|
146
|
-
assert.equal("tiers" in fixed, false);
|
|
147
|
-
assert.equal(fixed.fixed_rate, 9);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("a price nobody has typed is NOT quietly saved as free", () => {
|
|
151
|
-
// `Number("")` is 0. Coercing a blank field would turn "not filled in" into
|
|
152
|
-
// "every seat is free" - a real price, saved without anyone choosing it.
|
|
153
|
-
const payload = promoCodeUpdatePayload({
|
|
154
|
-
description: "",
|
|
155
|
-
type: "fixed",
|
|
156
|
-
fixed_rate: "" as any,
|
|
157
|
-
expiresAt: "",
|
|
158
|
-
tiers: [],
|
|
159
|
-
});
|
|
160
|
-
assert.equal(payload.fixed_rate, "");
|
|
161
|
-
assert.match(
|
|
162
|
-
String(validatePromoCodeEdit(payload, NOW)),
|
|
163
|
-
/Enter a price per seat/
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
// A deliberate 0, however, is a real answer and goes through.
|
|
167
|
-
const free = promoCodeUpdatePayload({
|
|
168
|
-
description: "",
|
|
169
|
-
type: "fixed",
|
|
170
|
-
fixed_rate: 0,
|
|
171
|
-
expiresAt: "",
|
|
172
|
-
tiers: [],
|
|
173
|
-
});
|
|
174
|
-
assert.equal(free.fixed_rate, 0);
|
|
175
|
-
assert.equal(validatePromoCodeEdit(free, NOW), null);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("band numbers typed as text reach the API as numbers", () => {
|
|
179
|
-
const payload = promoCodeUpdatePayload({
|
|
180
|
-
description: "",
|
|
181
|
-
type: "tiered",
|
|
182
|
-
fixed_rate: 0,
|
|
183
|
-
expiresAt: "",
|
|
184
|
-
tiers: [{ min: "1", max: "10", price: "9.5" } as any],
|
|
185
|
-
});
|
|
186
|
-
assert.deepEqual(payload.tiers, [{ min: 1, max: 10, price: 9.5 }]);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
// What the form refuses, in the API's terms
|
|
191
|
-
// ---------------------------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
test("a valid edit is refused nothing", () => {
|
|
194
|
-
assert.equal(
|
|
195
|
-
validatePromoCodeEdit(
|
|
196
|
-
{ type: "fixed", fixed_rate: 0, expiresAt: "", description: "" },
|
|
197
|
-
NOW
|
|
198
|
-
),
|
|
199
|
-
null
|
|
200
|
-
);
|
|
201
|
-
assert.equal(
|
|
202
|
-
validatePromoCodeEdit(
|
|
203
|
-
{
|
|
204
|
-
type: "tiered",
|
|
205
|
-
tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
|
|
206
|
-
expiresAt: "12/31/2026",
|
|
207
|
-
},
|
|
208
|
-
NOW
|
|
209
|
-
),
|
|
210
|
-
null
|
|
211
|
-
);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("an expiry already in the past is refused, and says what to do", () => {
|
|
215
|
-
const message = validatePromoCodeEdit(
|
|
216
|
-
{ type: "fixed", fixed_rate: 5, expiresAt: "01/01/2020" },
|
|
217
|
-
NOW
|
|
218
|
-
);
|
|
219
|
-
assert.match(String(message), /already passed/);
|
|
220
|
-
assert.match(String(message), /clear the date/);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("TODAY is not in the past - a code expiring today is still savable", () => {
|
|
224
|
-
assert.equal(
|
|
225
|
-
validatePromoCodeEdit(
|
|
226
|
-
{ type: "fixed", fixed_rate: 5, expiresAt: "08/20/2026" },
|
|
227
|
-
NOW
|
|
228
|
-
),
|
|
229
|
-
null
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("no expiry at all is allowed - that is a code with no end date", () => {
|
|
234
|
-
assert.equal(
|
|
235
|
-
validatePromoCodeEdit({ type: "fixed", fixed_rate: 5, expiresAt: "" }, NOW),
|
|
236
|
-
null
|
|
237
|
-
);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test("an unreadable expiry names the format instead of guessing", () => {
|
|
241
|
-
assert.match(
|
|
242
|
-
String(
|
|
243
|
-
validatePromoCodeEdit(
|
|
244
|
-
{ type: "fixed", fixed_rate: 5, expiresAt: "next Tuesday" },
|
|
245
|
-
NOW
|
|
246
|
-
)
|
|
247
|
-
),
|
|
248
|
-
/MM\/DD\/YYYY/
|
|
249
|
-
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("a free BAND is refused, because Joi's positive() refuses it", () => {
|
|
253
|
-
const message = validatePromoCodeEdit(
|
|
254
|
-
{ type: "tiered", tiers: [{ min: 1, max: 10, price: 0 }] },
|
|
255
|
-
NOW
|
|
256
|
-
);
|
|
257
|
-
assert.match(String(message), /^Band 1: /);
|
|
258
|
-
assert.match(String(message), /above 0/);
|
|
259
|
-
// ...and it says the way to actually make seats free, which is the other
|
|
260
|
-
// pricing shape. `fixed_rate` is `min(0)`, not `positive()`.
|
|
261
|
-
assert.equal(
|
|
262
|
-
validatePromoCodeEdit({ type: "fixed", fixed_rate: 0 }, NOW),
|
|
263
|
-
null
|
|
264
|
-
);
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("the refused band is named, not just 'a band'", () => {
|
|
268
|
-
assert.match(
|
|
269
|
-
String(
|
|
270
|
-
validatePromoCodeEdit(
|
|
271
|
-
{
|
|
272
|
-
type: "tiered",
|
|
273
|
-
tiers: [
|
|
274
|
-
{ min: 1, max: 10, price: 9 },
|
|
275
|
-
{ min: 11, max: 5, price: 7 },
|
|
276
|
-
],
|
|
277
|
-
},
|
|
278
|
-
NOW
|
|
279
|
-
)
|
|
280
|
-
),
|
|
281
|
-
/^Band 2: /
|
|
282
|
-
);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test("a band with no upper limit is allowed - 0 means 'and above'", () => {
|
|
286
|
-
assert.equal(
|
|
287
|
-
validatePromoCodeEdit(
|
|
288
|
-
{ type: "tiered", tiers: [{ min: 5, max: 0, price: 7 }] },
|
|
289
|
-
NOW
|
|
290
|
-
),
|
|
291
|
-
null
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("a tiered code with no bands is refused, and offers the other shape", () => {
|
|
296
|
-
assert.match(
|
|
297
|
-
String(validatePromoCodeEdit({ type: "tiered", tiers: [] }, NOW)),
|
|
298
|
-
/at least one quantity band/
|
|
299
|
-
);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
test("a negative fixed price is refused; a missing one is refused too", () => {
|
|
303
|
-
assert.match(
|
|
304
|
-
String(validatePromoCodeEdit({ type: "fixed", fixed_rate: -1 }, NOW)),
|
|
305
|
-
/cannot be less than 0/
|
|
306
|
-
);
|
|
307
|
-
assert.match(
|
|
308
|
-
String(validatePromoCodeEdit({ type: "fixed", fixed_rate: "" }, NOW)),
|
|
309
|
-
/Enter a price per seat/
|
|
310
|
-
);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("a code with no pricing shape is refused first, before anything else", () => {
|
|
314
|
-
assert.equal(
|
|
315
|
-
validatePromoCodeEdit({ expiresAt: "01/01/2020" }, NOW),
|
|
316
|
-
"Choose how this code prices seats."
|
|
317
|
-
);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
// ---------------------------------------------------------------------------
|
|
321
|
-
// What a refusal from the server reads like
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
|
|
324
|
-
test("the API's own wording is what the screen shows", () => {
|
|
325
|
-
assert.equal(
|
|
326
|
-
promoCodeApiError(
|
|
327
|
-
{ response: { _data: { message: "Promo code not found." } } },
|
|
328
|
-
"Could not save."
|
|
329
|
-
),
|
|
330
|
-
"Promo code not found."
|
|
331
|
-
);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test("'Not authorized.' is replaced with something a person can act on", () => {
|
|
335
|
-
const message = promoCodeApiError(
|
|
336
|
-
{ data: { message: "Not authorized." } },
|
|
337
|
-
"Could not save."
|
|
338
|
-
);
|
|
339
|
-
assert.match(message, /Seven365 staff account/);
|
|
340
|
-
assert.match(message, /Ask a Seven365 administrator/);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
test("an unreadable failure falls back rather than dumping the error", () => {
|
|
344
|
-
assert.equal(
|
|
345
|
-
promoCodeApiError(new Error("fetch failed"), "Could not save the changes."),
|
|
346
|
-
"Could not save the changes."
|
|
347
|
-
);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// ---------------------------------------------------------------------------
|
|
351
|
-
// The composable: verb, path and body of every write
|
|
352
|
-
// ---------------------------------------------------------------------------
|
|
353
|
-
|
|
354
|
-
const calls: Array<{ path: string; options: any }> = [];
|
|
355
|
-
|
|
356
|
-
(globalThis as any).useNuxtApp = () => ({
|
|
357
|
-
$api: (path: string, options: any = {}) => {
|
|
358
|
-
calls.push({ path, options });
|
|
359
|
-
return Promise.resolve({ message: "ok" });
|
|
360
|
-
},
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
const promoCodeApi = (await import("../composables/usePromoCode.ts")).default();
|
|
364
|
-
|
|
365
|
-
function lastCall() {
|
|
366
|
-
return calls[calls.length - 1];
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const ID = "652f1a2b3c4d5e6f70819293";
|
|
370
|
-
|
|
371
|
-
test("getById reads the one record, from the path the API mounts", async () => {
|
|
372
|
-
await promoCodeApi.getById(ID);
|
|
373
|
-
assert.equal(lastCall().path, `/api/promo-codes/id/${ID}`);
|
|
374
|
-
assert.equal(lastCall().options.method, "GET");
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
test("update is a PUT to /api/promo-codes/:id", async () => {
|
|
378
|
-
await promoCodeApi.update(ID, {
|
|
379
|
-
description: "Launch offer",
|
|
380
|
-
type: "fixed",
|
|
381
|
-
fixed_rate: 12,
|
|
382
|
-
expiresAt: "12/31/2026",
|
|
383
|
-
} as any);
|
|
384
|
-
|
|
385
|
-
assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
|
|
386
|
-
assert.equal(lastCall().options.method, "PUT");
|
|
387
|
-
assert.equal(lastCall().options.body.description, "Launch offer");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("update NEVER sends code, however it is called", async () => {
|
|
391
|
-
// The screen loads a record and could easily send it back whole. `code` is
|
|
392
|
-
// immutable on the server; sending it would be accepted and dropped, and
|
|
393
|
-
// the person would watch a rename succeed and change nothing.
|
|
394
|
-
await promoCodeApi.update(ID, {
|
|
395
|
-
code: "RENAMED",
|
|
396
|
-
type: "fixed",
|
|
397
|
-
fixed_rate: 1,
|
|
398
|
-
} as any);
|
|
399
|
-
|
|
400
|
-
assert.equal("code" in lastCall().options.body, false);
|
|
401
|
-
assert.equal(lastCall().options.body.type, "fixed");
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("updateStatus is a PATCH carrying only the new status", async () => {
|
|
405
|
-
await promoCodeApi.updateStatus(ID, "disabled");
|
|
406
|
-
assert.equal(lastCall().path, `/api/promo-codes/${ID}/status`);
|
|
407
|
-
assert.equal(lastCall().options.method, "PATCH");
|
|
408
|
-
assert.deepEqual(lastCall().options.body, { status: "disabled" });
|
|
409
|
-
|
|
410
|
-
await promoCodeApi.updateStatus(ID, "active");
|
|
411
|
-
assert.deepEqual(lastCall().options.body, { status: "active" });
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("remove is a DELETE, with no body to get wrong", async () => {
|
|
415
|
-
await promoCodeApi.remove(ID);
|
|
416
|
-
assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
|
|
417
|
-
assert.equal(lastCall().options.method, "DELETE");
|
|
418
|
-
assert.equal(lastCall().options.body, undefined);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
test("the four reads and writes are all on the composable", () => {
|
|
422
|
-
for (const name of [
|
|
423
|
-
"add",
|
|
424
|
-
"getPromoCodes",
|
|
425
|
-
"getByCode",
|
|
426
|
-
"getById",
|
|
427
|
-
"update",
|
|
428
|
-
"updateStatus",
|
|
429
|
-
"remove",
|
|
430
|
-
]) {
|
|
431
|
-
assert.equal(
|
|
432
|
-
typeof (promoCodeApi as any)[name],
|
|
433
|
-
"function",
|
|
434
|
-
`usePromoCode() is missing ${name}`
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
});
|
package/utils/promo-code-form.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE STAFF CONSOLE'S PROMO CODE EDITOR, DECIDED IN ONE PLACE.
|
|
3
|
-
*
|
|
4
|
-
* A Seven365 staff member opens a code, changes what it prices or when it
|
|
5
|
-
* stops, turns it off, turns it back on, or removes it. Everything the screen
|
|
6
|
-
* has to be sure of before it sends the request is worked out here, so the
|
|
7
|
-
* form and the API can never disagree about what is wrong.
|
|
8
|
-
*
|
|
9
|
-
* WHAT THE SERVER ACTUALLY ENFORCES, read from the merged backend rather than
|
|
10
|
-
* assumed (`@7365admin1/core` `models/promo-code.model.ts` `promoCodeUpdate` /
|
|
11
|
-
* `promoCodeUpdateSchema`, mounted by `API-core` as `PUT /api/promo-codes/:id`,
|
|
12
|
-
* `PATCH /api/promo-codes/:id/status`, `DELETE /api/promo-codes/:id`):
|
|
13
|
-
*
|
|
14
|
-
* - `code` is IMMUTABLE. The update schema accepts the key and drops it,
|
|
15
|
-
* because a subscription records the promo code it was bought with as
|
|
16
|
-
* text. The form must not offer to change it, or a person would watch a
|
|
17
|
-
* save succeed and the code stay as it was.
|
|
18
|
-
* - `type` is required and is "fixed" or "tiered".
|
|
19
|
-
* - a tiered code needs at least one band; every band starts at 1 or more,
|
|
20
|
-
* ends at or after where it starts (0 means "and above"), and is priced at
|
|
21
|
-
* MORE than 0 - Joi's `positive()`, so a free band is refused.
|
|
22
|
-
* - a fixed code needs a price of 0 or more; 0 is legal and means free.
|
|
23
|
-
* - `expiresAt` is free text to the server. It refuses nothing, which is
|
|
24
|
-
* exactly why a date already in the past is caught HERE: saving one is not
|
|
25
|
-
* an error, it is a code that silently stops working. Since core #1885 the
|
|
26
|
-
* shared lookup DOES refuse an expired or disabled code at checkout, so a
|
|
27
|
-
* past date is now a real off-switch rather than a decoration.
|
|
28
|
-
*
|
|
29
|
-
* Refusals are returned one at a time and in the API's order. A list of five
|
|
30
|
-
* complaints about one form is harder to act on than the first thing to fix.
|
|
31
|
-
*
|
|
32
|
-
* Nothing here takes a card, quotes a total, or moves money. Red Dot is the
|
|
33
|
-
* payment provider (owner decision 1); a promo code sets a price per seat.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
// Imported rather than left to Nuxt's auto-import so this file also loads
|
|
37
|
-
// under plain `node --test`, the same way `theme-aa-ledger.ts` imports
|
|
38
|
-
// `theme.ts`. One reader of a failed request for the whole console.
|
|
39
|
-
import { readApiError } from "./subscription-form.ts";
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* `expiresAt` is stored as free text and this console writes MM/DD/YYYY, but
|
|
43
|
-
* nothing has ever stopped another caller storing ISO - so both are read.
|
|
44
|
-
* Returns the END of the expiry day: a code dated today is good all of today.
|
|
45
|
-
*
|
|
46
|
-
* Deliberately the same rule as the org app's `utils/promo-code.js` and core's
|
|
47
|
-
* `promo-code-currency.util.ts`. The three are separate copies on purpose -
|
|
48
|
-
* the org app's is plain JS so `node --test` loads it directly, and core's
|
|
49
|
-
* runs on the server - but they must never disagree in front of a customer,
|
|
50
|
-
* so any change to one belongs in all three.
|
|
51
|
-
*/
|
|
52
|
-
export function promoExpiryDate(value: any): Date | null {
|
|
53
|
-
if (!value) return null;
|
|
54
|
-
const raw = String(value).trim();
|
|
55
|
-
if (!raw) return null;
|
|
56
|
-
|
|
57
|
-
const parts = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
58
|
-
if (parts) {
|
|
59
|
-
const [, m, d, y] = parts;
|
|
60
|
-
const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
|
|
61
|
-
// `new Date(2026, 12, ...)` rolls into the next year rather than failing.
|
|
62
|
-
return date.getMonth() === Number(m) - 1 ? date : null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
66
|
-
if (iso) {
|
|
67
|
-
const [, y, m, d] = iso;
|
|
68
|
-
const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
|
|
69
|
-
return date.getMonth() === Number(m) - 1 ? date : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const parsed = new Date(raw);
|
|
73
|
-
return isNaN(parsed.getTime()) ? null : parsed;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Nothing typed yet. Not the same thing as a zero, which is a real price. */
|
|
77
|
-
function isBlank(value: any): boolean {
|
|
78
|
-
return value === "" || value === null || value === undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** What the edit form holds while a person is filling it in. */
|
|
82
|
-
export interface TPromoCodeFormValues {
|
|
83
|
-
description: string;
|
|
84
|
-
type: "fixed" | "tiered";
|
|
85
|
-
fixed_rate: number;
|
|
86
|
-
expiresAt: string;
|
|
87
|
-
tiers: Array<{ min: number; max: number; price: number }>;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Pre-fill the form from the record the API sent back.
|
|
92
|
-
*
|
|
93
|
-
* `code` is not a form value - it is not editable, so it is displayed from the
|
|
94
|
-
* record and never carried through the form where a change could reach it.
|
|
95
|
-
*/
|
|
96
|
-
export function promoCodeFormValues(
|
|
97
|
-
record: Record<string, any> | null | undefined
|
|
98
|
-
): TPromoCodeFormValues {
|
|
99
|
-
const promo = record ?? {};
|
|
100
|
-
const type = promo.type === "tiered" ? "tiered" : "fixed";
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
description: String(promo.description ?? ""),
|
|
104
|
-
type,
|
|
105
|
-
fixed_rate: Number(promo.fixed_rate ?? 0),
|
|
106
|
-
expiresAt: String(promo.expiresAt ?? ""),
|
|
107
|
-
tiers:
|
|
108
|
-
type === "tiered" && Array.isArray(promo.tiers)
|
|
109
|
-
? promo.tiers.map((tier: any) => ({
|
|
110
|
-
min: Number(tier?.min ?? 1),
|
|
111
|
-
max: Number(tier?.max ?? 0),
|
|
112
|
-
price: Number(tier?.price ?? 0),
|
|
113
|
-
}))
|
|
114
|
-
: [],
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The body `PUT /api/promo-codes/:id` takes, and nothing more.
|
|
120
|
-
*
|
|
121
|
-
* No `code`, no `_id`, no `status`. The API would accept and ignore all three;
|
|
122
|
-
* not sending them means this screen cannot appear to change something it
|
|
123
|
-
* cannot. `status` in particular has its own endpoint - folding it in here
|
|
124
|
-
* would give two answers to "is this code on".
|
|
125
|
-
*/
|
|
126
|
-
export function promoCodeUpdatePayload(
|
|
127
|
-
values: TPromoCodeFormValues
|
|
128
|
-
): Record<string, any> {
|
|
129
|
-
const payload: Record<string, any> = {
|
|
130
|
-
description: values.description ?? "",
|
|
131
|
-
type: values.type,
|
|
132
|
-
expiresAt: values.expiresAt ?? "",
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
// The schema FORBIDS `tiers` on a fixed code, so each shape sends only the
|
|
136
|
-
// half that applies to it. `fixed_rate` is allowed either way but means
|
|
137
|
-
// nothing on a tiered code, and the server zeroes it there regardless.
|
|
138
|
-
if (values.type === "tiered") {
|
|
139
|
-
payload.tiers = (values.tiers ?? []).map((tier) => ({
|
|
140
|
-
min: Number(tier.min),
|
|
141
|
-
max: Number(tier.max),
|
|
142
|
-
price: Number(tier.price),
|
|
143
|
-
}));
|
|
144
|
-
} else {
|
|
145
|
-
// A blank price is passed through blank rather than quietly becoming 0.
|
|
146
|
-
// `Number("")` is 0, so coercing here would turn "they have not typed a
|
|
147
|
-
// price yet" into "every seat is free" - a real price, silently saved.
|
|
148
|
-
// The API refuses a blank `fixed_rate` too, so the two agree.
|
|
149
|
-
payload.fixed_rate = isBlank(values.fixed_rate)
|
|
150
|
-
? ""
|
|
151
|
-
: Number(values.fixed_rate);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return payload;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Everything that must be true before the request goes out. Returns the ONE
|
|
159
|
-
* thing to tell the person, or `null` when there is nothing to tell them.
|
|
160
|
-
*/
|
|
161
|
-
export function validatePromoCodeEdit(
|
|
162
|
-
payload: Record<string, any>,
|
|
163
|
-
now: Date = new Date()
|
|
164
|
-
): string | null {
|
|
165
|
-
if (payload?.type !== "fixed" && payload?.type !== "tiered") {
|
|
166
|
-
return "Choose how this code prices seats.";
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (payload.type === "fixed") {
|
|
170
|
-
const rate = Number(payload.fixed_rate);
|
|
171
|
-
if (isBlank(payload.fixed_rate) || !Number.isFinite(rate)) {
|
|
172
|
-
return "Enter a price per seat. Use 0 to make every seat free.";
|
|
173
|
-
}
|
|
174
|
-
if (rate < 0) {
|
|
175
|
-
return "The price per seat cannot be less than 0.";
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (payload.type === "tiered") {
|
|
180
|
-
const tiers = Array.isArray(payload.tiers) ? payload.tiers : [];
|
|
181
|
-
if (!tiers.length) {
|
|
182
|
-
return "Add at least one quantity band, or switch this code to one price for every seat.";
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (let i = 0; i < tiers.length; i++) {
|
|
186
|
-
const band = tiers[i] ?? {};
|
|
187
|
-
const where = "Band " + (i + 1) + ": ";
|
|
188
|
-
const min = Number(band.min);
|
|
189
|
-
const max = Number(band.max);
|
|
190
|
-
const price = Number(band.price);
|
|
191
|
-
|
|
192
|
-
if (!Number.isInteger(min) || min < 1) {
|
|
193
|
-
return where + "a band has to start at 1 seat or more.";
|
|
194
|
-
}
|
|
195
|
-
if (!Number.isInteger(max) || max < 0) {
|
|
196
|
-
return (
|
|
197
|
-
where +
|
|
198
|
-
"the upper limit has to be a whole number of seats, or no limit."
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
// `max: 0` is how the band editor stores "and above" - not a band that
|
|
202
|
-
// ends at zero seats, which is why the API allows it.
|
|
203
|
-
if (max !== 0 && max < min) {
|
|
204
|
-
return (
|
|
205
|
-
where + "the upper limit cannot be lower than where the band starts."
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
if (!Number.isFinite(price) || price <= 0) {
|
|
209
|
-
return (
|
|
210
|
-
where +
|
|
211
|
-
"every band needs a price above 0. To make seats free, use one price for every seat and set it to 0."
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (payload.expiresAt) {
|
|
218
|
-
const expiry = promoExpiryDate(payload.expiresAt);
|
|
219
|
-
if (!expiry) {
|
|
220
|
-
return "The expiry date is not a date we can read. Use MM/DD/YYYY.";
|
|
221
|
-
}
|
|
222
|
-
if (expiry.getTime() < now.getTime()) {
|
|
223
|
-
return "That expiry date has already passed, so the code would stop working the moment it is saved. Pick a date in the future, or clear the date to remove the expiry.";
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Turn a refused request into something a person can act on.
|
|
232
|
-
*
|
|
233
|
-
* `readApiError` is what every other console screen uses, and the API's promo
|
|
234
|
-
* messages are already written to be read - "Promo code not found.", "This
|
|
235
|
-
* promo code has expired." Only one answer needs rewording: the staff guard
|
|
236
|
-
* says "Not authorized.", which is true and tells nobody what to do next.
|
|
237
|
-
*/
|
|
238
|
-
export function promoCodeApiError(error: any, fallback: string): string {
|
|
239
|
-
const message = readApiError(error, fallback);
|
|
240
|
-
|
|
241
|
-
if (/^not authorized\.?$/i.test(String(message).trim())) {
|
|
242
|
-
return "Your account is not a Seven365 staff account, so it cannot change promo codes. Ask a Seven365 administrator to make the change.";
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return message;
|
|
246
|
-
}
|