@stamprally/ui 0.8.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,565 +1,299 @@
1
- import { resolveLocalizedText, DEFAULT_SHEET_THEME } from '@stamprally/core';
2
- import { useState, useEffect, useRef } from 'react';
3
- import { jsxs, jsx } from 'react/jsx-runtime';
1
+ import { calculateProgress, resolveLocalizedText, isQrSupported, isNfcSupported, readQrContext, getCurrentGeoContext, readNfcContext } from '@stamprally/core';
2
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
3
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
 
5
5
  // src/index.tsx
6
- function RallyViewer({
7
- config,
8
- adapter,
9
- client,
6
+ var label = (dictionary, locale, key, fallback) => dictionary?.[locale]?.[key] ?? fallback;
7
+ function DefaultCondition({
8
+ condition,
9
+ dictionary,
10
10
  locale,
11
- onSpotSelect
11
+ disabled,
12
+ onSubmit
12
13
  }) {
13
- const activeLocale = locale ?? "en";
14
- const resolvedConfig = adapter?.config ?? config ?? client?.getConfig();
15
- const renderConfig = resolvedConfig ?? {
16
- id: "empty",
17
- title: "",
18
- spots: [],
19
- rewards: []
14
+ const [value, setValue] = useState("");
15
+ const [status, setStatus] = useState("idle");
16
+ const [error, setError] = useState(null);
17
+ const videoRef = useRef(null);
18
+ const text = (key, fallback) => label(dictionary, locale, key, fallback);
19
+ const verify = async (proof) => {
20
+ setStatus("loading");
21
+ setError(null);
22
+ try {
23
+ const result = await onSubmit(proof);
24
+ if (result !== void 0 && typeof result === "object" && result !== null && "ok" in result && result.ok === false) {
25
+ setStatus("error");
26
+ setError(text("verificationFailed", "Verification failed."));
27
+ } else setStatus("success");
28
+ } catch {
29
+ setStatus("error");
30
+ setError(text("verificationFailed", "Verification failed."));
31
+ }
20
32
  };
21
- const emptyState = {
22
- rallyId: renderConfig.id,
23
- records: [],
24
- updatedAt: (/* @__PURE__ */ new Date(0)).toISOString()
33
+ const submit = (event) => {
34
+ event.preventDefault();
35
+ if (condition.type === "gps") return;
36
+ if (condition.type === "nfc") return;
37
+ void verify(condition.type === "passcode" ? value : value);
25
38
  };
26
- const resolvedState = adapter?.state ?? client?.getState() ?? emptyState;
27
- const [selectedSpotId, setSelectedSpotId] = useState(null);
28
- const [proof, setProof] = useState("");
29
- const [feedback, setFeedback] = useState(null);
30
- const [, setClientRevision] = useState(0);
31
- useEffect(() => {
32
- if (client === void 0) return;
33
- return client.subscribe(() => setClientRevision((revision) => revision + 1));
34
- }, [client]);
35
- const selectedSpot = renderConfig.spots.find((spot) => spot.id === selectedSpotId);
36
- const checkIn = adapter?.onCheckIn ?? (client === void 0 ? void 0 : (spotId, value) => client.checkIn(spotId, value));
37
- const claimReward = adapter?.onClaimReward ?? (client === void 0 ? void 0 : (rewardId, options) => client.claimReward(rewardId, options));
38
- const claimed = new Set(resolvedState.records.map((record) => record.stampId));
39
- const submit = async () => {
40
- if (selectedSpot === void 0 || checkIn === void 0) return;
41
- const result = await checkIn(selectedSpot.id, proof);
42
- setFeedback(result.ok ? "Check-in completed." : result.error.code);
43
- if (result.ok) {
44
- setProof("");
45
- setSelectedSpotId(null);
46
- }
39
+ const scanQr = () => {
40
+ if (videoRef.current === null) return;
41
+ setStatus("loading");
42
+ setError(null);
43
+ void readQrContext(videoRef.current).then((result) => {
44
+ if (result.ok) void verify(result.value);
45
+ else {
46
+ setStatus("error");
47
+ setError(result.error.message);
48
+ }
49
+ });
47
50
  };
48
- if (resolvedConfig === void 0) return null;
49
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Stamp rally viewer", className: "stamprally-viewer", children: [
50
- /* @__PURE__ */ jsxs("header", { children: [
51
- /* @__PURE__ */ jsx("h1", { children: resolveLocalizedText(renderConfig.title, activeLocale) }),
52
- renderConfig.description !== void 0 && /* @__PURE__ */ jsx("p", { children: resolveLocalizedText(renderConfig.description, activeLocale) }),
53
- adapter?.onSync !== void 0 && /* @__PURE__ */ jsx("button", { type: "button", onClick: () => void adapter.onSync?.(), children: "Sync" })
54
- ] }),
55
- /* @__PURE__ */ jsx("div", { role: "grid", "aria-label": "Stamp rally spots", children: renderConfig.spots.map((spot, index) => /* @__PURE__ */ jsxs("div", { children: [
56
- /* @__PURE__ */ jsxs(
57
- "button",
51
+ const readLocation = () => {
52
+ setStatus("loading");
53
+ setError(null);
54
+ void getCurrentGeoContext().then((result) => {
55
+ if (result.ok) void verify(result.value);
56
+ else {
57
+ setStatus("error");
58
+ setError(result.error.message);
59
+ }
60
+ });
61
+ };
62
+ const readNfc = () => {
63
+ setStatus("loading");
64
+ setError(null);
65
+ void readNfcContext().then((result) => {
66
+ if (result.ok) void verify(result.value);
67
+ else {
68
+ setStatus("error");
69
+ setError(result.error.message);
70
+ }
71
+ });
72
+ };
73
+ return /* @__PURE__ */ jsxs("div", { children: [
74
+ condition.type === "qr" && /* @__PURE__ */ jsxs(Fragment, { children: [
75
+ /* @__PURE__ */ jsxs("label", { children: [
76
+ text("qrValue", "QR value"),
77
+ /* @__PURE__ */ jsx(
78
+ "input",
79
+ {
80
+ "aria-label": text("qrValue", "QR value"),
81
+ placeholder: condition.qrEntryUrl ?? text("qrPlaceholder", "Paste a QR value"),
82
+ value,
83
+ onChange: (event) => setValue(event.target.value)
84
+ }
85
+ )
86
+ ] }),
87
+ /* @__PURE__ */ jsx(
88
+ "video",
58
89
  {
59
- type: "button",
60
- "aria-label": `${resolveLocalizedText(spot.name, activeLocale)}${claimed.has(spot.id) ? ", claimed" : ", available"}`,
61
- onClick: () => {
62
- onSpotSelect?.(spot.id);
63
- setSelectedSpotId(spot.id);
64
- setFeedback(null);
65
- },
66
- children: [
67
- /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", children: [
68
- "#",
69
- String(index + 1).padStart(2, "0")
70
- ] }),
71
- " ",
72
- resolveLocalizedText(spot.name, activeLocale)
73
- ]
90
+ ref: videoRef,
91
+ "aria-label": text("qrCamera", "QR camera"),
92
+ hidden: !isQrSupported(),
93
+ children: /* @__PURE__ */ jsx("track", { kind: "captions" })
74
94
  }
75
95
  ),
76
- /* @__PURE__ */ jsx("span", { children: spot.conditions[0]?.type ?? "custom" })
77
- ] }, spot.id)) }),
78
- /* @__PURE__ */ jsx("p", { role: "status", "aria-live": "polite", children: feedback ?? `${claimed.size} / ${renderConfig.spots.length} claimed` }),
79
- /* @__PURE__ */ jsxs("section", { "aria-label": "Rewards", children: [
80
- /* @__PURE__ */ jsx("h2", { children: "Rewards" }),
81
- renderConfig.rewards.map((reward) => {
82
- const rewardState = resolvedState.rewards?.find((item) => item.rewardId === reward.id);
83
- const available = rewardState?.status === "AVAILABLE";
84
- return /* @__PURE__ */ jsxs("article", { children: [
85
- /* @__PURE__ */ jsx("h3", { children: resolveLocalizedText(reward.title, activeLocale) }),
86
- /* @__PURE__ */ jsx("span", { children: rewardState?.status ?? "LOCKED" }),
87
- claimReward !== void 0 && /* @__PURE__ */ jsx(
88
- "button",
89
- {
90
- type: "button",
91
- disabled: !available,
92
- onClick: () => void claimReward(reward.id).then((result) => {
93
- setFeedback(result.ok ? "Reward claimed." : result.error.code);
94
- }),
95
- children: "Claim reward"
96
- }
97
- )
98
- ] }, reward.id);
99
- })
100
- ] }),
101
- selectedSpot !== void 0 && checkIn !== void 0 && /* @__PURE__ */ jsxs("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "rally-viewer-spot-title", children: [
102
- /* @__PURE__ */ jsx("h2", { id: "rally-viewer-spot-title", children: resolveLocalizedText(selectedSpot.name, activeLocale) }),
103
- /* @__PURE__ */ jsx("p", { children: resolveLocalizedText(selectedSpot.description, activeLocale) }),
104
- /* @__PURE__ */ jsxs("label", { children: [
105
- "Proof",
106
- /* @__PURE__ */ jsx("input", { value: proof, onChange: (event) => setProof(event.target.value) })
107
- ] }),
108
96
  /* @__PURE__ */ jsx(
109
97
  "button",
110
98
  {
111
99
  type: "button",
112
- onClick: () => void submit(),
113
- disabled: claimed.has(selectedSpot.id),
114
- children: "Check in"
100
+ className: "sry-action",
101
+ disabled: disabled || !isQrSupported(),
102
+ onClick: scanQr,
103
+ children: text("scanQr", "Scan QR")
115
104
  }
116
- ),
117
- /* @__PURE__ */ jsx("button", { type: "button", onClick: () => setSelectedSpotId(null), children: "Close" })
118
- ] })
119
- ] });
120
- }
121
- function useFocusTrap(active, onClose, closeDisabled = false) {
122
- const containerRef = useRef(null);
123
- useEffect(() => {
124
- if (!active) return;
125
- const container = containerRef.current;
126
- if (container === null) return;
127
- const focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
128
- const focusable = () => Array.from(container.querySelectorAll(focusableSelector));
129
- const first = focusable()[0];
130
- first?.focus();
131
- const handleKeyDown = (event) => {
132
- if (event.key === "Escape") {
133
- if (!closeDisabled) {
134
- event.preventDefault();
135
- onClose?.();
136
- }
137
- return;
138
- }
139
- if (event.key !== "Tab") return;
140
- const elements = focusable();
141
- if (elements.length === 0) {
142
- event.preventDefault();
143
- container.focus();
144
- return;
145
- }
146
- const firstElement = elements[0];
147
- const lastElement = elements[elements.length - 1];
148
- if (event.shiftKey && document.activeElement === firstElement) {
149
- event.preventDefault();
150
- lastElement?.focus();
151
- } else if (!event.shiftKey && document.activeElement === lastElement) {
152
- event.preventDefault();
153
- firstElement?.focus();
105
+ )
106
+ ] }),
107
+ condition.type === "gps" && /* @__PURE__ */ jsx("button", { type: "button", className: "sry-action", disabled, onClick: readLocation, children: text("checkLocation", "Check location") }),
108
+ condition.type === "nfc" && /* @__PURE__ */ jsx(
109
+ "button",
110
+ {
111
+ type: "button",
112
+ className: "sry-action",
113
+ disabled: disabled || !isNfcSupported(),
114
+ onClick: readNfc,
115
+ children: text("readNfc", "Read NFC")
154
116
  }
155
- };
156
- document.addEventListener("keydown", handleKeyDown);
157
- return () => document.removeEventListener("keydown", handleKeyDown);
158
- }, [active, closeDisabled, onClose]);
159
- return containerRef;
160
- }
161
- var FONT_STACKS = {
162
- "system-ui": 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
163
- serif: 'Georgia, "Times New Roman", "Yu Mincho", serif',
164
- "rounded-sans": '"Arial Rounded MT Bold", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif',
165
- monospace: 'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
166
- handwritten: '"Segoe Print", "Bradley Hand", "Comic Sans MS", cursive'
167
- };
168
- function formatStampDate(value) {
169
- const date = new Date(value);
170
- if (Number.isNaN(date.getTime())) return value;
171
- const pad = (part) => String(part).padStart(2, "0");
172
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
173
- }
174
- function StampSlot({
175
- stamp,
176
- record,
177
- isNext = false,
178
- slotNumber = 1,
179
- presentation = {},
180
- isAnimating = false,
181
- disabled = false,
182
- slotShape = "rounded",
183
- locale = "ja",
184
- statusText,
185
- onSelect
186
- }) {
187
- const status = record === void 0 ? isNext ? "available" : "locked" : "stamped";
188
- const resolvedStatus = statusText ?? status;
189
- const statusSeparator = statusText !== void 0 && locale === "ja" ? "\u3001" : ", ";
190
- const name = resolveLocalizedText(stamp.name, locale);
191
- const label = presentation.label ?? stamp.condition.type;
192
- return /* @__PURE__ */ jsxs(
193
- "button",
194
- {
195
- type: "button",
196
- disabled,
197
- onClick: onSelect,
198
- "aria-label": `#${String(slotNumber).padStart(2, "0")} ${name}${statusSeparator}${resolvedStatus}`,
199
- "aria-disabled": disabled,
200
- "data-status": status,
201
- "data-shape": slotShape,
202
- className: `stamp-slot stamp-slot--${status} stamp-slot--${presentation.ink ?? "vermilion"} stamp-slot--shape-${slotShape}${isAnimating ? " stamp-slot--animating" : ""}`,
203
- style: { "--stamp-primary": "var(--stamprally-primary, #9e551e)" },
204
- children: [
205
- /* @__PURE__ */ jsxs("span", { className: "stamp-slot__topline", children: [
206
- /* @__PURE__ */ jsxs("span", { className: "stamp-slot__number", children: [
207
- "#",
208
- String(slotNumber).padStart(2, "0")
209
- ] }),
210
- /* @__PURE__ */ jsx("span", { className: `stamp-slot__state stamp-slot__state--${status}`, children: resolvedStatus })
211
- ] }),
212
- /* @__PURE__ */ jsx("span", { className: "stamp-slot__watermark", "aria-hidden": "true", children: presentation.icon ?? "\u2726" }),
213
- /* @__PURE__ */ jsx("span", { className: "stamp-slot__name", children: name }),
214
- /* @__PURE__ */ jsxs("span", { className: "stamp-slot__condition", children: [
215
- /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: presentation.icon ?? "\u2726" }),
216
- label
217
- ] }),
218
- record !== void 0 && /* @__PURE__ */ jsx(
219
- "span",
117
+ ),
118
+ (condition.type === "passcode" || condition.type === "custom" || condition.type === "qr") && /* @__PURE__ */ jsxs("form", { onSubmit: submit, children: [
119
+ /* @__PURE__ */ jsxs("label", { children: [
120
+ text(
121
+ condition.type === "passcode" ? "passcode" : "proof",
122
+ condition.type === "passcode" ? "Passcode" : "Proof"
123
+ ),
124
+ /* @__PURE__ */ jsx(
125
+ "input",
220
126
  {
221
- className: `stamp-imprint ${isAnimating ? "stamp-press" : ""}`,
222
- role: "img",
223
- "aria-label": `Acquired at ${formatStampDate(record.acquiredAt)}`,
224
- children: /* @__PURE__ */ jsxs("span", { className: "stamp-imprint__inner", children: [
225
- /* @__PURE__ */ jsx("span", { className: "stamp-imprint__word", children: "STAMP" }),
226
- /* @__PURE__ */ jsx("span", { className: "stamp-imprint__date", children: formatStampDate(record.acquiredAt) })
227
- ] })
127
+ "aria-label": text("proof", "Proof"),
128
+ value,
129
+ onChange: (event) => setValue(event.target.value)
228
130
  }
229
131
  )
230
- ]
231
- }
232
- );
132
+ ] }),
133
+ /* @__PURE__ */ jsx("button", { type: "submit", className: "sry-action", disabled: disabled || value.trim() === "", children: text("checkIn", "Check in") })
134
+ ] }),
135
+ /* @__PURE__ */ jsxs(
136
+ "div",
137
+ {
138
+ className: "sry-feedback",
139
+ "aria-live": "polite",
140
+ role: status === "error" ? "alert" : void 0,
141
+ children: [
142
+ status === "loading" && text("verifying", "Verifying\u2026"),
143
+ status === "success" && text("verified", "Verified"),
144
+ status === "error" && (error ?? text("verificationFailed", "Verification failed."))
145
+ ]
146
+ }
147
+ )
148
+ ] });
233
149
  }
234
- function StampSheet({
235
- title,
236
- config,
237
- state = null,
238
- progress,
239
- presentations = {},
240
- animatedStampId = null,
241
- disabled = false,
242
- locale = "ja",
150
+ function RallyViewer({
151
+ config: providedConfig,
152
+ client,
153
+ adapter,
154
+ locale,
243
155
  dictionary,
244
- theme = config.theme ?? DEFAULT_SHEET_THEME,
245
- onStampSelect,
246
- adapter
156
+ classNames = {},
157
+ style,
158
+ customConditionRenderers = {}
247
159
  }) {
248
- if ("spots" in config) {
249
- if (adapter === void 0)
250
- return /* @__PURE__ */ jsx(
251
- RallyViewer,
252
- {
253
- config,
254
- locale,
255
- ...onStampSelect === void 0 ? {} : { onSpotSelect: onStampSelect }
256
- }
257
- );
258
- return /* @__PURE__ */ jsx(
259
- RallyViewer,
260
- {
261
- config,
262
- adapter,
263
- locale,
264
- ...onStampSelect === void 0 ? {} : { onSpotSelect: onStampSelect }
265
- }
266
- );
267
- }
268
- const acquired = new Set(state?.records.map((record) => record.stampId) ?? []);
269
- const next = new Set(progress?.nextAvailableStamps.map((stamp) => stamp.id) ?? []);
270
- const count = progress?.acquired ?? acquired.size;
271
- const total = progress?.total ?? config.stamps.length;
272
- const percentage = progress?.percentage ?? (total === 0 ? 0 : count / total * 100);
273
- const style = {
274
- "--stamp-primary": `var(--stamprally-primary-override, ${theme.primaryColor})`,
275
- "--stamp-bg": `var(--stamprally-background-override, ${theme.backgroundColor ?? DEFAULT_SHEET_THEME.backgroundColor ?? "#ffffff"})`,
276
- "--stamp-card-bg": `var(--stamprally-card-override, ${theme.cardBackgroundColor})`,
277
- "--stamp-text": `var(--stamprally-text-override, ${theme.textColor})`,
278
- "--stamp-grid-cols": String(theme.gridColumns),
279
- "--stamp-grid-cols-mobile": String(Math.min(theme.gridColumns, 2)),
280
- "--stamp-unclaimed-opacity": String(theme.unclaimedOpacity ?? 1),
281
- "--stamp-font-family": FONT_STACKS[theme.fontFamily ?? "serif"],
282
- ...theme.completedStampColor === void 0 ? {} : { "--stamp-completed-color": theme.completedStampColor },
283
- "--stamprally-primary": `var(--stamprally-primary-override, ${theme.primaryColor})`,
284
- "--stamprally-background": `var(--stamprally-background-override, ${theme.backgroundColor ?? "transparent"})`,
285
- "--stamprally-card": `var(--stamprally-card-override, ${theme.cardBackgroundColor})`,
286
- "--stamprally-text": `var(--stamprally-text-override, ${theme.textColor})`,
287
- "--stamprally-columns": String(theme.gridColumns)
288
- };
289
- const messages = {
290
- available: dictionary?.[locale]?.available ?? (locale === "en" ? "available" : "\u53D6\u5F97\u53EF\u80FD"),
291
- locked: dictionary?.[locale]?.locked ?? (locale === "en" ? "locked" : "\u9806\u5E8F\u5F85\u3061"),
292
- stamped: dictionary?.[locale]?.stamped ?? (locale === "en" ? "stamped" : "\u53D6\u5F97\u6E08\u307F"),
293
- remaining: (value) => dictionary?.[locale]?.remaining?.replace("{count}", String(value)) ?? (locale === "en" ? `${value} spots remaining` : `\u3042\u3068${value}\u7B87\u6240`),
294
- completed: dictionary?.[locale]?.completed ?? (locale === "en" ? "RALLY COMPLETED" : "\u30E9\u30EA\u30FC\u9054\u6210")
295
- };
160
+ const config = providedConfig ?? client?.getConfig() ?? adapter?.config;
161
+ const [state, setState] = useState(() => client?.getState() ?? null);
162
+ const [busy, setBusy] = useState(null);
163
+ useEffect(() => {
164
+ if (client === void 0) return;
165
+ const unsubscribe = client.subscribe(setState);
166
+ void client.init().then(setState);
167
+ return unsubscribe;
168
+ }, [client]);
169
+ if (config === void 0) throw new Error("RallyViewer requires config, client, or adapter.");
170
+ const progress = useMemo(
171
+ () => calculateProgress(
172
+ state ?? { rallyId: config.id, userId: null, records: [], rewards: [], updatedAt: "" },
173
+ config
174
+ ),
175
+ [config, state]
176
+ );
177
+ const checkIn = adapter?.onCheckIn ?? (client === void 0 ? void 0 : (spotId, proof, options) => client.checkIn(spotId, proof, options));
178
+ const claim = adapter?.onClaimReward ?? (client === void 0 ? void 0 : (rewardId, options) => client.claimReward(rewardId, options));
179
+ const submit = useCallback(
180
+ (spotId, proof) => {
181
+ if (checkIn === void 0) return Promise.resolve(void 0);
182
+ setBusy(spotId);
183
+ return checkIn(spotId, proof).finally(() => setBusy(null));
184
+ },
185
+ [checkIn]
186
+ );
296
187
  return /* @__PURE__ */ jsxs(
297
188
  "section",
298
189
  {
299
- className: `stamp-sheet ${percentage >= 100 ? "stamp-sheet--completed" : ""}`,
190
+ className: classNames.root,
300
191
  style,
301
- "aria-label": resolveLocalizedText(title, locale) || config.id,
192
+ "aria-label": label(dictionary, locale, "viewer", "Stamp rally"),
302
193
  children: [
303
- /* @__PURE__ */ jsxs("header", { className: "stamp-sheet__header", children: [
304
- /* @__PURE__ */ jsx("h2", { children: resolveLocalizedText(title, locale) || config.id }),
305
- /* @__PURE__ */ jsxs("span", { className: "stamp-sheet__score", role: "status", "aria-live": "polite", children: [
306
- count,
307
- " / ",
308
- total
309
- ] })
310
- ] }),
311
- /* @__PURE__ */ jsxs("div", { className: "stamp-sheet__progress-label", children: [
312
- /* @__PURE__ */ jsx("span", { children: messages.remaining(Math.max(0, total - count)) }),
313
- /* @__PURE__ */ jsxs("strong", { children: [
314
- Math.round(percentage),
315
- "%"
316
- ] })
317
- ] }),
318
- /* @__PURE__ */ jsx(
319
- "div",
320
- {
321
- className: "stamp-sheet__progress-track",
322
- role: "progressbar",
323
- "aria-valuemin": 0,
324
- "aria-valuemax": 100,
325
- "aria-valuenow": Math.round(percentage),
326
- "aria-label": locale === "en" ? "Rally progress" : "\u30B9\u30BF\u30F3\u30D7\u53D6\u5F97\u9032\u6357",
327
- children: /* @__PURE__ */ jsx(
328
- "span",
329
- {
330
- className: "stamp-sheet__progress-fill",
331
- style: { display: "block", width: `${percentage}%` }
332
- }
333
- )
334
- }
335
- ),
336
- /* @__PURE__ */ jsx("div", { className: "stamp-sheet__grid", children: config.stamps.map((stamp, index) => {
337
- const recordForStamp = state?.records.find((record) => record.stampId === stamp.id);
338
- return /* @__PURE__ */ jsx(
339
- StampSlot,
340
- {
341
- stamp,
342
- ...recordForStamp === void 0 ? {} : { record: recordForStamp },
343
- isNext: next.has(stamp.id),
344
- slotNumber: index + 1,
345
- ...presentations[stamp.id] === void 0 ? {} : { presentation: presentations[stamp.id] },
346
- isAnimating: animatedStampId === stamp.id,
347
- disabled,
348
- slotShape: theme.slotShape,
349
- locale,
350
- ...dictionary === void 0 ? {} : { dictionary },
351
- statusText: recordForStamp === void 0 ? next.has(stamp.id) ? messages.available : messages.locked : messages.stamped,
352
- onSelect: () => onStampSelect?.(stamp.id)
353
- },
354
- stamp.id
355
- );
356
- }) }),
357
- percentage >= 100 && /* @__PURE__ */ jsx("div", { className: "stamp-sheet__complete-mark", role: "status", "aria-live": "polite", children: "COMPLETE!!" }),
358
- percentage >= 100 && /* @__PURE__ */ jsx("div", { className: "stamp-sheet__complete-message", children: messages.completed })
359
- ]
360
- }
361
- );
362
- }
363
- function StampModal({
364
- open,
365
- stamp,
366
- record,
367
- isAvailable = true,
368
- isPending = false,
369
- locale = "ja",
370
- dictionary,
371
- onClose,
372
- onAcquire,
373
- onCheckIn,
374
- onNotify
375
- }) {
376
- const [token, setToken] = useState("");
377
- const modalRef = useFocusTrap(open && stamp !== null, onClose, isPending);
378
- useEffect(() => {
379
- if (!open) setToken("");
380
- }, [open]);
381
- if (!open || stamp === null) return null;
382
- const submit = async (context) => {
383
- if (!isAvailable || record !== void 0) return;
384
- const handler = onAcquire ?? onCheckIn;
385
- if (handler === void 0) return;
386
- const feedback = await handler(stamp.id, context);
387
- onNotify?.(feedback);
388
- if (feedback.ok) onClose();
389
- };
390
- const name = resolveLocalizedText(stamp.name, locale);
391
- const messages = dictionary?.[locale] ?? {};
392
- return /* @__PURE__ */ jsx(
393
- "div",
394
- {
395
- role: "dialog",
396
- "aria-modal": "true",
397
- "aria-labelledby": "stamprally-modal-title",
398
- className: "stamprally-modal",
399
- children: /* @__PURE__ */ jsxs("div", { className: "stamprally-modal__panel", ref: modalRef, tabIndex: -1, children: [
194
+ /* @__PURE__ */ jsx("h1", { children: resolveLocalizedText(config.title, locale) || config.id }),
400
195
  /* @__PURE__ */ jsx(
401
- "button",
196
+ "progress",
402
197
  {
403
- type: "button",
404
- "aria-label": "Close stamp details",
405
- "aria-disabled": isPending,
406
- onClick: onClose,
407
- disabled: isPending,
408
- children: "\xD7"
198
+ "aria-label": label(dictionary, locale, "progress", "Progress"),
199
+ max: 100,
200
+ value: progress.percentage
409
201
  }
410
202
  ),
411
- /* @__PURE__ */ jsx("h2", { id: "stamprally-modal-title", children: name }),
412
- record !== void 0 ? /* @__PURE__ */ jsx("p", { children: messages.alreadyClaimed ?? "Already claimed." }) : /* @__PURE__ */ jsx("p", { children: resolveLocalizedText(stamp.description, locale) }),
413
- stamp.condition.type === "token" ? /* @__PURE__ */ jsxs(
414
- "form",
415
- {
416
- onSubmit: (event) => {
417
- event.preventDefault();
418
- void submit({ type: "token", token });
419
- },
420
- children: [
421
- /* @__PURE__ */ jsxs("label", { children: [
422
- messages.passcode ?? "Passcode",
423
- " ",
424
- /* @__PURE__ */ jsx("input", { value: token, onChange: (event) => setToken(event.target.value) })
425
- ] }),
426
- /* @__PURE__ */ jsx(
427
- "button",
428
- {
429
- type: "submit",
430
- "aria-label": "Claim stamp",
431
- "aria-disabled": !isAvailable || isPending,
432
- disabled: !isAvailable || isPending,
433
- children: messages.claimStamp ?? "Claim stamp"
434
- }
435
- )
436
- ]
437
- }
438
- ) : /* @__PURE__ */ jsx(
439
- "button",
203
+ " ",
204
+ /* @__PURE__ */ jsxs("span", { children: [
205
+ progress.acquired,
206
+ "/",
207
+ progress.total
208
+ ] }),
209
+ /* @__PURE__ */ jsx("div", { children: config.spots.map(
210
+ (spot) => spot.conditions.map((condition) => {
211
+ const Renderer = customConditionRenderers[condition.type] ?? DefaultCondition;
212
+ return /* @__PURE__ */ jsxs(
213
+ "article",
214
+ {
215
+ className: classNames.condition,
216
+ children: [
217
+ /* @__PURE__ */ jsx("h2", { children: resolveLocalizedText(spot.name, locale) }),
218
+ /* @__PURE__ */ jsx(
219
+ Renderer,
220
+ {
221
+ spot,
222
+ condition,
223
+ locale,
224
+ ...dictionary === void 0 ? {} : { dictionary },
225
+ disabled: busy !== null,
226
+ onSubmit: (proof) => submit(spot.id, proof)
227
+ }
228
+ )
229
+ ]
230
+ },
231
+ `${spot.id}-${condition.type}-${JSON.stringify(condition)}`
232
+ );
233
+ })
234
+ ) }),
235
+ /* @__PURE__ */ jsx("section", { "aria-label": label(dictionary, locale, "rewards", "Rewards"), children: config.rewards.map((reward) => /* @__PURE__ */ jsx(
236
+ RewardButton,
440
237
  {
441
- type: "button",
442
- "aria-label": "Claim stamp",
443
- "aria-disabled": !isAvailable || isPending,
444
- onClick: () => void submit({ type: "instant" }),
445
- disabled: !isAvailable || isPending,
446
- children: messages.claimStamp ?? "Claim stamp"
447
- }
448
- )
449
- ] })
238
+ reward,
239
+ state: state?.rewards.find((item) => item.rewardId === reward.id),
240
+ locale,
241
+ ...dictionary === void 0 ? {} : { dictionary },
242
+ ...classNames.reward === void 0 ? {} : { className: classNames.reward },
243
+ onClaim: claim
244
+ },
245
+ reward.id
246
+ )) })
247
+ ]
450
248
  }
451
249
  );
452
250
  }
453
- function RewardPanel({
454
- rewards,
455
- states,
456
- locale = "ja",
251
+ function RewardButton({
252
+ reward,
253
+ state,
254
+ locale,
457
255
  dictionary,
458
- isPending = false,
459
- open = true,
460
- onClose,
461
- onRedeem,
462
- onNotify
256
+ className,
257
+ onClaim
463
258
  }) {
464
- const [passcodes, setPasscodes] = useState({});
465
- const panelRef = useFocusTrap(open && onClose !== void 0, onClose, isPending);
466
- if (!open) return null;
259
+ const status = state?.status ?? "LOCKED";
467
260
  return /* @__PURE__ */ jsxs(
468
- "section",
261
+ "button",
469
262
  {
470
- ref: panelRef,
471
- role: "dialog",
472
- "aria-labelledby": "stamprally-rewards-title",
473
- "aria-modal": "true",
474
- className: "stamprally-rewards",
475
- tabIndex: -1,
263
+ type: "button",
264
+ className,
265
+ disabled: status !== "AVAILABLE" || onClaim === void 0,
266
+ onClick: () => {
267
+ if (onClaim !== void 0) void onClaim(reward.id);
268
+ },
476
269
  children: [
477
- /* @__PURE__ */ jsxs("header", { className: "stamprally-rewards__header", children: [
478
- /* @__PURE__ */ jsx("h2", { id: "stamprally-rewards-title", children: dictionary?.[locale]?.rewards ?? "Rewards" }),
479
- onClose !== void 0 && /* @__PURE__ */ jsx(
480
- "button",
481
- {
482
- type: "button",
483
- "aria-label": "Close rewards",
484
- "aria-disabled": isPending,
485
- disabled: isPending,
486
- onClick: onClose,
487
- children: "\xD7"
488
- }
489
- )
490
- ] }),
491
- rewards.map((reward) => {
492
- const state = states.find((item) => item.rewardId === reward.id);
493
- const available = state?.status === "AVAILABLE";
494
- const redeem = async (options) => {
495
- const result = await onRedeem(reward.id, options);
496
- onNotify?.(
497
- result.ok,
498
- result.ok ? dictionary?.[locale]?.rewardClaimed ?? "Reward claimed." : result.error.code
499
- );
500
- };
501
- return /* @__PURE__ */ jsxs(
502
- "article",
503
- {
504
- className: `stamprally-reward stamprally-reward--${state?.status ?? "LOCKED"}`,
505
- children: [
506
- /* @__PURE__ */ jsx("h3", { children: resolveLocalizedText(reward.title, locale) }),
507
- /* @__PURE__ */ jsx("p", { children: resolveLocalizedText(reward.description, locale) }),
508
- /* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", children: state?.status ?? "LOCKED" }),
509
- reward.redemptionMethod === "staff_passcode" && /* @__PURE__ */ jsxs(
510
- "form",
511
- {
512
- onSubmit: (event) => {
513
- event.preventDefault();
514
- void redeem({ passcode: passcodes[reward.id] ?? "" });
515
- },
516
- children: [
517
- /* @__PURE__ */ jsxs("label", { children: [
518
- dictionary?.[locale]?.staffPasscode ?? "Staff passcode",
519
- " ",
520
- /* @__PURE__ */ jsx(
521
- "input",
522
- {
523
- type: "password",
524
- value: passcodes[reward.id] ?? "",
525
- onChange: (event) => setPasscodes((current) => ({ ...current, [reward.id]: event.target.value }))
526
- }
527
- )
528
- ] }),
529
- /* @__PURE__ */ jsx(
530
- "button",
531
- {
532
- type: "submit",
533
- "aria-label": `Redeem ${resolveLocalizedText(reward.title, locale)}`,
534
- "aria-disabled": !available || isPending,
535
- disabled: !available || isPending,
536
- children: dictionary?.[locale]?.redeem ?? "Redeem"
537
- }
538
- )
539
- ]
540
- }
541
- ),
542
- reward.redemptionMethod !== "staff_passcode" && /* @__PURE__ */ jsx(
543
- "button",
544
- {
545
- type: "button",
546
- "aria-label": `Redeem ${resolveLocalizedText(reward.title, locale)}`,
547
- "aria-disabled": !available || isPending,
548
- disabled: !available || isPending,
549
- onClick: () => void redeem(),
550
- children: dictionary?.[locale]?.redeem ?? "Redeem"
551
- }
552
- )
553
- ]
554
- },
555
- reward.id
556
- );
557
- })
270
+ resolveLocalizedText(reward.title, locale),
271
+ " (",
272
+ label(dictionary, locale, `status.${status.toLowerCase()}`, status),
273
+ ")"
558
274
  ]
559
275
  }
560
276
  );
561
277
  }
278
+ function StampSheet({
279
+ config,
280
+ state,
281
+ title,
282
+ progress,
283
+ locale = "en",
284
+ dictionary
285
+ }) {
286
+ const current = progress ?? calculateProgress(
287
+ state ?? { rallyId: config.id, userId: null, records: [], rewards: [], updatedAt: "" },
288
+ config
289
+ );
290
+ return /* @__PURE__ */ jsxs("section", { "aria-label": title ?? label(dictionary, locale, "stampSheet", "Stamp sheet"), children: [
291
+ /* @__PURE__ */ jsx("h2", { children: title ?? resolveLocalizedText(config.title, locale) }),
292
+ /* @__PURE__ */ jsx("progress", { max: 100, value: current.percentage }),
293
+ /* @__PURE__ */ jsx("div", { children: config.spots.map((spot) => /* @__PURE__ */ jsx("span", { children: state?.records.some((record) => record.stampId === spot.id) ? "\u2713" : "\u25CB" }, spot.id)) })
294
+ ] });
295
+ }
562
296
 
563
- export { RallyViewer, RewardPanel, StampModal, StampSheet, StampSlot };
297
+ export { RallyViewer, StampSheet };
564
298
  //# sourceMappingURL=index.js.map
565
299
  //# sourceMappingURL=index.js.map