@stamprally/admin-ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
- import { resolveLocalizedText } from '@stamprally/core';
1
+ import { resolveLocalizedText, updateLocalizedField, safeParseAdminConfig } from '@stamprally/core';
2
2
  import { useState } from 'react';
3
- import { jsxs, jsx } from 'react/jsx-runtime';
3
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
 
5
5
  // src/index.tsx
6
+ function text(dictionary, locale, key, fallback) {
7
+ return dictionary?.[locale]?.[key] ?? fallback;
8
+ }
6
9
  var condition = () => ({ type: "passcode", code: "" });
7
10
  var newSpot = (index) => ({
8
11
  id: `spot-${index + 1}`,
@@ -17,21 +20,372 @@ var newReward = (index) => ({
17
20
  redemptionMethod: "server_claim",
18
21
  requiredStampCount: index + 1
19
22
  });
20
- function AdminRallyEditor({
21
- config,
23
+ function move(items, index, direction) {
24
+ const target = index + direction;
25
+ if (target < 0 || target >= items.length) return items;
26
+ const next = [...items];
27
+ const [item] = next.splice(index, 1);
28
+ if (item !== void 0) next.splice(target, 0, item);
29
+ return next;
30
+ }
31
+ function ConditionEditor({
32
+ condition: condition2,
33
+ onChange,
34
+ onRemove,
35
+ locale,
36
+ dictionary
37
+ }) {
38
+ const activeLocale = locale ?? "en";
39
+ const field = (key, fallback) => text(dictionary, activeLocale, key, fallback);
40
+ return /* @__PURE__ */ jsxs("fieldset", { children: [
41
+ /* @__PURE__ */ jsx("legend", { children: field("condition", "Condition") }),
42
+ /* @__PURE__ */ jsxs("label", { children: [
43
+ field("conditionType", "Type"),
44
+ /* @__PURE__ */ jsx(
45
+ "select",
46
+ {
47
+ value: condition2.type,
48
+ onChange: (event) => {
49
+ const type = event.target.value;
50
+ onChange(
51
+ type === "qr" ? { type, secretToken: "" } : type === "passcode" ? { type, code: "", caseSensitive: false } : type === "gps" ? { type, latitude: 0, longitude: 0, radiusMeters: 100 } : type === "nfc" ? { type, tagId: "" } : { type, validatorName: "" }
52
+ );
53
+ },
54
+ children: ["qr", "passcode", "gps", "nfc", "custom"].map((type) => /* @__PURE__ */ jsx("option", { value: type, children: field(`condition.${type}`, type.toUpperCase()) }, type))
55
+ }
56
+ )
57
+ ] }),
58
+ condition2.type === "qr" && /* @__PURE__ */ jsxs(Fragment, { children: [
59
+ /* @__PURE__ */ jsxs("label", { children: [
60
+ field("secretToken", "QR token"),
61
+ /* @__PURE__ */ jsx(
62
+ "input",
63
+ {
64
+ value: condition2.secretToken,
65
+ onChange: (event) => onChange({ ...condition2, secretToken: event.target.value })
66
+ }
67
+ )
68
+ ] }),
69
+ /* @__PURE__ */ jsxs("label", { children: [
70
+ field("qrEntryUrl", "QR entry URL"),
71
+ /* @__PURE__ */ jsx(
72
+ "input",
73
+ {
74
+ value: condition2.qrEntryUrl ?? "",
75
+ onChange: (event) => onChange({ ...condition2, qrEntryUrl: event.target.value })
76
+ }
77
+ )
78
+ ] })
79
+ ] }),
80
+ condition2.type === "passcode" && /* @__PURE__ */ jsxs(Fragment, { children: [
81
+ /* @__PURE__ */ jsxs("label", { children: [
82
+ field("passcode", "Passcode"),
83
+ /* @__PURE__ */ jsx(
84
+ "input",
85
+ {
86
+ value: condition2.code,
87
+ onChange: (event) => onChange({ ...condition2, code: event.target.value })
88
+ }
89
+ )
90
+ ] }),
91
+ /* @__PURE__ */ jsxs("label", { children: [
92
+ /* @__PURE__ */ jsx(
93
+ "input",
94
+ {
95
+ type: "checkbox",
96
+ checked: condition2.caseSensitive ?? false,
97
+ onChange: (event) => onChange({ ...condition2, caseSensitive: event.target.checked })
98
+ }
99
+ ),
100
+ field("caseSensitive", "Case sensitive")
101
+ ] })
102
+ ] }),
103
+ condition2.type === "gps" && /* @__PURE__ */ jsx("div", { children: ["latitude", "longitude", "radiusMeters"].map((key) => /* @__PURE__ */ jsxs("label", { children: [
104
+ field(key, key),
105
+ /* @__PURE__ */ jsx(
106
+ "input",
107
+ {
108
+ type: "number",
109
+ value: condition2[key],
110
+ onChange: (event) => onChange({ ...condition2, [key]: Number(event.target.value) })
111
+ }
112
+ )
113
+ ] }, key)) }),
114
+ condition2.type === "nfc" && /* @__PURE__ */ jsxs("label", { children: [
115
+ field("tagId", "NFC tag ID"),
116
+ /* @__PURE__ */ jsx(
117
+ "input",
118
+ {
119
+ value: condition2.tagId,
120
+ onChange: (event) => onChange({ ...condition2, tagId: event.target.value })
121
+ }
122
+ )
123
+ ] }),
124
+ condition2.type === "custom" && /* @__PURE__ */ jsxs("label", { children: [
125
+ field("validatorName", "Validator name"),
126
+ /* @__PURE__ */ jsx(
127
+ "input",
128
+ {
129
+ value: condition2.validatorName,
130
+ onChange: (event) => onChange({ ...condition2, validatorName: event.target.value })
131
+ }
132
+ )
133
+ ] }),
134
+ onRemove !== void 0 && /* @__PURE__ */ jsx("button", { type: "button", onClick: onRemove, children: field("removeCondition", "Remove condition") })
135
+ ] });
136
+ }
137
+ function SpotItemForm({
138
+ spot,
139
+ onChange,
140
+ onRemove,
141
+ locale,
142
+ dictionary
143
+ }) {
144
+ const activeLocale = locale ?? "en";
145
+ const field = (key, fallback) => text(dictionary, activeLocale, key, fallback);
146
+ const update = (patch) => onChange({ ...spot, ...patch });
147
+ return /* @__PURE__ */ jsxs("fieldset", { children: [
148
+ /* @__PURE__ */ jsx("legend", { children: resolveLocalizedText(spot.name, activeLocale) }),
149
+ /* @__PURE__ */ jsxs("label", { children: [
150
+ field("spotName", "Spot name"),
151
+ /* @__PURE__ */ jsx(
152
+ "input",
153
+ {
154
+ value: resolveLocalizedText(spot.name, activeLocale),
155
+ onChange: (event) => update({ name: updateLocalizedField(spot.name, activeLocale, event.target.value) })
156
+ }
157
+ )
158
+ ] }),
159
+ ["imageUrl", "iconUrl", "redirectUrlAfterClaim"].map((key) => /* @__PURE__ */ jsxs("label", { children: [
160
+ field(key, key),
161
+ /* @__PURE__ */ jsx(
162
+ "input",
163
+ {
164
+ value: spot[key] ?? "",
165
+ onChange: (event) => update({ [key]: event.target.value })
166
+ }
167
+ )
168
+ ] }, key)),
169
+ /* @__PURE__ */ jsxs("label", { children: [
170
+ field("prerequisites", "Prerequisite spots"),
171
+ /* @__PURE__ */ jsx(
172
+ "input",
173
+ {
174
+ value: spot.prerequisites?.join(", ") ?? "",
175
+ onChange: (event) => update({
176
+ prerequisites: event.target.value.split(",").map((item) => item.trim()).filter(Boolean)
177
+ })
178
+ }
179
+ )
180
+ ] }),
181
+ /* @__PURE__ */ jsxs("label", { children: [
182
+ field("externalReferences", "External references"),
183
+ /* @__PURE__ */ jsx(
184
+ "textarea",
185
+ {
186
+ value: JSON.stringify(spot.externalReferences ?? [], null, 2),
187
+ onChange: (event) => {
188
+ try {
189
+ const parsed = JSON.parse(event.target.value);
190
+ if (Array.isArray(parsed)) update({ externalReferences: parsed });
191
+ } catch {
192
+ }
193
+ }
194
+ }
195
+ )
196
+ ] }),
197
+ /* @__PURE__ */ jsxs("label", { children: [
198
+ field("metadata", "Metadata"),
199
+ /* @__PURE__ */ jsx(
200
+ "textarea",
201
+ {
202
+ value: JSON.stringify(spot.metadata ?? {}, null, 2),
203
+ onChange: (event) => {
204
+ try {
205
+ const parsed = JSON.parse(event.target.value);
206
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
207
+ update({ metadata: parsed });
208
+ } catch {
209
+ }
210
+ }
211
+ }
212
+ )
213
+ ] }),
214
+ spot.conditions.map((item, index) => /* @__PURE__ */ jsx(
215
+ ConditionEditor,
216
+ {
217
+ condition: item,
218
+ locale: activeLocale,
219
+ ...dictionary === void 0 ? {} : { dictionary },
220
+ onChange: (next) => update({
221
+ conditions: spot.conditions.map(
222
+ (current, itemIndex) => itemIndex === index ? next : current
223
+ )
224
+ }),
225
+ ...spot.conditions.length <= 1 ? {} : {
226
+ onRemove: () => update({
227
+ conditions: spot.conditions.filter((_, itemIndex) => itemIndex !== index)
228
+ })
229
+ }
230
+ },
231
+ `${spot.id}-condition-${JSON.stringify(item)}`
232
+ )),
233
+ /* @__PURE__ */ jsx(
234
+ "button",
235
+ {
236
+ type: "button",
237
+ onClick: () => update({ conditions: [...spot.conditions, condition()] }),
238
+ children: field("addCondition", "Add condition")
239
+ }
240
+ ),
241
+ onRemove !== void 0 && /* @__PURE__ */ jsx("button", { type: "button", onClick: onRemove, children: field("removeSpot", "Remove spot") })
242
+ ] });
243
+ }
244
+ function RewardItemForm({
245
+ reward,
246
+ locale,
247
+ dictionary,
22
248
  onChange,
23
- locale = "en"
249
+ onRemove
24
250
  }) {
251
+ const field = (key, fallback) => text(dictionary, locale, key, fallback);
252
+ return /* @__PURE__ */ jsxs("fieldset", { children: [
253
+ /* @__PURE__ */ jsx("legend", { children: resolveLocalizedText(reward.title, locale) }),
254
+ /* @__PURE__ */ jsxs("label", { children: [
255
+ field("rewardTitle", "Reward title"),
256
+ /* @__PURE__ */ jsx(
257
+ "input",
258
+ {
259
+ value: resolveLocalizedText(reward.title, locale),
260
+ onChange: (event) => onChange({
261
+ ...reward,
262
+ title: updateLocalizedField(reward.title, locale, event.target.value)
263
+ })
264
+ }
265
+ )
266
+ ] }),
267
+ /* @__PURE__ */ jsxs("label", { children: [
268
+ field("requiredSpotCount", "Required spot count"),
269
+ /* @__PURE__ */ jsx(
270
+ "input",
271
+ {
272
+ type: "number",
273
+ min: 0,
274
+ value: reward.requiredStampCount,
275
+ onChange: (event) => onChange({ ...reward, requiredStampCount: Number(event.target.value) })
276
+ }
277
+ )
278
+ ] }),
279
+ /* @__PURE__ */ jsxs("label", { children: [
280
+ field("rewardType", "Reward type"),
281
+ /* @__PURE__ */ jsxs(
282
+ "select",
283
+ {
284
+ value: reward.type,
285
+ onChange: (event) => onChange({ ...reward, type: event.target.value }),
286
+ children: [
287
+ /* @__PURE__ */ jsx("option", { value: "digital", children: field("rewardType.digital", "Digital") }),
288
+ /* @__PURE__ */ jsx("option", { value: "in_person", children: field("rewardType.inPerson", "In person") })
289
+ ]
290
+ }
291
+ )
292
+ ] }),
293
+ /* @__PURE__ */ jsxs("label", { children: [
294
+ field("redemptionMethod", "Redemption method"),
295
+ /* @__PURE__ */ jsxs(
296
+ "select",
297
+ {
298
+ value: reward.redemptionMethod,
299
+ onChange: (event) => onChange({
300
+ ...reward,
301
+ redemptionMethod: event.target.value
302
+ }),
303
+ children: [
304
+ /* @__PURE__ */ jsx("option", { value: "server_claim", children: field("redemption.serverClaim", "Server claim") }),
305
+ /* @__PURE__ */ jsx("option", { value: "manual_slide", children: field("redemption.manualSlide", "Manual slide") }),
306
+ /* @__PURE__ */ jsx("option", { value: "staff_passcode", children: field("redemption.staffPasscode", "Staff passcode") }),
307
+ /* @__PURE__ */ jsx("option", { value: "view_only", children: field("redemption.viewOnly", "View only") })
308
+ ]
309
+ }
310
+ )
311
+ ] }),
312
+ /* @__PURE__ */ jsxs("label", { children: [
313
+ field("unlockConditions", "Unlock conditions"),
314
+ /* @__PURE__ */ jsx(
315
+ "textarea",
316
+ {
317
+ value: JSON.stringify(reward.conditions ?? [], null, 2),
318
+ onChange: (event) => {
319
+ try {
320
+ const parsed = JSON.parse(event.target.value);
321
+ if (Array.isArray(parsed))
322
+ onChange({ ...reward, conditions: parsed });
323
+ } catch {
324
+ }
325
+ }
326
+ }
327
+ )
328
+ ] }),
329
+ ["stockLimit", "userClaimLimit"].map((key) => /* @__PURE__ */ jsxs("label", { children: [
330
+ field(key, key),
331
+ /* @__PURE__ */ jsx(
332
+ "input",
333
+ {
334
+ type: "number",
335
+ min: 0,
336
+ value: reward[key] ?? "",
337
+ onChange: (event) => {
338
+ if (event.target.value === "") {
339
+ const { [key]: _removed, ...withoutLimit } = reward;
340
+ onChange(withoutLimit);
341
+ } else onChange({ ...reward, [key]: Number(event.target.value) });
342
+ }
343
+ }
344
+ )
345
+ ] }, key)),
346
+ /* @__PURE__ */ jsxs("label", { children: [
347
+ field("staffPasscode", "Staff passcode"),
348
+ /* @__PURE__ */ jsx(
349
+ "input",
350
+ {
351
+ value: reward.staffPasscode ?? "",
352
+ onChange: (event) => onChange({ ...reward, staffPasscode: event.target.value })
353
+ }
354
+ )
355
+ ] }),
356
+ /* @__PURE__ */ jsxs("label", { children: [
357
+ field("validUntil", "Valid until"),
358
+ /* @__PURE__ */ jsx(
359
+ "input",
360
+ {
361
+ type: "datetime-local",
362
+ value: reward.validUntil?.slice(0, 16) ?? "",
363
+ onChange: (event) => {
364
+ if (event.target.value === "") {
365
+ const { validUntil: _removed, ...withoutDate } = reward;
366
+ onChange(withoutDate);
367
+ } else onChange({ ...reward, validUntil: new Date(event.target.value).toISOString() });
368
+ }
369
+ }
370
+ )
371
+ ] }),
372
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: onRemove, children: field("removeReward", "Remove reward") })
373
+ ] });
374
+ }
375
+ function AdminRallyEditor({ config, onChange, locale, dictionary }) {
376
+ const activeLocale = locale ?? "en";
377
+ const field = (key, fallback) => text(dictionary, activeLocale, key, fallback);
25
378
  const update = (next) => onChange({ ...config, ...next });
26
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Rally editor", children: [
27
- /* @__PURE__ */ jsx("h1", { children: resolveLocalizedText(config.title, locale) }),
379
+ const updateSpots = (spots) => update({ spots: spots.map((spot, index) => ({ ...spot, orderIndex: index })) });
380
+ return /* @__PURE__ */ jsxs("section", { "aria-label": field("rallyEditor", "Rally editor"), children: [
381
+ /* @__PURE__ */ jsx("h1", { children: resolveLocalizedText(config.title, activeLocale) }),
28
382
  /* @__PURE__ */ jsxs("label", { children: [
29
- "Title",
383
+ field("title", "Title"),
30
384
  /* @__PURE__ */ jsx(
31
385
  "input",
32
386
  {
33
- value: resolveLocalizedText(config.title, locale),
34
- onChange: (event) => update({ title: event.target.value })
387
+ value: resolveLocalizedText(config.title, activeLocale),
388
+ onChange: (event) => update({ title: updateLocalizedField(config.title, activeLocale, event.target.value) })
35
389
  }
36
390
  )
37
391
  ] }),
@@ -39,72 +393,134 @@ function AdminRallyEditor({
39
393
  "button",
40
394
  {
41
395
  type: "button",
42
- onClick: () => update({ spots: [...config.spots, newSpot(config.spots.length)] }),
43
- children: "Add spot"
396
+ onClick: () => updateSpots([...config.spots, newSpot(config.spots.length)]),
397
+ children: field("addSpot", "Add spot")
44
398
  }
45
399
  ),
400
+ /* @__PURE__ */ jsx("div", { children: config.spots.map((spot, index) => /* @__PURE__ */ jsxs("div", { children: [
401
+ /* @__PURE__ */ jsx(
402
+ SpotItemForm,
403
+ {
404
+ spot,
405
+ locale: activeLocale,
406
+ ...dictionary === void 0 ? {} : { dictionary },
407
+ onChange: (next) => updateSpots(
408
+ config.spots.map((current) => current.id === spot.id ? next : current)
409
+ ),
410
+ onRemove: () => updateSpots(config.spots.filter((current) => current.id !== spot.id))
411
+ }
412
+ ),
413
+ /* @__PURE__ */ jsx(
414
+ "button",
415
+ {
416
+ type: "button",
417
+ disabled: index === 0,
418
+ onClick: () => updateSpots(move(config.spots, index, -1)),
419
+ children: field("moveUp", "Move up")
420
+ }
421
+ ),
422
+ /* @__PURE__ */ jsx(
423
+ "button",
424
+ {
425
+ type: "button",
426
+ disabled: index === config.spots.length - 1,
427
+ onClick: () => updateSpots(move(config.spots, index, 1)),
428
+ children: field("moveDown", "Move down")
429
+ }
430
+ )
431
+ ] }, spot.id)) }),
46
432
  /* @__PURE__ */ jsx(
47
433
  "button",
48
434
  {
49
435
  type: "button",
50
436
  onClick: () => update({ rewards: [...config.rewards, newReward(config.rewards.length)] }),
51
- children: "Add reward"
437
+ children: field("addReward", "Add reward")
52
438
  }
53
439
  ),
54
- /* @__PURE__ */ jsx("ul", { children: config.spots.map((spot) => /* @__PURE__ */ jsx("li", { children: resolveLocalizedText(spot.name, locale) }, spot.id)) })
440
+ /* @__PURE__ */ jsx("div", { children: config.rewards.map((reward) => /* @__PURE__ */ jsx(
441
+ RewardItemForm,
442
+ {
443
+ reward,
444
+ locale: activeLocale,
445
+ ...dictionary === void 0 ? {} : { dictionary },
446
+ onChange: (next) => update({
447
+ rewards: config.rewards.map(
448
+ (current) => current.id === reward.id ? next : current
449
+ )
450
+ }),
451
+ onRemove: () => update({ rewards: config.rewards.filter((current) => current.id !== reward.id) })
452
+ },
453
+ reward.id
454
+ )) })
55
455
  ] });
56
456
  }
57
457
  var RallyEditor = AdminRallyEditor;
58
- function SpotItemForm({
59
- spot,
60
- onChange
458
+ function GeneralSettingsForm({
459
+ config,
460
+ onChange,
461
+ locale,
462
+ dictionary
61
463
  }) {
464
+ const activeLocale = locale ?? "en";
62
465
  return /* @__PURE__ */ jsxs("label", { children: [
63
- "Spot name",
466
+ text(dictionary, activeLocale, "title", "Rally title"),
64
467
  /* @__PURE__ */ jsx(
65
468
  "input",
66
469
  {
67
- value: resolveLocalizedText(spot.name, "en"),
68
- onChange: (event) => onChange({ ...spot, name: event.target.value })
470
+ value: resolveLocalizedText(config.title, activeLocale),
471
+ onChange: (event) => onChange({
472
+ ...config,
473
+ title: updateLocalizedField(config.title, activeLocale, event.target.value)
474
+ })
69
475
  }
70
476
  )
71
477
  ] });
72
478
  }
73
- function GeneralSettingsForm({ config, onChange }) {
74
- return /* @__PURE__ */ jsxs("label", { children: [
75
- "Rally title",
479
+ function JsonConfigIO({ config, onImport, locale, dictionary }) {
480
+ const activeLocale = locale ?? "en";
481
+ const [value, setValue] = useState(() => JSON.stringify(config, null, 2));
482
+ const [errors, setErrors] = useState([]);
483
+ const field = (key, fallback) => text(dictionary, activeLocale, key, fallback);
484
+ return /* @__PURE__ */ jsxs("div", { children: [
76
485
  /* @__PURE__ */ jsx(
77
- "input",
486
+ "textarea",
78
487
  {
79
- value: resolveLocalizedText(config.title, "en"),
80
- onChange: (event) => onChange({ ...config, title: event.target.value })
488
+ "aria-label": field("jsonConfig", "JSON configuration"),
489
+ value,
490
+ onChange: (event) => {
491
+ setValue(event.target.value);
492
+ setErrors([]);
493
+ }
81
494
  }
82
- )
83
- ] });
84
- }
85
- function JsonConfigIO({ config, onImport }) {
86
- const [value, setValue] = useState(() => JSON.stringify(config, null, 2));
87
- const parse = (event) => {
88
- setValue(event.target.value);
89
- };
90
- return /* @__PURE__ */ jsxs("div", { children: [
91
- /* @__PURE__ */ jsx("textarea", { value, onChange: parse }),
495
+ ),
92
496
  /* @__PURE__ */ jsx(
93
497
  "button",
94
498
  {
95
499
  type: "button",
96
500
  onClick: () => {
97
501
  try {
98
- onImport(JSON.parse(value));
502
+ const result = safeParseAdminConfig(JSON.parse(value));
503
+ if (!result.success) {
504
+ setErrors(result.errors);
505
+ return;
506
+ }
507
+ onImport(result.data);
508
+ setErrors([]);
99
509
  } catch {
510
+ setErrors([{ path: "$", message: field("invalidJson", "Invalid JSON.") }]);
100
511
  }
101
512
  },
102
- children: "Import"
513
+ children: field("import", "Import")
103
514
  }
104
- )
515
+ ),
516
+ errors.length > 0 && /* @__PURE__ */ jsx("ul", { role: "alert", children: errors.map((error) => /* @__PURE__ */ jsxs("li", { children: [
517
+ error.path,
518
+ ": ",
519
+ error.message
520
+ ] }, `${error.path}-${error.message}`)) })
105
521
  ] });
106
522
  }
107
523
 
108
- export { AdminRallyEditor, GeneralSettingsForm, JsonConfigIO, RallyEditor, SpotItemForm };
524
+ export { AdminRallyEditor, ConditionEditor, GeneralSettingsForm, JsonConfigIO, RallyEditor, SpotItemForm };
109
525
  //# sourceMappingURL=index.js.map
110
526
  //# sourceMappingURL=index.js.map