@stamprally/core 0.1.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.cjs ADDED
@@ -0,0 +1,1375 @@
1
+ 'use strict';
2
+
3
+ // src/engine/evaluate.ts
4
+ var EARTH_RADIUS_METERS = 6371e3;
5
+ function toRadians(degrees) {
6
+ return degrees * Math.PI / 180;
7
+ }
8
+ function calculateDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
9
+ const latitudeDelta = toRadians(latitudeB - latitudeA);
10
+ const longitudeDelta = toRadians(longitudeB - longitudeA);
11
+ const latitudeARadians = toRadians(latitudeA);
12
+ const latitudeBRadians = toRadians(latitudeB);
13
+ const haversine = Math.sin(latitudeDelta / 2) ** 2 + Math.cos(latitudeARadians) * Math.cos(latitudeBRadians) * Math.sin(longitudeDelta / 2) ** 2;
14
+ return 2 * EARTH_RADIUS_METERS * Math.asin(Math.sqrt(Math.min(1, haversine)));
15
+ }
16
+ function isValidCoordinate(latitude, longitude) {
17
+ return Number.isFinite(latitude) && latitude >= -90 && latitude <= 90 && Number.isFinite(longitude) && longitude >= -180 && longitude <= 180;
18
+ }
19
+ function contextTypeMismatch(conditionType, expectedContextType, actualContextType) {
20
+ return {
21
+ ok: false,
22
+ error: {
23
+ code: "CONDITION_MISMATCH",
24
+ conditionType,
25
+ reason: "CONTEXT_TYPE_MISMATCH",
26
+ expectedContextType,
27
+ actualContextType
28
+ }
29
+ };
30
+ }
31
+ function assertNever(value) {
32
+ throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
33
+ }
34
+ function evaluateConditionDetailed(condition, context, now) {
35
+ switch (condition.type) {
36
+ case "instant":
37
+ return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
38
+ case "token":
39
+ if (context.type !== "token") {
40
+ return contextTypeMismatch("token", "token", context.type);
41
+ }
42
+ return context.token === condition.token ? { ok: true, value: { conditionType: "token" } } : {
43
+ ok: false,
44
+ error: {
45
+ code: "CONDITION_MISMATCH",
46
+ conditionType: "token",
47
+ reason: "TOKEN_MISMATCH"
48
+ }
49
+ };
50
+ case "geo": {
51
+ if (context.type !== "geo") {
52
+ return contextTypeMismatch("geo", "geo", context.type);
53
+ }
54
+ if (!isValidCoordinate(condition.latitude, condition.longitude) || !isValidCoordinate(context.currentLatitude, context.currentLongitude) || !Number.isFinite(condition.radiusMeters) || condition.radiusMeters < 0) {
55
+ return {
56
+ ok: false,
57
+ error: {
58
+ code: "CONDITION_MISMATCH",
59
+ conditionType: "geo",
60
+ reason: "INVALID_GEO_INPUT"
61
+ }
62
+ };
63
+ }
64
+ const distanceMeters = calculateDistanceMeters(
65
+ condition.latitude,
66
+ condition.longitude,
67
+ context.currentLatitude,
68
+ context.currentLongitude
69
+ );
70
+ if (distanceMeters <= condition.radiusMeters) {
71
+ return { ok: true, value: { conditionType: "geo", distanceMeters } };
72
+ }
73
+ return {
74
+ ok: false,
75
+ error: {
76
+ code: "CONDITION_MISMATCH",
77
+ conditionType: "geo",
78
+ reason: "OUTSIDE_RADIUS",
79
+ distanceMeters,
80
+ radiusMeters: condition.radiusMeters,
81
+ differenceMeters: distanceMeters - condition.radiusMeters
82
+ }
83
+ };
84
+ }
85
+ case "composite": {
86
+ if (context.type !== "composite") {
87
+ return contextTypeMismatch("composite", "composite", context.type);
88
+ }
89
+ if (condition.conditions.length !== context.contexts.length) {
90
+ return {
91
+ ok: false,
92
+ error: {
93
+ code: "CONDITION_MISMATCH",
94
+ conditionType: "composite",
95
+ reason: "CONTEXT_LENGTH_MISMATCH",
96
+ expectedCount: condition.conditions.length,
97
+ actualCount: context.contexts.length
98
+ }
99
+ };
100
+ }
101
+ const failures = [];
102
+ let matchedCount = 0;
103
+ for (const [index, childCondition] of condition.conditions.entries()) {
104
+ const childContext = context.contexts[index];
105
+ if (childContext === void 0) continue;
106
+ const result = evaluateConditionDetailed(childCondition, childContext, now);
107
+ if (result.ok) matchedCount += 1;
108
+ else failures.push({ index, error: result.error });
109
+ }
110
+ if (condition.operator === "AND" && failures.length === 0) {
111
+ return { ok: true, value: { conditionType: "composite" } };
112
+ }
113
+ if (condition.operator === "OR" && matchedCount > 0) {
114
+ return { ok: true, value: { conditionType: "composite" } };
115
+ }
116
+ return {
117
+ ok: false,
118
+ error: {
119
+ code: "CONDITION_MISMATCH",
120
+ conditionType: "composite",
121
+ reason: condition.operator === "AND" ? "AND_CHILD_FAILED" : "OR_ALL_FAILED",
122
+ failures
123
+ }
124
+ };
125
+ }
126
+ case "time_window": {
127
+ const startsAt = Date.parse(condition.startsAt);
128
+ const endsAt = Date.parse(condition.endsAt);
129
+ const currentTime = Date.parse(now);
130
+ if (!Number.isFinite(currentTime)) {
131
+ return {
132
+ ok: false,
133
+ error: {
134
+ code: "CONDITION_MISMATCH",
135
+ conditionType: "time_window",
136
+ reason: "INVALID_NOW",
137
+ now,
138
+ startsAt: condition.startsAt,
139
+ endsAt: condition.endsAt
140
+ }
141
+ };
142
+ }
143
+ if (!Number.isFinite(startsAt) || !Number.isFinite(endsAt) || startsAt > endsAt) {
144
+ return {
145
+ ok: false,
146
+ error: {
147
+ code: "CONDITION_MISMATCH",
148
+ conditionType: "time_window",
149
+ reason: "INVALID_TIME_WINDOW",
150
+ now,
151
+ startsAt: condition.startsAt,
152
+ endsAt: condition.endsAt
153
+ }
154
+ };
155
+ }
156
+ if (currentTime < startsAt || currentTime > endsAt) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: "CONDITION_MISMATCH",
161
+ conditionType: "time_window",
162
+ reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
163
+ now,
164
+ startsAt: condition.startsAt,
165
+ endsAt: condition.endsAt
166
+ }
167
+ };
168
+ }
169
+ const childResult = evaluateConditionDetailed(condition.condition, context, now);
170
+ return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
171
+ }
172
+ default:
173
+ return assertNever(condition);
174
+ }
175
+ }
176
+ function evaluateCondition(condition, context, now) {
177
+ return evaluateConditionDetailed(condition, context, now ?? "").ok;
178
+ }
179
+
180
+ // src/engine/order.ts
181
+ function getOrderedStamps(config) {
182
+ return config.stamps.map((stamp, index) => ({ stamp, index })).sort((left, right) => {
183
+ const orderDifference = (left.stamp.order ?? Number.POSITIVE_INFINITY) - (right.stamp.order ?? Number.POSITIVE_INFINITY);
184
+ return orderDifference === 0 ? left.index - right.index : orderDifference;
185
+ }).map(({ stamp }) => stamp);
186
+ }
187
+
188
+ // src/engine/progress.ts
189
+ function calculateProgress(state, config) {
190
+ const configuredStampIds = new Set(config.stamps.map((stamp) => stamp.id));
191
+ const acquiredStampIds = new Set(
192
+ state.records.map((record) => record.stampId).filter((stampId) => configuredStampIds.has(stampId))
193
+ );
194
+ const total = config.stamps.length;
195
+ const acquired = acquiredStampIds.size;
196
+ const isCompleted = total > 0 && acquired === total;
197
+ const remainingStamps = config.stamps.filter((stamp) => !acquiredStampIds.has(stamp.id));
198
+ const nextAvailableStamps = config.isSequential === true ? getOrderedStamps(config).filter((stamp) => !acquiredStampIds.has(stamp.id)).slice(0, 1) : remainingStamps;
199
+ return {
200
+ acquired,
201
+ total,
202
+ percentage: total === 0 ? 0 : acquired / total * 100,
203
+ isCompleted,
204
+ isComplete: isCompleted,
205
+ nextAvailableStamps
206
+ };
207
+ }
208
+
209
+ // src/detectors/types.ts
210
+ function createDetectorError(detector, code, message, cause) {
211
+ return cause === void 0 ? { detector, code, message } : { detector, code, message, cause };
212
+ }
213
+ function mapBrowserError(detector, error) {
214
+ if (typeof DOMException !== "undefined" && error instanceof DOMException) {
215
+ if (error.name === "NotAllowedError" || error.name === "SecurityError") {
216
+ return createDetectorError(
217
+ detector,
218
+ "PERMISSION_DENIED",
219
+ `${detector} permission was denied.`,
220
+ error
221
+ );
222
+ }
223
+ if (error.name === "AbortError") {
224
+ return createDetectorError(detector, "ABORTED", `${detector} detection was aborted.`, error);
225
+ }
226
+ if (error.name === "NotSupportedError") {
227
+ return createDetectorError(
228
+ detector,
229
+ "UNSUPPORTED",
230
+ `${detector} is not supported in this environment.`,
231
+ error
232
+ );
233
+ }
234
+ if (error.name === "TimeoutError") {
235
+ return createDetectorError(detector, "TIMEOUT", `${detector} detection timed out.`, error);
236
+ }
237
+ }
238
+ return createDetectorError(detector, "READ_FAILED", `${detector} detection failed.`, error);
239
+ }
240
+
241
+ // src/detectors/geolocation.ts
242
+ function isGeolocationSupported() {
243
+ return typeof navigator !== "undefined" && navigator.geolocation !== void 0;
244
+ }
245
+ function getCurrentGeoContext(options = {}) {
246
+ if (!isGeolocationSupported()) {
247
+ return Promise.resolve({
248
+ ok: false,
249
+ error: createDetectorError(
250
+ "geolocation",
251
+ "UNSUPPORTED",
252
+ "Geolocation is not supported in this environment."
253
+ )
254
+ });
255
+ }
256
+ if (options.timeout !== void 0 && (!Number.isFinite(options.timeout) || options.timeout < 0)) {
257
+ return Promise.resolve({
258
+ ok: false,
259
+ error: createDetectorError(
260
+ "geolocation",
261
+ "INVALID_DATA",
262
+ "Geolocation timeout must be a non-negative finite number."
263
+ )
264
+ });
265
+ }
266
+ const positionOptions = {
267
+ ...options.enableHighAccuracy === void 0 ? {} : { enableHighAccuracy: options.enableHighAccuracy },
268
+ ...options.timeout === void 0 ? {} : { timeout: options.timeout }
269
+ };
270
+ return new Promise((resolve) => {
271
+ try {
272
+ navigator.geolocation.getCurrentPosition(
273
+ (position) => {
274
+ const currentLatitude = position.coords.latitude;
275
+ const currentLongitude = position.coords.longitude;
276
+ if (!Number.isFinite(currentLatitude) || currentLatitude < -90 || currentLatitude > 90 || !Number.isFinite(currentLongitude) || currentLongitude < -180 || currentLongitude > 180) {
277
+ resolve({
278
+ ok: false,
279
+ error: createDetectorError(
280
+ "geolocation",
281
+ "INVALID_DATA",
282
+ "Geolocation returned invalid coordinates."
283
+ )
284
+ });
285
+ return;
286
+ }
287
+ resolve({
288
+ ok: true,
289
+ value: { type: "geo", currentLatitude, currentLongitude }
290
+ });
291
+ },
292
+ (error) => {
293
+ const code = error.code === 1 ? "PERMISSION_DENIED" : error.code === 2 ? "POSITION_UNAVAILABLE" : error.code === 3 ? "TIMEOUT" : "READ_FAILED";
294
+ resolve({
295
+ ok: false,
296
+ error: createDetectorError(
297
+ "geolocation",
298
+ code,
299
+ `Geolocation failed: ${error.message || code}.`,
300
+ error
301
+ )
302
+ });
303
+ },
304
+ positionOptions
305
+ );
306
+ } catch (error) {
307
+ resolve({
308
+ ok: false,
309
+ error: createDetectorError(
310
+ "geolocation",
311
+ "READ_FAILED",
312
+ "Geolocation failed before a position could be requested.",
313
+ error
314
+ )
315
+ });
316
+ }
317
+ });
318
+ }
319
+
320
+ // src/detectors/nfc.ts
321
+ var DEFAULT_TIMEOUT_MS = 3e4;
322
+ function getNdefReaderConstructor() {
323
+ return globalThis.NDEFReader;
324
+ }
325
+ function isNfcSupported() {
326
+ return getNdefReaderConstructor() !== void 0;
327
+ }
328
+ function decodeFirstTextRecord(event) {
329
+ for (const record of event.message.records) {
330
+ if (record.recordType !== "text" || record.data === void 0) continue;
331
+ try {
332
+ const token = new TextDecoder(record.encoding ?? "utf-8").decode(record.data);
333
+ if (token.length > 0) return token;
334
+ } catch {
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+ function readNfcContext(options = {}) {
340
+ const Reader = getNdefReaderConstructor();
341
+ if (Reader === void 0) {
342
+ return Promise.resolve({
343
+ ok: false,
344
+ error: createDetectorError("nfc", "UNSUPPORTED", "Web NFC is not supported.")
345
+ });
346
+ }
347
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
348
+ if (!Number.isFinite(timeout) || timeout < 0) {
349
+ return Promise.resolve({
350
+ ok: false,
351
+ error: createDetectorError(
352
+ "nfc",
353
+ "INVALID_DATA",
354
+ "NFC timeout must be a non-negative finite number."
355
+ )
356
+ });
357
+ }
358
+ if (options.signal?.aborted === true) {
359
+ return Promise.resolve({
360
+ ok: false,
361
+ error: createDetectorError("nfc", "ABORTED", "NFC detection was aborted.")
362
+ });
363
+ }
364
+ let reader;
365
+ try {
366
+ reader = new Reader();
367
+ } catch (error) {
368
+ return Promise.resolve({ ok: false, error: mapBrowserError("nfc", error) });
369
+ }
370
+ return new Promise((resolve) => {
371
+ const scanController = new AbortController();
372
+ let settled = false;
373
+ let timer;
374
+ const finish = (result) => {
375
+ if (settled) return;
376
+ settled = true;
377
+ if (timer !== void 0) clearTimeout(timer);
378
+ options.signal?.removeEventListener("abort", handleAbort);
379
+ reader.onreading = null;
380
+ reader.onreadingerror = null;
381
+ scanController.abort();
382
+ resolve(result);
383
+ };
384
+ const fail = (error) => finish({ ok: false, error });
385
+ const handleAbort = () => fail(createDetectorError("nfc", "ABORTED", "NFC detection was aborted."));
386
+ options.signal?.addEventListener("abort", handleAbort, { once: true });
387
+ timer = setTimeout(
388
+ () => fail(createDetectorError("nfc", "TIMEOUT", "NFC detection timed out.")),
389
+ timeout
390
+ );
391
+ reader.onreading = (event) => {
392
+ const token = decodeFirstTextRecord(event);
393
+ if (token === null) {
394
+ fail(createDetectorError("nfc", "NO_TOKEN", "The NFC tag has no text token."));
395
+ return;
396
+ }
397
+ finish({ ok: true, value: { type: "token", token } });
398
+ };
399
+ reader.onreadingerror = (event) => fail(createDetectorError("nfc", "READ_FAILED", "The NFC tag could not be read.", event));
400
+ try {
401
+ void reader.scan({ signal: scanController.signal }).catch((error) => fail(mapBrowserError("nfc", error)));
402
+ } catch (error) {
403
+ fail(mapBrowserError("nfc", error));
404
+ }
405
+ });
406
+ }
407
+
408
+ // src/detectors/passcode.ts
409
+ function normalizePasscode(input, caseSensitive = false) {
410
+ const normalized = input.normalize("NFKC").trim();
411
+ return caseSensitive ? normalized : normalized.toUpperCase();
412
+ }
413
+ function verifyPasscode(inputCode, condition) {
414
+ const input = normalizePasscode(inputCode, condition.caseSensitive);
415
+ const expected = normalizePasscode(condition.passcode, condition.caseSensitive);
416
+ return input === expected ? { success: true } : {
417
+ success: false,
418
+ reason: "INVALID_PASSCODE",
419
+ message: "The passcode is invalid."
420
+ };
421
+ }
422
+
423
+ // src/detectors/qr.ts
424
+ var DEFAULT_TIMEOUT_MS2 = 3e4;
425
+ var SCAN_INTERVAL_MS = 120;
426
+ function getBarcodeDetectorConstructor() {
427
+ return globalThis.BarcodeDetector;
428
+ }
429
+ function getMediaDevices() {
430
+ return typeof navigator === "undefined" ? void 0 : navigator.mediaDevices;
431
+ }
432
+ function isQrSupported() {
433
+ return getBarcodeDetectorConstructor() !== void 0 && typeof getMediaDevices()?.getUserMedia === "function";
434
+ }
435
+ function createTermination(timeout, signal) {
436
+ let settle;
437
+ const promise = new Promise((resolve) => {
438
+ settle = resolve;
439
+ });
440
+ const handleAbort = () => settle?.(createDetectorError("qr", "ABORTED", "QR detection was aborted."));
441
+ signal?.addEventListener("abort", handleAbort, { once: true });
442
+ const timer = setTimeout(
443
+ () => settle?.(createDetectorError("qr", "TIMEOUT", "QR detection timed out.")),
444
+ timeout
445
+ );
446
+ return {
447
+ promise,
448
+ cleanup: () => {
449
+ clearTimeout(timer);
450
+ signal?.removeEventListener("abort", handleAbort);
451
+ settle = void 0;
452
+ }
453
+ };
454
+ }
455
+ async function raceWithTermination(operation, termination) {
456
+ return Promise.race([
457
+ operation.then(
458
+ (value) => ({ ok: true, value }),
459
+ (error) => ({ ok: false, error: mapBrowserError("qr", error) })
460
+ ),
461
+ termination.then((error) => ({ ok: false, error }))
462
+ ]);
463
+ }
464
+ function stopStream(stream) {
465
+ for (const track of stream.getTracks()) track.stop();
466
+ }
467
+ function waitForNextScan() {
468
+ return new Promise((resolve) => setTimeout(resolve, SCAN_INTERVAL_MS));
469
+ }
470
+ async function readQrContext(videoElement, options = {}) {
471
+ const Detector = getBarcodeDetectorConstructor();
472
+ const mediaDevices = getMediaDevices();
473
+ if (Detector === void 0 || typeof mediaDevices?.getUserMedia !== "function") {
474
+ return {
475
+ ok: false,
476
+ error: createDetectorError(
477
+ "qr",
478
+ "UNSUPPORTED",
479
+ "Live QR detection is not supported in this environment."
480
+ )
481
+ };
482
+ }
483
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS2;
484
+ if (!Number.isFinite(timeout) || timeout < 0) {
485
+ return {
486
+ ok: false,
487
+ error: createDetectorError(
488
+ "qr",
489
+ "INVALID_DATA",
490
+ "QR timeout must be a non-negative finite number."
491
+ )
492
+ };
493
+ }
494
+ if (options.signal?.aborted === true) {
495
+ return {
496
+ ok: false,
497
+ error: createDetectorError("qr", "ABORTED", "QR detection was aborted.")
498
+ };
499
+ }
500
+ const termination = createTermination(timeout, options.signal);
501
+ let stream = null;
502
+ try {
503
+ const mediaPromise = mediaDevices.getUserMedia({
504
+ audio: false,
505
+ video: { facingMode: { ideal: options.facingMode ?? "environment" } }
506
+ });
507
+ const mediaResult = await raceWithTermination(mediaPromise, termination.promise);
508
+ if (!mediaResult.ok) {
509
+ void mediaPromise.then(stopStream, () => void 0);
510
+ return mediaResult;
511
+ }
512
+ stream = mediaResult.value;
513
+ videoElement.srcObject = stream;
514
+ videoElement.muted = true;
515
+ videoElement.playsInline = true;
516
+ const playResult = await raceWithTermination(videoElement.play(), termination.promise);
517
+ if (!playResult.ok) return playResult;
518
+ const detector = new Detector({ formats: ["qr_code"] });
519
+ while (true) {
520
+ const detection = await raceWithTermination(
521
+ detector.detect(videoElement),
522
+ termination.promise
523
+ );
524
+ if (!detection.ok) return detection;
525
+ const token = detection.value.find((barcode) => barcode.rawValue.length > 0)?.rawValue;
526
+ if (token !== void 0) {
527
+ return { ok: true, value: { type: "token", token } };
528
+ }
529
+ const interval = await raceWithTermination(waitForNextScan(), termination.promise);
530
+ if (!interval.ok) return interval;
531
+ }
532
+ } catch (error) {
533
+ return { ok: false, error: mapBrowserError("qr", error) };
534
+ } finally {
535
+ termination.cleanup();
536
+ if (stream !== null) stopStream(stream);
537
+ try {
538
+ videoElement.srcObject = null;
539
+ } catch {
540
+ }
541
+ }
542
+ }
543
+
544
+ // src/engine/transition.ts
545
+ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now) {
546
+ const statesById = new Map(currentStates.map((state) => [state.rewardId, state]));
547
+ return rewards.map((reward) => {
548
+ const current = statesById.get(reward.id);
549
+ if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
550
+ return current;
551
+ }
552
+ if (acquiredStampCount >= reward.requiredStampCount) {
553
+ if (current?.status === "AVAILABLE") return current;
554
+ return {
555
+ rewardId: reward.id,
556
+ status: "AVAILABLE",
557
+ unlockedAt: current?.unlockedAt ?? now
558
+ };
559
+ }
560
+ if (current?.status === "LOCKED" && current.unlockedAt === void 0) return current;
561
+ return { rewardId: reward.id, status: "LOCKED" };
562
+ });
563
+ }
564
+ function consumeReward(params) {
565
+ const { reward, currentState } = params;
566
+ if (currentState.status === "CONSUMED") {
567
+ return {
568
+ ok: false,
569
+ error: { code: "ALREADY_CONSUMED", rewardId: reward.id }
570
+ };
571
+ }
572
+ if (currentState.status !== "AVAILABLE") {
573
+ return {
574
+ ok: false,
575
+ error: { code: "NOT_AVAILABLE", rewardId: reward.id }
576
+ };
577
+ }
578
+ if (reward.redemptionMethod === "staff_passcode") {
579
+ const passcodeResult = reward.staffPasscode === void 0 ? null : verifyPasscode(params.inputPasscode ?? "", { passcode: reward.staffPasscode });
580
+ if (passcodeResult === null || !passcodeResult.success) {
581
+ return {
582
+ ok: false,
583
+ error: {
584
+ code: "INVALID_PASSCODE",
585
+ rewardId: reward.id,
586
+ message: passcodeResult?.message ?? "The passcode is invalid."
587
+ }
588
+ };
589
+ }
590
+ }
591
+ if (reward.redemptionMethod === "view_only") {
592
+ return { ok: true, value: currentState };
593
+ }
594
+ return {
595
+ ok: true,
596
+ value: {
597
+ ...currentState,
598
+ status: "CONSUMED",
599
+ consumedAt: params.now,
600
+ ...params.staffId === void 0 ? {} : { consumedByStaffId: params.staffId }
601
+ }
602
+ };
603
+ }
604
+ function processStamp(state, config, targetStampId, context, now) {
605
+ const targetStamp = config.stamps.find((stamp) => stamp.id === targetStampId);
606
+ if (targetStamp === void 0) {
607
+ return { ok: false, error: { code: "STAMP_NOT_FOUND", stampId: targetStampId } };
608
+ }
609
+ const acquiredStampIds = new Set(state.records.map((record2) => record2.stampId));
610
+ if (acquiredStampIds.has(targetStampId)) {
611
+ return {
612
+ ok: false,
613
+ error: { code: "STAMP_ALREADY_ACQUIRED", stampId: targetStampId }
614
+ };
615
+ }
616
+ if (config.isSequential === true) {
617
+ const expectedStamp = getOrderedStamps(config).find((stamp) => !acquiredStampIds.has(stamp.id));
618
+ if (expectedStamp !== void 0 && expectedStamp.id !== targetStampId) {
619
+ return {
620
+ ok: false,
621
+ error: {
622
+ code: "INVALID_ORDER",
623
+ stampId: targetStampId,
624
+ expectedStampId: expectedStamp.id
625
+ }
626
+ };
627
+ }
628
+ }
629
+ const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now);
630
+ if (!conditionResult.ok) {
631
+ return {
632
+ ok: false,
633
+ error: {
634
+ code: "CONDITION_MISMATCH",
635
+ stampId: targetStampId,
636
+ mismatch: conditionResult.error
637
+ }
638
+ };
639
+ }
640
+ const record = { stampId: targetStampId, acquiredAt: now };
641
+ const nextRecords = [...state.records, record];
642
+ const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length, now);
643
+ const nextState = {
644
+ ...state,
645
+ records: nextRecords,
646
+ ...nextRewards === void 0 ? {} : { rewards: nextRewards },
647
+ updatedAt: now
648
+ };
649
+ const events = [{ type: "stampAcquired", record }];
650
+ const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
651
+ if (completed) {
652
+ events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now });
653
+ }
654
+ return { ok: true, value: { nextState, events } };
655
+ }
656
+
657
+ // src/client/storage.ts
658
+ var StorageAdapterError = class extends Error {
659
+ code;
660
+ operation;
661
+ rallyId;
662
+ constructor(code, operation, message, options = {}) {
663
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
664
+ this.name = "StorageAdapterError";
665
+ this.code = code;
666
+ this.operation = operation;
667
+ this.rallyId = options.rallyId;
668
+ }
669
+ };
670
+ function cloneRecord(record) {
671
+ return record.metadata === void 0 ? { ...record } : { ...record, metadata: { ...record.metadata } };
672
+ }
673
+ function cloneRewardState(state) {
674
+ return { ...state };
675
+ }
676
+ function cloneState(state) {
677
+ return {
678
+ ...state,
679
+ records: state.records.map(cloneRecord),
680
+ ...state.rewards === void 0 ? {} : { rewards: state.rewards.map(cloneRewardState) }
681
+ };
682
+ }
683
+ function isRecord(value) {
684
+ if (typeof value !== "object" || value === null) return false;
685
+ const record = value;
686
+ return typeof record.stampId === "string" && typeof record.acquiredAt === "string" && (record.metadata === void 0 || typeof record.metadata === "object" && record.metadata !== null && !Array.isArray(record.metadata));
687
+ }
688
+ var rewardStatuses = /* @__PURE__ */ new Set(["LOCKED", "AVAILABLE", "CONSUMED", "EXPIRED"]);
689
+ function isOptionalDate(value) {
690
+ return value === void 0 || typeof value === "string" && !Number.isNaN(Date.parse(value));
691
+ }
692
+ function isRewardState(value) {
693
+ if (typeof value !== "object" || value === null) return false;
694
+ const state = value;
695
+ return typeof state.rewardId === "string" && typeof state.status === "string" && rewardStatuses.has(state.status) && isOptionalDate(state.unlockedAt) && isOptionalDate(state.consumedAt) && (state.consumedByStaffId === void 0 || typeof state.consumedByStaffId === "string");
696
+ }
697
+ function isStampRallyState(value) {
698
+ if (typeof value !== "object" || value === null) return false;
699
+ const state = value;
700
+ return typeof state.rallyId === "string" && typeof state.updatedAt === "string" && Array.isArray(state.records) && state.records.every(isRecord) && (state.rewards === void 0 || Array.isArray(state.rewards) && state.rewards.every(isRewardState));
701
+ }
702
+ function isValidDate(value) {
703
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
704
+ }
705
+ function isSnapshotRecord(value) {
706
+ return isRecord(value) && isValidDate(value.acquiredAt);
707
+ }
708
+ function isRallySnapshot(value) {
709
+ if (typeof value !== "object" || value === null) return false;
710
+ const snapshot = value;
711
+ return snapshot.version === 1 && typeof snapshot.rallyId === "string" && Array.isArray(snapshot.stamps) && snapshot.stamps.every(isSnapshotRecord) && Array.isArray(snapshot.rewards) && snapshot.rewards.every(isRewardState) && isValidDate(snapshot.exportedAt);
712
+ }
713
+ function exportProgressToken(snapshot) {
714
+ return globalThis.btoa(encodeURIComponent(JSON.stringify(snapshot)));
715
+ }
716
+ function importProgressToken(token, currentRallyId) {
717
+ try {
718
+ const parsed = JSON.parse(decodeURIComponent(globalThis.atob(token)));
719
+ if (!isRallySnapshot(parsed) || parsed.rallyId !== currentRallyId) return null;
720
+ return {
721
+ ...parsed,
722
+ stamps: parsed.stamps.map(cloneRecord),
723
+ rewards: parsed.rewards.map(cloneRewardState)
724
+ };
725
+ } catch {
726
+ return null;
727
+ }
728
+ }
729
+ var InMemoryStorage = class {
730
+ #states = /* @__PURE__ */ new Map();
731
+ async load(rallyId) {
732
+ const state = this.#states.get(rallyId);
733
+ return state === void 0 ? null : cloneState(state);
734
+ }
735
+ async save(state) {
736
+ this.#states.set(state.rallyId, cloneState(state));
737
+ }
738
+ async remove(rallyId) {
739
+ this.#states.delete(rallyId);
740
+ }
741
+ };
742
+ var defaultStorageWarningHandler = (error) => {
743
+ console.warn(`[@stamprally/core] ${error.message}`, error);
744
+ };
745
+ var LocalStorageAdapter = class {
746
+ #providedStorage;
747
+ #keyPrefix;
748
+ #failureMode;
749
+ #onWarning;
750
+ #fallbackStorage = new InMemoryStorage();
751
+ #isFallbackActive = false;
752
+ constructor(options = {}) {
753
+ this.#providedStorage = options.storage;
754
+ this.#keyPrefix = options.keyPrefix ?? "stamprally:";
755
+ this.#failureMode = options.failureMode ?? "fallback";
756
+ this.#onWarning = options.onWarning ?? defaultStorageWarningHandler;
757
+ }
758
+ async load(rallyId) {
759
+ if (this.#isFallbackActive) return this.#fallbackStorage.load(rallyId);
760
+ try {
761
+ const serialized = this.#getStorage("load", rallyId).getItem(this.#key(rallyId));
762
+ if (serialized === null) return null;
763
+ let parsed;
764
+ try {
765
+ parsed = JSON.parse(serialized);
766
+ } catch (cause) {
767
+ throw new StorageAdapterError(
768
+ "STORAGE_INVALID_DATA",
769
+ "load",
770
+ `Stored rally '${rallyId}' is not valid JSON.`,
771
+ { cause, rallyId }
772
+ );
773
+ }
774
+ if (!isStampRallyState(parsed) || parsed.rallyId !== rallyId) {
775
+ throw new StorageAdapterError(
776
+ "STORAGE_INVALID_DATA",
777
+ "load",
778
+ `Stored rally '${rallyId}' has an invalid state shape.`,
779
+ { rallyId }
780
+ );
781
+ }
782
+ return cloneState(parsed);
783
+ } catch (cause) {
784
+ return this.#handleFailure(
785
+ this.#normalizeError(
786
+ cause,
787
+ "STORAGE_READ_FAILED",
788
+ "load",
789
+ `Failed to read rally '${rallyId}' from localStorage.`,
790
+ rallyId
791
+ ),
792
+ () => this.#fallbackStorage.load(rallyId)
793
+ );
794
+ }
795
+ }
796
+ async save(state) {
797
+ if (this.#isFallbackActive) return this.#fallbackStorage.save(state);
798
+ try {
799
+ this.#getStorage("save", state.rallyId).setItem(
800
+ this.#key(state.rallyId),
801
+ JSON.stringify(state)
802
+ );
803
+ } catch (cause) {
804
+ return this.#handleFailure(
805
+ this.#normalizeError(
806
+ cause,
807
+ "STORAGE_WRITE_FAILED",
808
+ "save",
809
+ `Failed to save rally '${state.rallyId}' to localStorage.`,
810
+ state.rallyId
811
+ ),
812
+ () => this.#fallbackStorage.save(state)
813
+ );
814
+ }
815
+ }
816
+ async remove(rallyId) {
817
+ if (this.#isFallbackActive) return this.#fallbackStorage.remove(rallyId);
818
+ try {
819
+ this.#getStorage("remove", rallyId).removeItem(this.#key(rallyId));
820
+ } catch (cause) {
821
+ return this.#handleFailure(
822
+ this.#normalizeError(
823
+ cause,
824
+ "STORAGE_REMOVE_FAILED",
825
+ "remove",
826
+ `Failed to remove rally '${rallyId}' from localStorage.`,
827
+ rallyId
828
+ ),
829
+ () => this.#fallbackStorage.remove(rallyId)
830
+ );
831
+ }
832
+ }
833
+ #key(rallyId) {
834
+ return `${this.#keyPrefix}${rallyId}`;
835
+ }
836
+ #getStorage(operation, rallyId) {
837
+ if (this.#providedStorage === null) {
838
+ throw new StorageAdapterError(
839
+ "STORAGE_UNAVAILABLE",
840
+ operation,
841
+ "localStorage is unavailable in this environment.",
842
+ { rallyId }
843
+ );
844
+ }
845
+ if (this.#providedStorage !== void 0) return this.#providedStorage;
846
+ if (typeof window === "undefined") {
847
+ throw new StorageAdapterError(
848
+ "STORAGE_UNAVAILABLE",
849
+ operation,
850
+ "localStorage is unavailable in this environment.",
851
+ { rallyId }
852
+ );
853
+ }
854
+ try {
855
+ const storage = window.localStorage;
856
+ if (storage !== void 0) return storage;
857
+ } catch (cause) {
858
+ throw new StorageAdapterError(
859
+ "STORAGE_UNAVAILABLE",
860
+ operation,
861
+ "localStorage is unavailable in this environment.",
862
+ { cause, rallyId }
863
+ );
864
+ }
865
+ throw new StorageAdapterError(
866
+ "STORAGE_UNAVAILABLE",
867
+ operation,
868
+ "localStorage is unavailable in this environment.",
869
+ { rallyId }
870
+ );
871
+ }
872
+ #normalizeError(cause, code, operation, message, rallyId) {
873
+ return cause instanceof StorageAdapterError ? cause : new StorageAdapterError(code, operation, message, { cause, rallyId });
874
+ }
875
+ #handleFailure(error, fallback) {
876
+ if (this.#failureMode === "throw") throw error;
877
+ this.#isFallbackActive = true;
878
+ try {
879
+ this.#onWarning(error);
880
+ } catch {
881
+ }
882
+ return fallback();
883
+ }
884
+ };
885
+ var INDEXED_DB_STORE_NAME = "states";
886
+ var IndexedDBAdapter = class {
887
+ #providedFactory;
888
+ #databaseName;
889
+ #databasePromise = null;
890
+ constructor(options = {}) {
891
+ this.#providedFactory = options.indexedDB;
892
+ this.#databaseName = options.databaseName ?? "stamprally";
893
+ }
894
+ async load(rallyId) {
895
+ const database = await this.#openDatabase(rallyId);
896
+ return new Promise((resolve, reject) => {
897
+ try {
898
+ const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readonly");
899
+ const request = transaction.objectStore(INDEXED_DB_STORE_NAME).get(rallyId);
900
+ request.onsuccess = () => {
901
+ const value = request.result;
902
+ if (value === void 0) {
903
+ resolve(null);
904
+ return;
905
+ }
906
+ if (!isStampRallyState(value) || value.rallyId !== rallyId) {
907
+ reject(
908
+ new StorageAdapterError(
909
+ "STORAGE_INVALID_DATA",
910
+ "load",
911
+ `Stored rally '${rallyId}' has an invalid state shape.`,
912
+ { rallyId }
913
+ )
914
+ );
915
+ return;
916
+ }
917
+ resolve(cloneState(value));
918
+ };
919
+ request.onerror = () => {
920
+ reject(
921
+ new StorageAdapterError(
922
+ "STORAGE_READ_FAILED",
923
+ "load",
924
+ `Failed to read rally '${rallyId}' from IndexedDB.`,
925
+ { cause: request.error, rallyId }
926
+ )
927
+ );
928
+ };
929
+ } catch (cause) {
930
+ reject(
931
+ new StorageAdapterError(
932
+ "STORAGE_READ_FAILED",
933
+ "load",
934
+ `Failed to read rally '${rallyId}' from IndexedDB.`,
935
+ { cause, rallyId }
936
+ )
937
+ );
938
+ }
939
+ });
940
+ }
941
+ async save(state) {
942
+ const database = await this.#openDatabase(state.rallyId);
943
+ return new Promise((resolve, reject) => {
944
+ try {
945
+ const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
946
+ transaction.objectStore(INDEXED_DB_STORE_NAME).put(cloneState(state), state.rallyId);
947
+ transaction.oncomplete = () => resolve();
948
+ transaction.onerror = () => {
949
+ reject(
950
+ new StorageAdapterError(
951
+ "STORAGE_WRITE_FAILED",
952
+ "save",
953
+ `Failed to save rally '${state.rallyId}' to IndexedDB.`,
954
+ { cause: transaction.error, rallyId: state.rallyId }
955
+ )
956
+ );
957
+ };
958
+ transaction.onabort = transaction.onerror;
959
+ } catch (cause) {
960
+ reject(
961
+ new StorageAdapterError(
962
+ "STORAGE_WRITE_FAILED",
963
+ "save",
964
+ `Failed to save rally '${state.rallyId}' to IndexedDB.`,
965
+ { cause, rallyId: state.rallyId }
966
+ )
967
+ );
968
+ }
969
+ });
970
+ }
971
+ async remove(rallyId) {
972
+ const database = await this.#openDatabase(rallyId);
973
+ return new Promise((resolve, reject) => {
974
+ try {
975
+ const transaction = database.transaction(INDEXED_DB_STORE_NAME, "readwrite");
976
+ transaction.objectStore(INDEXED_DB_STORE_NAME).delete(rallyId);
977
+ transaction.oncomplete = () => resolve();
978
+ transaction.onerror = () => {
979
+ reject(
980
+ new StorageAdapterError(
981
+ "STORAGE_REMOVE_FAILED",
982
+ "remove",
983
+ `Failed to remove rally '${rallyId}' from IndexedDB.`,
984
+ { cause: transaction.error, rallyId }
985
+ )
986
+ );
987
+ };
988
+ transaction.onabort = transaction.onerror;
989
+ } catch (cause) {
990
+ reject(
991
+ new StorageAdapterError(
992
+ "STORAGE_REMOVE_FAILED",
993
+ "remove",
994
+ `Failed to remove rally '${rallyId}' from IndexedDB.`,
995
+ { cause, rallyId }
996
+ )
997
+ );
998
+ }
999
+ });
1000
+ }
1001
+ #getFactory(rallyId) {
1002
+ if (this.#providedFactory === null) {
1003
+ throw new StorageAdapterError(
1004
+ "STORAGE_UNAVAILABLE",
1005
+ "open",
1006
+ "IndexedDB is unavailable in this environment.",
1007
+ { rallyId }
1008
+ );
1009
+ }
1010
+ if (this.#providedFactory !== void 0) return this.#providedFactory;
1011
+ try {
1012
+ const factory = globalThis.indexedDB;
1013
+ if (factory !== void 0) return factory;
1014
+ } catch (cause) {
1015
+ throw new StorageAdapterError(
1016
+ "STORAGE_UNAVAILABLE",
1017
+ "open",
1018
+ "IndexedDB is unavailable in this environment.",
1019
+ { cause, rallyId }
1020
+ );
1021
+ }
1022
+ throw new StorageAdapterError(
1023
+ "STORAGE_UNAVAILABLE",
1024
+ "open",
1025
+ "IndexedDB is unavailable in this environment.",
1026
+ { rallyId }
1027
+ );
1028
+ }
1029
+ #openDatabase(rallyId) {
1030
+ if (this.#databasePromise !== null) return this.#databasePromise;
1031
+ const factory = this.#getFactory(rallyId);
1032
+ const opening = new Promise((resolve, reject) => {
1033
+ let request;
1034
+ try {
1035
+ request = factory.open(this.#databaseName, 1);
1036
+ } catch (cause) {
1037
+ reject(
1038
+ new StorageAdapterError(
1039
+ "STORAGE_OPEN_FAILED",
1040
+ "open",
1041
+ `Failed to open IndexedDB database '${this.#databaseName}'.`,
1042
+ { cause, rallyId }
1043
+ )
1044
+ );
1045
+ return;
1046
+ }
1047
+ request.onupgradeneeded = () => {
1048
+ const database = request.result;
1049
+ if (!database.objectStoreNames.contains(INDEXED_DB_STORE_NAME)) {
1050
+ database.createObjectStore(INDEXED_DB_STORE_NAME);
1051
+ }
1052
+ };
1053
+ request.onsuccess = () => resolve(request.result);
1054
+ request.onerror = () => {
1055
+ reject(
1056
+ new StorageAdapterError(
1057
+ "STORAGE_OPEN_FAILED",
1058
+ "open",
1059
+ `Failed to open IndexedDB database '${this.#databaseName}'.`,
1060
+ { cause: request.error, rallyId }
1061
+ )
1062
+ );
1063
+ };
1064
+ request.onblocked = () => {
1065
+ reject(
1066
+ new StorageAdapterError(
1067
+ "STORAGE_OPEN_FAILED",
1068
+ "open",
1069
+ `Opening IndexedDB database '${this.#databaseName}' was blocked.`,
1070
+ { rallyId }
1071
+ )
1072
+ );
1073
+ };
1074
+ });
1075
+ this.#databasePromise = opening.catch((error) => {
1076
+ this.#databasePromise = null;
1077
+ throw error;
1078
+ });
1079
+ return this.#databasePromise;
1080
+ }
1081
+ };
1082
+
1083
+ // src/client/client.ts
1084
+ var systemClock = () => (/* @__PURE__ */ new Date()).toISOString();
1085
+ var StampRallyClient = class {
1086
+ #listeners = /* @__PURE__ */ new Set();
1087
+ #config;
1088
+ #storage;
1089
+ #clock;
1090
+ #currentState = null;
1091
+ #initialization = null;
1092
+ #operationQueue = Promise.resolve();
1093
+ constructor(config, storage, clock = systemClock) {
1094
+ this.#config = config;
1095
+ this.#storage = storage;
1096
+ this.#clock = clock;
1097
+ }
1098
+ getState() {
1099
+ return this.#currentState;
1100
+ }
1101
+ getConfig() {
1102
+ return this.#config;
1103
+ }
1104
+ subscribe(listener) {
1105
+ this.#listeners.add(listener);
1106
+ return () => {
1107
+ this.#listeners.delete(listener);
1108
+ };
1109
+ }
1110
+ init() {
1111
+ return this.initialize();
1112
+ }
1113
+ initialize() {
1114
+ if (this.#currentState !== null) {
1115
+ return Promise.resolve(this.#currentState);
1116
+ }
1117
+ if (this.#initialization === null) {
1118
+ this.#initialization = this.#storage.load(this.#config.id).then((storedState) => {
1119
+ const state = storedState === null ? this.#createEmptyState(this.#clock()) : this.#reconcileState(cloneState(storedState), storedState.updatedAt);
1120
+ this.#currentState = state;
1121
+ this.#emit(state);
1122
+ return state;
1123
+ }).catch((error) => {
1124
+ this.#initialization = null;
1125
+ throw error;
1126
+ });
1127
+ }
1128
+ return this.#initialization;
1129
+ }
1130
+ acquire(stampId, context, now = this.#clock()) {
1131
+ return this.#enqueue(async () => {
1132
+ const currentState = await this.initialize();
1133
+ const result = processStamp(currentState, this.#config, stampId, context, now);
1134
+ if (!result.ok) {
1135
+ return result;
1136
+ }
1137
+ await this.#storage.save(result.value.nextState);
1138
+ this.#currentState = result.value.nextState;
1139
+ this.#emit(result.value.nextState);
1140
+ return result;
1141
+ });
1142
+ }
1143
+ reset(now = this.#clock()) {
1144
+ return this.#enqueue(async () => {
1145
+ const initialization = this.#initialization;
1146
+ if (initialization !== null) {
1147
+ await initialization.catch(() => void 0);
1148
+ }
1149
+ await this.#storage.remove(this.#config.id);
1150
+ const nextState = this.#createEmptyState(now);
1151
+ this.#currentState = nextState;
1152
+ this.#initialization = Promise.resolve(nextState);
1153
+ this.#emit(nextState);
1154
+ return nextState;
1155
+ });
1156
+ }
1157
+ restore(state) {
1158
+ return this.#enqueue(async () => {
1159
+ const initialization = this.#initialization;
1160
+ if (initialization !== null) {
1161
+ await initialization.catch(() => void 0);
1162
+ }
1163
+ if (state.rallyId !== this.#config.id) {
1164
+ throw new Error(
1165
+ `Cannot restore rally '${state.rallyId}' into client '${this.#config.id}'.`
1166
+ );
1167
+ }
1168
+ const nextState = this.#reconcileState(cloneState(state), state.updatedAt);
1169
+ await this.#storage.save(nextState);
1170
+ this.#currentState = nextState;
1171
+ this.#initialization = Promise.resolve(nextState);
1172
+ this.#emit(nextState);
1173
+ return nextState;
1174
+ });
1175
+ }
1176
+ #enqueue(operation) {
1177
+ const next = this.#operationQueue.then(operation, operation);
1178
+ this.#operationQueue = next.then(
1179
+ () => void 0,
1180
+ () => void 0
1181
+ );
1182
+ return next;
1183
+ }
1184
+ #emit(state) {
1185
+ for (const listener of this.#listeners) {
1186
+ listener(state);
1187
+ }
1188
+ }
1189
+ #createEmptyState(now) {
1190
+ const state = {
1191
+ rallyId: this.#config.id,
1192
+ records: [],
1193
+ ...this.#config.rewards === void 0 ? {} : {
1194
+ rewards: reconcileRewardStates(this.#config.rewards, [], 0, now)
1195
+ },
1196
+ updatedAt: now
1197
+ };
1198
+ return state;
1199
+ }
1200
+ #reconcileState(state, now) {
1201
+ const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
1202
+ const seenStampIds = /* @__PURE__ */ new Set();
1203
+ const records = state.records.filter((record) => {
1204
+ if (!configuredStampIds.has(record.stampId) || seenStampIds.has(record.stampId)) return false;
1205
+ seenStampIds.add(record.stampId);
1206
+ return true;
1207
+ });
1208
+ if (this.#config.rewards === void 0 && state.rewards === void 0) {
1209
+ return records.length === state.records.length ? state : { ...state, records };
1210
+ }
1211
+ return {
1212
+ ...state,
1213
+ records,
1214
+ rewards: reconcileRewardStates(
1215
+ this.#config.rewards ?? [],
1216
+ state.rewards ?? [],
1217
+ records.length,
1218
+ now
1219
+ )
1220
+ };
1221
+ }
1222
+ };
1223
+
1224
+ // src/domain/i18n.ts
1225
+ function resolveLocalizedText(text, locale, fallbackLocale = "ja") {
1226
+ if (text === void 0 || text === "") return "";
1227
+ if (typeof text === "string") return text;
1228
+ return text[locale] || text[fallbackLocale] || "";
1229
+ }
1230
+ function toLocalizedString(text) {
1231
+ if (text === void 0) return { ja: "", en: "" };
1232
+ return typeof text === "string" ? { ja: text, en: "" } : { ...text };
1233
+ }
1234
+
1235
+ // src/domain/models.ts
1236
+ var DEFAULT_SHEET_THEME = {
1237
+ primaryColor: "#9e551e",
1238
+ backgroundColor: "#fbf4df",
1239
+ cardBackgroundColor: "#fffdf5",
1240
+ textColor: "#352f25",
1241
+ slotShape: "rounded",
1242
+ gridColumns: 3,
1243
+ unclaimedOpacity: 1,
1244
+ fontFamily: "serif"
1245
+ };
1246
+
1247
+ // src/domain/themePresets.ts
1248
+ var THEME_PRESETS = [
1249
+ {
1250
+ id: "default",
1251
+ name: { ja: "\u30AF\u30E9\u30B7\u30C3\u30AF\u30D6\u30EB\u30FC", en: "Classic Blue" },
1252
+ description: {
1253
+ ja: "\u9752\u3092\u57FA\u8ABF\u306B\u3057\u305F\u3001\u660E\u308B\u304F\u89AA\u3057\u307F\u3084\u3059\u3044\u5B9A\u756A\u30C7\u30B6\u30A4\u30F3\u3067\u3059\u3002",
1254
+ en: "A bright, approachable classic built around a crisp blue palette."
1255
+ },
1256
+ theme: {
1257
+ primaryColor: "#2563eb",
1258
+ backgroundColor: "#eff6ff",
1259
+ cardBackgroundColor: "#ffffff",
1260
+ textColor: "#1e293b",
1261
+ slotShape: "circle",
1262
+ gridColumns: 3,
1263
+ unclaimedOpacity: 1,
1264
+ completedStampColor: "#dc2626",
1265
+ fontFamily: "serif"
1266
+ }
1267
+ },
1268
+ {
1269
+ id: "modern_dark",
1270
+ name: { ja: "\u30E2\u30C0\u30F3\u30C0\u30FC\u30AF", en: "Modern Dark" },
1271
+ description: {
1272
+ ja: "\u6DF1\u3044\u30CD\u30A4\u30D3\u30FC\u3068\u30A8\u30E1\u30E9\u30EB\u30C9\u3067\u307E\u3068\u3081\u305F\u3001\u6D17\u7DF4\u3055\u308C\u305F\u30C0\u30FC\u30AF\u30C6\u30FC\u30DE\u3067\u3059\u3002",
1273
+ en: "A polished dark theme pairing deep navy with emerald accents."
1274
+ },
1275
+ theme: {
1276
+ primaryColor: "#10b981",
1277
+ backgroundColor: "#0f172a",
1278
+ cardBackgroundColor: "#1e293b",
1279
+ textColor: "#f8fafc",
1280
+ slotShape: "rounded",
1281
+ gridColumns: 3,
1282
+ unclaimedOpacity: 1,
1283
+ completedStampColor: "#34d399",
1284
+ fontFamily: "system-ui"
1285
+ }
1286
+ },
1287
+ {
1288
+ id: "pop_candy",
1289
+ name: { ja: "\u30DD\u30C3\u30D7\u30AD\u30E3\u30F3\u30C7\u30A3", en: "Pop Candy" },
1290
+ description: {
1291
+ ja: "\u30D4\u30F3\u30AF\u3092\u4E3B\u5F79\u306B\u3057\u305F\u3001\u697D\u3057\u304F\u83EF\u3084\u304B\u306A\u30AD\u30E3\u30F3\u30C7\u30A3\u30AB\u30E9\u30FC\u30C6\u30FC\u30DE\u3067\u3059\u3002",
1292
+ en: "A playful candy-colored theme with vivid pink at center stage."
1293
+ },
1294
+ theme: {
1295
+ primaryColor: "#ec4899",
1296
+ backgroundColor: "#fdf2f8",
1297
+ cardBackgroundColor: "#ffffff",
1298
+ textColor: "#831843",
1299
+ slotShape: "circle",
1300
+ gridColumns: 2,
1301
+ unclaimedOpacity: 1,
1302
+ completedStampColor: "#f43f5e",
1303
+ fontFamily: "rounded-sans"
1304
+ }
1305
+ },
1306
+ {
1307
+ id: "retro_craft",
1308
+ name: { ja: "\u30EC\u30C8\u30ED\u30AF\u30E9\u30D5\u30C8", en: "Retro Craft" },
1309
+ description: {
1310
+ ja: "\u30AF\u30E9\u30D5\u30C8\u7D19\u3068\u8D64\u8336\u8272\u306E\u30A4\u30F3\u30AF\u3092\u601D\u308F\u305B\u308B\u3001\u6E29\u304B\u307F\u306E\u3042\u308B\u30EC\u30C8\u30ED\u30C6\u30FC\u30DE\u3067\u3059\u3002",
1311
+ en: "A warm retro theme inspired by craft paper and earthy red ink."
1312
+ },
1313
+ theme: {
1314
+ primaryColor: "#9a3412",
1315
+ backgroundColor: "#fef3c7",
1316
+ cardBackgroundColor: "#fffbeb",
1317
+ textColor: "#78350f",
1318
+ slotShape: "square",
1319
+ gridColumns: 3,
1320
+ unclaimedOpacity: 1,
1321
+ completedStampColor: "#b91c1c",
1322
+ fontFamily: "handwritten"
1323
+ }
1324
+ },
1325
+ {
1326
+ id: "cyber",
1327
+ name: { ja: "\u30B5\u30A4\u30D0\u30FC\u30CD\u30AA\u30F3", en: "Cyber Neon" },
1328
+ description: {
1329
+ ja: "\u30B7\u30A2\u30F3\u3068\u7D2B\u306E\u30CD\u30AA\u30F3\u304C\u6620\u3048\u308B\u3001\u30B7\u30E3\u30FC\u30D7\u306A\u8FD1\u672A\u6765\u30C6\u30FC\u30DE\u3067\u3059\u3002",
1330
+ en: "A sharp futuristic theme illuminated by cyan and violet neon."
1331
+ },
1332
+ theme: {
1333
+ primaryColor: "#06b6d4",
1334
+ backgroundColor: "#09090b",
1335
+ cardBackgroundColor: "#18181b",
1336
+ textColor: "#fafafa",
1337
+ slotShape: "square",
1338
+ gridColumns: 4,
1339
+ unclaimedOpacity: 1,
1340
+ completedStampColor: "#a855f7",
1341
+ fontFamily: "monospace"
1342
+ }
1343
+ }
1344
+ ];
1345
+
1346
+ exports.DEFAULT_SHEET_THEME = DEFAULT_SHEET_THEME;
1347
+ exports.InMemoryStorage = InMemoryStorage;
1348
+ exports.IndexedDBAdapter = IndexedDBAdapter;
1349
+ exports.LocalStorageAdapter = LocalStorageAdapter;
1350
+ exports.StampRallyClient = StampRallyClient;
1351
+ exports.StorageAdapterError = StorageAdapterError;
1352
+ exports.THEME_PRESETS = THEME_PRESETS;
1353
+ exports.calculateDistanceMeters = calculateDistanceMeters;
1354
+ exports.calculateProgress = calculateProgress;
1355
+ exports.consumeReward = consumeReward;
1356
+ exports.evaluateCondition = evaluateCondition;
1357
+ exports.evaluateConditionDetailed = evaluateConditionDetailed;
1358
+ exports.exportProgressToken = exportProgressToken;
1359
+ exports.getCurrentGeoContext = getCurrentGeoContext;
1360
+ exports.importProgressToken = importProgressToken;
1361
+ exports.isGeolocationSupported = isGeolocationSupported;
1362
+ exports.isNfcSupported = isNfcSupported;
1363
+ exports.isQrSupported = isQrSupported;
1364
+ exports.isRewardState = isRewardState;
1365
+ exports.isStampRallyState = isStampRallyState;
1366
+ exports.normalizePasscode = normalizePasscode;
1367
+ exports.processStamp = processStamp;
1368
+ exports.readNfcContext = readNfcContext;
1369
+ exports.readQrContext = readQrContext;
1370
+ exports.reconcileRewardStates = reconcileRewardStates;
1371
+ exports.resolveLocalizedText = resolveLocalizedText;
1372
+ exports.toLocalizedString = toLocalizedString;
1373
+ exports.verifyPasscode = verifyPasscode;
1374
+ //# sourceMappingURL=index.cjs.map
1375
+ //# sourceMappingURL=index.cjs.map