@stamprally/core 0.7.0 → 0.8.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 +368 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +134 -1
- package/dist/index.d.ts +134 -1
- package/dist/index.js +368 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ function contextTypeMismatch(conditionType, expectedContextType, actualContextTy
|
|
|
29
29
|
function assertNever(value) {
|
|
30
30
|
throw new Error(`Unexpected condition: ${JSON.stringify(value)}`);
|
|
31
31
|
}
|
|
32
|
-
function evaluateConditionDetailed(condition2, context,
|
|
32
|
+
function evaluateConditionDetailed(condition2, context, now2) {
|
|
33
33
|
switch (condition2.type) {
|
|
34
34
|
case "instant":
|
|
35
35
|
return context.type === "instant" ? { ok: true, value: { conditionType: "instant" } } : contextTypeMismatch("instant", "instant", context.type);
|
|
@@ -59,14 +59,14 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
59
59
|
}
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
const
|
|
62
|
+
const distanceMeters2 = calculateDistanceMeters(
|
|
63
63
|
condition2.latitude,
|
|
64
64
|
condition2.longitude,
|
|
65
65
|
context.currentLatitude,
|
|
66
66
|
context.currentLongitude
|
|
67
67
|
);
|
|
68
|
-
if (
|
|
69
|
-
return { ok: true, value: { conditionType: "geo", distanceMeters } };
|
|
68
|
+
if (distanceMeters2 <= condition2.radiusMeters) {
|
|
69
|
+
return { ok: true, value: { conditionType: "geo", distanceMeters: distanceMeters2 } };
|
|
70
70
|
}
|
|
71
71
|
return {
|
|
72
72
|
ok: false,
|
|
@@ -74,9 +74,9 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
74
74
|
code: "CONDITION_MISMATCH",
|
|
75
75
|
conditionType: "geo",
|
|
76
76
|
reason: "OUTSIDE_RADIUS",
|
|
77
|
-
distanceMeters,
|
|
77
|
+
distanceMeters: distanceMeters2,
|
|
78
78
|
radiusMeters: condition2.radiusMeters,
|
|
79
|
-
differenceMeters:
|
|
79
|
+
differenceMeters: distanceMeters2 - condition2.radiusMeters
|
|
80
80
|
}
|
|
81
81
|
};
|
|
82
82
|
}
|
|
@@ -101,7 +101,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
101
101
|
for (const [index, childCondition] of condition2.conditions.entries()) {
|
|
102
102
|
const childContext = context.contexts[index];
|
|
103
103
|
if (childContext === void 0) continue;
|
|
104
|
-
const result = evaluateConditionDetailed(childCondition, childContext,
|
|
104
|
+
const result = evaluateConditionDetailed(childCondition, childContext, now2);
|
|
105
105
|
if (result.ok) matchedCount += 1;
|
|
106
106
|
else failures.push({ index, error: result.error });
|
|
107
107
|
}
|
|
@@ -124,7 +124,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
124
124
|
case "time_window": {
|
|
125
125
|
const startsAt = Date.parse(condition2.startsAt);
|
|
126
126
|
const endsAt = Date.parse(condition2.endsAt);
|
|
127
|
-
const currentTime = Date.parse(
|
|
127
|
+
const currentTime = Date.parse(now2);
|
|
128
128
|
if (!Number.isFinite(currentTime)) {
|
|
129
129
|
return {
|
|
130
130
|
ok: false,
|
|
@@ -132,7 +132,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
132
132
|
code: "CONDITION_MISMATCH",
|
|
133
133
|
conditionType: "time_window",
|
|
134
134
|
reason: "INVALID_NOW",
|
|
135
|
-
now,
|
|
135
|
+
now: now2,
|
|
136
136
|
startsAt: condition2.startsAt,
|
|
137
137
|
endsAt: condition2.endsAt
|
|
138
138
|
}
|
|
@@ -145,7 +145,7 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
145
145
|
code: "CONDITION_MISMATCH",
|
|
146
146
|
conditionType: "time_window",
|
|
147
147
|
reason: "INVALID_TIME_WINDOW",
|
|
148
|
-
now,
|
|
148
|
+
now: now2,
|
|
149
149
|
startsAt: condition2.startsAt,
|
|
150
150
|
endsAt: condition2.endsAt
|
|
151
151
|
}
|
|
@@ -158,21 +158,21 @@ function evaluateConditionDetailed(condition2, context, now) {
|
|
|
158
158
|
code: "CONDITION_MISMATCH",
|
|
159
159
|
conditionType: "time_window",
|
|
160
160
|
reason: currentTime < startsAt ? "BEFORE_START" : "AFTER_END",
|
|
161
|
-
now,
|
|
161
|
+
now: now2,
|
|
162
162
|
startsAt: condition2.startsAt,
|
|
163
163
|
endsAt: condition2.endsAt
|
|
164
164
|
}
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
|
-
const childResult = evaluateConditionDetailed(condition2.condition, context,
|
|
167
|
+
const childResult = evaluateConditionDetailed(condition2.condition, context, now2);
|
|
168
168
|
return childResult.ok ? { ok: true, value: { conditionType: "time_window" } } : childResult;
|
|
169
169
|
}
|
|
170
170
|
default:
|
|
171
171
|
return assertNever(condition2);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
function evaluateCondition(condition2, context,
|
|
175
|
-
return evaluateConditionDetailed(condition2, context,
|
|
174
|
+
function evaluateCondition(condition2, context, now2) {
|
|
175
|
+
return evaluateConditionDetailed(condition2, context, now2 ?? "").ok;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// src/engine/checkIn.ts
|
|
@@ -621,14 +621,14 @@ async function readQrContext(videoElement, options = {}) {
|
|
|
621
621
|
}
|
|
622
622
|
|
|
623
623
|
// src/engine/transition.ts
|
|
624
|
-
function reconcileRewardStates(rewards, currentStates, acquiredStampCount,
|
|
624
|
+
function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now2) {
|
|
625
625
|
const statesById = new Map(currentStates.map((state) => [state.rewardId, state]));
|
|
626
626
|
return rewards.map((reward) => {
|
|
627
627
|
const current = statesById.get(reward.id);
|
|
628
628
|
if (current?.status === "CONSUMED" || current?.status === "EXPIRED") {
|
|
629
629
|
return current;
|
|
630
630
|
}
|
|
631
|
-
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(
|
|
631
|
+
if (reward.validUntil !== void 0 && Date.parse(reward.validUntil) <= Date.parse(now2)) {
|
|
632
632
|
return { rewardId: reward.id, status: "EXPIRED" };
|
|
633
633
|
}
|
|
634
634
|
const stockLimit = reward.stockLimit ?? reward.maxStock;
|
|
@@ -644,7 +644,7 @@ function reconcileRewardStates(rewards, currentStates, acquiredStampCount, now)
|
|
|
644
644
|
return {
|
|
645
645
|
rewardId: reward.id,
|
|
646
646
|
status: "AVAILABLE",
|
|
647
|
-
unlockedAt: current?.unlockedAt ??
|
|
647
|
+
unlockedAt: current?.unlockedAt ?? now2,
|
|
648
648
|
...current?.claimTicketNumber === void 0 ? {} : { claimTicketNumber: current.claimTicketNumber }
|
|
649
649
|
};
|
|
650
650
|
}
|
|
@@ -709,7 +709,7 @@ function consumeReward(params) {
|
|
|
709
709
|
}
|
|
710
710
|
};
|
|
711
711
|
}
|
|
712
|
-
function processStamp(state, config, targetStampId, context,
|
|
712
|
+
function processStamp(state, config, targetStampId, context, now2) {
|
|
713
713
|
const targetStamp = config.stamps.find((stamp) => stamp.id === targetStampId);
|
|
714
714
|
if (targetStamp === void 0) {
|
|
715
715
|
return { ok: false, error: { code: "STAMP_NOT_FOUND", stampId: targetStampId } };
|
|
@@ -734,7 +734,7 @@ function processStamp(state, config, targetStampId, context, now) {
|
|
|
734
734
|
};
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
|
-
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context,
|
|
737
|
+
const conditionResult = evaluateConditionDetailed(targetStamp.condition, context, now2);
|
|
738
738
|
if (!conditionResult.ok) {
|
|
739
739
|
return {
|
|
740
740
|
ok: false,
|
|
@@ -745,27 +745,27 @@ function processStamp(state, config, targetStampId, context, now) {
|
|
|
745
745
|
}
|
|
746
746
|
};
|
|
747
747
|
}
|
|
748
|
-
const record = { stampId: targetStampId, acquiredAt:
|
|
748
|
+
const record = { stampId: targetStampId, acquiredAt: now2 };
|
|
749
749
|
const nextRecords = [...state.records, record];
|
|
750
|
-
const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length,
|
|
750
|
+
const nextRewards = config.rewards === void 0 && state.rewards === void 0 ? void 0 : reconcileRewardStates(config.rewards ?? [], state.rewards ?? [], nextRecords.length, now2);
|
|
751
751
|
const nextState = {
|
|
752
752
|
...state,
|
|
753
753
|
records: nextRecords,
|
|
754
754
|
...nextRewards === void 0 ? {} : { rewards: nextRewards },
|
|
755
|
-
updatedAt:
|
|
755
|
+
updatedAt: now2
|
|
756
756
|
};
|
|
757
757
|
const events = [{ type: "stampAcquired", record }];
|
|
758
758
|
if (nextRewards !== void 0) {
|
|
759
759
|
for (const rewardState of nextRewards) {
|
|
760
760
|
const previous = state.rewards?.find((item) => item.rewardId === rewardState.rewardId);
|
|
761
761
|
if (rewardState.status === "AVAILABLE" && previous?.status !== "AVAILABLE") {
|
|
762
|
-
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt:
|
|
762
|
+
events.push({ type: "rewardUnlocked", rewardId: rewardState.rewardId, unlockedAt: now2 });
|
|
763
763
|
}
|
|
764
764
|
}
|
|
765
765
|
}
|
|
766
766
|
const completed = config.stamps.length > 0 && config.stamps.every((stamp) => nextState.records.some((item) => item.stampId === stamp.id));
|
|
767
767
|
if (completed) {
|
|
768
|
-
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt:
|
|
768
|
+
events.push({ type: "rallyCompleted", rallyId: config.id, completedAt: now2 });
|
|
769
769
|
}
|
|
770
770
|
return { ok: true, value: { nextState, events } };
|
|
771
771
|
}
|
|
@@ -1270,10 +1270,10 @@ var StampRallyClient = class {
|
|
|
1270
1270
|
}
|
|
1271
1271
|
return this.#initialization;
|
|
1272
1272
|
}
|
|
1273
|
-
acquire(stampId, context,
|
|
1273
|
+
acquire(stampId, context, now2 = this.#clock()) {
|
|
1274
1274
|
return this.#enqueue(async () => {
|
|
1275
1275
|
const currentState = await this.initialize();
|
|
1276
|
-
const result = processStamp(currentState, this.#config, stampId, context,
|
|
1276
|
+
const result = processStamp(currentState, this.#config, stampId, context, now2);
|
|
1277
1277
|
if (!result.ok) {
|
|
1278
1278
|
this.#emitEvent({ type: "error", error: result.error });
|
|
1279
1279
|
return result;
|
|
@@ -1285,14 +1285,14 @@ var StampRallyClient = class {
|
|
|
1285
1285
|
return result;
|
|
1286
1286
|
});
|
|
1287
1287
|
}
|
|
1288
|
-
reset(
|
|
1288
|
+
reset(now2 = this.#clock()) {
|
|
1289
1289
|
return this.#enqueue(async () => {
|
|
1290
1290
|
const initialization = this.#initialization;
|
|
1291
1291
|
if (initialization !== null) {
|
|
1292
1292
|
await initialization.catch(() => void 0);
|
|
1293
1293
|
}
|
|
1294
1294
|
await this.#storage.remove(this.#config.id);
|
|
1295
|
-
const nextState = this.#createEmptyState(
|
|
1295
|
+
const nextState = this.#createEmptyState(now2);
|
|
1296
1296
|
this.#currentState = nextState;
|
|
1297
1297
|
this.#initialization = Promise.resolve(nextState);
|
|
1298
1298
|
this.#emit(nextState);
|
|
@@ -1334,18 +1334,18 @@ var StampRallyClient = class {
|
|
|
1334
1334
|
#emitEvent(event) {
|
|
1335
1335
|
for (const listener of this.#eventListeners) listener(event);
|
|
1336
1336
|
}
|
|
1337
|
-
#createEmptyState(
|
|
1337
|
+
#createEmptyState(now2) {
|
|
1338
1338
|
const state = {
|
|
1339
1339
|
rallyId: this.#config.id,
|
|
1340
1340
|
records: [],
|
|
1341
1341
|
...this.#config.rewards === void 0 ? {} : {
|
|
1342
|
-
rewards: reconcileRewardStates(this.#config.rewards, [], 0,
|
|
1342
|
+
rewards: reconcileRewardStates(this.#config.rewards, [], 0, now2)
|
|
1343
1343
|
},
|
|
1344
|
-
updatedAt:
|
|
1344
|
+
updatedAt: now2
|
|
1345
1345
|
};
|
|
1346
1346
|
return state;
|
|
1347
1347
|
}
|
|
1348
|
-
#reconcileState(state,
|
|
1348
|
+
#reconcileState(state, now2) {
|
|
1349
1349
|
const configuredStampIds = new Set(this.#config.stamps.map((stamp) => stamp.id));
|
|
1350
1350
|
const seenStampIds = /* @__PURE__ */ new Set();
|
|
1351
1351
|
const records = state.records.filter((record) => {
|
|
@@ -1363,12 +1363,319 @@ var StampRallyClient = class {
|
|
|
1363
1363
|
this.#config.rewards ?? [],
|
|
1364
1364
|
state.rewards ?? [],
|
|
1365
1365
|
records.length,
|
|
1366
|
-
|
|
1366
|
+
now2
|
|
1367
1367
|
)
|
|
1368
1368
|
};
|
|
1369
1369
|
}
|
|
1370
1370
|
};
|
|
1371
1371
|
|
|
1372
|
+
// src/client/universalClient.ts
|
|
1373
|
+
function isStorage(value) {
|
|
1374
|
+
return "load" in value && "save" in value && "remove" in value;
|
|
1375
|
+
}
|
|
1376
|
+
function randomId(prefix) {
|
|
1377
|
+
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
|
1378
|
+
}
|
|
1379
|
+
function now() {
|
|
1380
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1381
|
+
}
|
|
1382
|
+
function text(value) {
|
|
1383
|
+
if (typeof value === "string") return value;
|
|
1384
|
+
if (typeof value === "object" && value !== null) {
|
|
1385
|
+
const first = Object.values(value).find((item) => typeof item === "string");
|
|
1386
|
+
if (typeof first === "string") return first;
|
|
1387
|
+
}
|
|
1388
|
+
return "";
|
|
1389
|
+
}
|
|
1390
|
+
function proofString(value) {
|
|
1391
|
+
if (typeof value === "string") return value;
|
|
1392
|
+
if (typeof value === "object" && value !== null) {
|
|
1393
|
+
const record = value;
|
|
1394
|
+
for (const key of ["token", "code", "passcode", "value"]) {
|
|
1395
|
+
if (typeof record[key] === "string") return record[key];
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return "";
|
|
1399
|
+
}
|
|
1400
|
+
function distanceMeters(aLat, aLon, bLat, bLon) {
|
|
1401
|
+
const radians = (degrees) => degrees * Math.PI / 180;
|
|
1402
|
+
const dLat = radians(bLat - aLat);
|
|
1403
|
+
const dLon = radians(bLon - aLon);
|
|
1404
|
+
const value = Math.sin(dLat / 2) ** 2 + Math.cos(radians(aLat)) * Math.cos(radians(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
1405
|
+
return 6371e3 * 2 * Math.asin(Math.sqrt(Math.min(1, value)));
|
|
1406
|
+
}
|
|
1407
|
+
function conditionMatches(condition2, proofData) {
|
|
1408
|
+
switch (condition2.type) {
|
|
1409
|
+
case "qr":
|
|
1410
|
+
return proofString(proofData).trim() !== "";
|
|
1411
|
+
case "passcode":
|
|
1412
|
+
return proofString(proofData).trim() !== "";
|
|
1413
|
+
case "gps": {
|
|
1414
|
+
if (typeof proofData !== "object" || proofData === null) return false;
|
|
1415
|
+
const value = proofData;
|
|
1416
|
+
const latitude = typeof value.latitude === "number" ? value.latitude : Number.NaN;
|
|
1417
|
+
const longitude = typeof value.longitude === "number" ? value.longitude : Number.NaN;
|
|
1418
|
+
return Number.isFinite(latitude) && Number.isFinite(longitude) && distanceMeters(condition2.latitude, condition2.longitude, latitude, longitude) <= condition2.radiusMeters;
|
|
1419
|
+
}
|
|
1420
|
+
case "custom":
|
|
1421
|
+
return true;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function asReward(reward) {
|
|
1425
|
+
return { ...reward, description: text(reward.description) };
|
|
1426
|
+
}
|
|
1427
|
+
function initialState(config, timestamp) {
|
|
1428
|
+
const rewards = config.rewards.map(asReward);
|
|
1429
|
+
return {
|
|
1430
|
+
rallyId: config.id,
|
|
1431
|
+
records: [],
|
|
1432
|
+
rewards: reconcileRewardStates(rewards, [], 0, timestamp),
|
|
1433
|
+
updatedAt: timestamp
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
var UniversalStampRallyClient = class {
|
|
1437
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1438
|
+
#eventListeners = /* @__PURE__ */ new Set();
|
|
1439
|
+
#config;
|
|
1440
|
+
#storage;
|
|
1441
|
+
#options;
|
|
1442
|
+
#state = null;
|
|
1443
|
+
#initialization = null;
|
|
1444
|
+
#queue = Promise.resolve();
|
|
1445
|
+
constructor(config, storageOrOptions = {}, clock) {
|
|
1446
|
+
this.#config = config;
|
|
1447
|
+
this.#options = isStorage(storageOrOptions) ? { storage: storageOrOptions, ...clock === void 0 ? {} : { clock } } : storageOrOptions;
|
|
1448
|
+
this.#storage = this.#options.storage ?? new InMemoryStorage();
|
|
1449
|
+
}
|
|
1450
|
+
getConfig() {
|
|
1451
|
+
return this.#config;
|
|
1452
|
+
}
|
|
1453
|
+
getState() {
|
|
1454
|
+
return this.#state;
|
|
1455
|
+
}
|
|
1456
|
+
subscribe(listener) {
|
|
1457
|
+
this.#listeners.add(listener);
|
|
1458
|
+
return () => this.#listeners.delete(listener);
|
|
1459
|
+
}
|
|
1460
|
+
subscribeEvents(listener) {
|
|
1461
|
+
this.#eventListeners.add(listener);
|
|
1462
|
+
return () => this.#eventListeners.delete(listener);
|
|
1463
|
+
}
|
|
1464
|
+
init() {
|
|
1465
|
+
return this.initialize();
|
|
1466
|
+
}
|
|
1467
|
+
initialize() {
|
|
1468
|
+
if (this.#state !== null) return Promise.resolve(this.#state);
|
|
1469
|
+
if (this.#initialization === null) {
|
|
1470
|
+
this.#initialization = this.#storage.load(this.#config.id).then((stored) => {
|
|
1471
|
+
const state = stored === null ? initialState(this.#config, this.#now()) : this.#reconcile(stored);
|
|
1472
|
+
this.#state = state;
|
|
1473
|
+
this.#emit(state);
|
|
1474
|
+
return state;
|
|
1475
|
+
}).catch((error) => {
|
|
1476
|
+
this.#initialization = null;
|
|
1477
|
+
throw error;
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
return this.#initialization;
|
|
1481
|
+
}
|
|
1482
|
+
checkIn(spotId, proofData, options = {}) {
|
|
1483
|
+
return this.#enqueue(async () => {
|
|
1484
|
+
const current = await this.initialize();
|
|
1485
|
+
const spot = this.#config.spots.find((item) => item.id === spotId);
|
|
1486
|
+
if (spot === void 0)
|
|
1487
|
+
return this.#fail({ code: "SPOT_NOT_FOUND", spotId, message: "Spot was not found." });
|
|
1488
|
+
if (current.records.some((record2) => record2.stampId === spotId))
|
|
1489
|
+
return this.#fail({
|
|
1490
|
+
code: "STAMP_ALREADY_ACQUIRED",
|
|
1491
|
+
spotId,
|
|
1492
|
+
message: "Spot was already claimed."
|
|
1493
|
+
});
|
|
1494
|
+
const acquired = new Set(current.records.map((record2) => record2.stampId));
|
|
1495
|
+
if (spot.prerequisites?.some((id) => !acquired.has(id)))
|
|
1496
|
+
return this.#fail({
|
|
1497
|
+
code: "PREREQUISITES_NOT_MET",
|
|
1498
|
+
spotId,
|
|
1499
|
+
message: "Prerequisite spots are not complete."
|
|
1500
|
+
});
|
|
1501
|
+
for (const condition2 of spot.conditions) {
|
|
1502
|
+
if (condition2.type === "custom") {
|
|
1503
|
+
const validator = this.#options.customValidators?.[condition2.validatorName] ?? this.#options.customValidator;
|
|
1504
|
+
if (validator !== void 0) {
|
|
1505
|
+
try {
|
|
1506
|
+
const validation = await validator({
|
|
1507
|
+
spotId,
|
|
1508
|
+
proofData,
|
|
1509
|
+
config: this.#config,
|
|
1510
|
+
userState: current
|
|
1511
|
+
});
|
|
1512
|
+
if (!validation.success)
|
|
1513
|
+
return this.#fail({
|
|
1514
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1515
|
+
spotId,
|
|
1516
|
+
message: validation.error ?? "Custom validation failed."
|
|
1517
|
+
});
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
return this.#fail({
|
|
1520
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1521
|
+
spotId,
|
|
1522
|
+
message: error instanceof Error ? error.message : "Custom validation failed."
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
} else {
|
|
1526
|
+
return this.#fail({
|
|
1527
|
+
code: "CUSTOM_VALIDATION_FAILED",
|
|
1528
|
+
spotId,
|
|
1529
|
+
message: "No custom validator is registered."
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
} else if (!conditionMatches(condition2, proofData)) {
|
|
1533
|
+
return this.#fail({ code: "INVALID_PROOF", spotId, message: "Verification failed." });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const timestamp = options.now ?? this.#now();
|
|
1537
|
+
const request = {
|
|
1538
|
+
rallyId: this.#config.id,
|
|
1539
|
+
spotId,
|
|
1540
|
+
proofData,
|
|
1541
|
+
idempotencyKey: options.idempotencyKey ?? randomId("check-in"),
|
|
1542
|
+
now: timestamp,
|
|
1543
|
+
state: current
|
|
1544
|
+
};
|
|
1545
|
+
const remote = this.#options.syncAdapter?.checkIn;
|
|
1546
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1547
|
+
const result = await remote(request);
|
|
1548
|
+
if (!result.ok) return this.#fail(result.error);
|
|
1549
|
+
return this.#commitCheckIn(result.value.state, result);
|
|
1550
|
+
}
|
|
1551
|
+
const record = { stampId: spotId, acquiredAt: timestamp };
|
|
1552
|
+
const next = this.#reconcile({
|
|
1553
|
+
...current,
|
|
1554
|
+
records: [...current.records, record],
|
|
1555
|
+
updatedAt: timestamp
|
|
1556
|
+
});
|
|
1557
|
+
await this.#storage.save(next);
|
|
1558
|
+
return this.#commitCheckIn(next, { ok: true, value: { state: next, record } });
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
claimReward(rewardId, options = {}) {
|
|
1562
|
+
return this.#enqueue(async () => {
|
|
1563
|
+
const current = await this.initialize();
|
|
1564
|
+
const configured = this.#config.rewards.find((item) => item.id === rewardId);
|
|
1565
|
+
if (configured === void 0)
|
|
1566
|
+
return this.#fail({ code: "REWARD_NOT_FOUND", rewardId, message: "Reward was not found." });
|
|
1567
|
+
const timestamp = options.now ?? this.#now();
|
|
1568
|
+
const state = current.rewards?.find((item) => item.rewardId === rewardId) ?? {
|
|
1569
|
+
rewardId,
|
|
1570
|
+
status: "LOCKED"
|
|
1571
|
+
};
|
|
1572
|
+
const local = consumeReward({
|
|
1573
|
+
reward: asReward(configured),
|
|
1574
|
+
currentState: state,
|
|
1575
|
+
now: timestamp,
|
|
1576
|
+
...options.staffPasscode === void 0 ? {} : { inputPasscode: options.staffPasscode },
|
|
1577
|
+
...options.staffId === void 0 ? {} : { staffId: options.staffId }
|
|
1578
|
+
});
|
|
1579
|
+
if (!local.ok) return this.#fail(local.error);
|
|
1580
|
+
const request = {
|
|
1581
|
+
rallyId: this.#config.id,
|
|
1582
|
+
rewardId,
|
|
1583
|
+
idempotencyKey: options.idempotencyKey ?? randomId("claim"),
|
|
1584
|
+
now: timestamp,
|
|
1585
|
+
options,
|
|
1586
|
+
state: current
|
|
1587
|
+
};
|
|
1588
|
+
const remote = this.#options.syncAdapter?.claimReward;
|
|
1589
|
+
if (options.sync !== false && remote !== void 0) {
|
|
1590
|
+
const result = await remote(request);
|
|
1591
|
+
if (!result.ok) return this.#fail(result.error);
|
|
1592
|
+
return this.#commitClaim(result.value.state, result);
|
|
1593
|
+
}
|
|
1594
|
+
const nextRewards = (current.rewards ?? []).map(
|
|
1595
|
+
(item) => item.rewardId === rewardId ? local.value : item
|
|
1596
|
+
);
|
|
1597
|
+
const next = { ...current, rewards: nextRewards, updatedAt: timestamp };
|
|
1598
|
+
await this.#storage.save(next);
|
|
1599
|
+
return this.#commitClaim(next, { ok: true, value: { state: next, reward: local.value } });
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
sync(adapter = this.#options.syncAdapter ?? {}) {
|
|
1603
|
+
return this.#enqueue(async () => {
|
|
1604
|
+
const current = await this.initialize();
|
|
1605
|
+
if (adapter.sync === void 0) {
|
|
1606
|
+
this.#emitEvent({ type: "sync", state: current });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
try {
|
|
1610
|
+
const next = this.#reconcile(
|
|
1611
|
+
await adapter.sync({ rallyId: this.#config.id, state: current })
|
|
1612
|
+
);
|
|
1613
|
+
await this.#storage.save(next);
|
|
1614
|
+
this.#state = next;
|
|
1615
|
+
this.#emit(next);
|
|
1616
|
+
this.#emitEvent({ type: "sync", state: next });
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
const failure = {
|
|
1619
|
+
code: "SYNC_FAILED",
|
|
1620
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1621
|
+
};
|
|
1622
|
+
this.#emitEvent({ type: "error", error: failure });
|
|
1623
|
+
throw error;
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
#now() {
|
|
1628
|
+
return this.#options.clock?.() ?? now();
|
|
1629
|
+
}
|
|
1630
|
+
#reconcile(state) {
|
|
1631
|
+
const ids = new Set(this.#config.spots.map((spot) => spot.id));
|
|
1632
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1633
|
+
const records = state.records.filter(
|
|
1634
|
+
(record) => ids.has(record.stampId) && !seen.has(record.stampId) && seen.add(record.stampId)
|
|
1635
|
+
);
|
|
1636
|
+
return {
|
|
1637
|
+
...cloneState(state),
|
|
1638
|
+
records,
|
|
1639
|
+
rewards: reconcileRewardStates(
|
|
1640
|
+
this.#config.rewards.map(asReward),
|
|
1641
|
+
state.rewards ?? [],
|
|
1642
|
+
records.length,
|
|
1643
|
+
state.updatedAt
|
|
1644
|
+
)
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
#enqueue(operation) {
|
|
1648
|
+
const next = this.#queue.then(operation, operation);
|
|
1649
|
+
this.#queue = next.then(
|
|
1650
|
+
() => void 0,
|
|
1651
|
+
() => void 0
|
|
1652
|
+
);
|
|
1653
|
+
return next;
|
|
1654
|
+
}
|
|
1655
|
+
#fail(error) {
|
|
1656
|
+
this.#emitEvent({ type: "error", error });
|
|
1657
|
+
return { ok: false, error };
|
|
1658
|
+
}
|
|
1659
|
+
#commitCheckIn(state, result) {
|
|
1660
|
+
this.#state = state;
|
|
1661
|
+
this.#emit(state);
|
|
1662
|
+
this.#emitEvent({ type: "checkIn", result });
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
#commitClaim(state, result) {
|
|
1666
|
+
this.#state = state;
|
|
1667
|
+
this.#emit(state);
|
|
1668
|
+
this.#emitEvent({ type: "rewardClaimed", result });
|
|
1669
|
+
return result;
|
|
1670
|
+
}
|
|
1671
|
+
#emit(state) {
|
|
1672
|
+
for (const listener of this.#listeners) listener(state);
|
|
1673
|
+
}
|
|
1674
|
+
#emitEvent(event) {
|
|
1675
|
+
for (const listener of this.#eventListeners) listener(event);
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1372
1679
|
// src/crypto/token.ts
|
|
1373
1680
|
var encoder = new TextEncoder();
|
|
1374
1681
|
var decoder = new TextDecoder();
|
|
@@ -1430,7 +1737,7 @@ async function encryptPayload(plaintext, secret, api) {
|
|
|
1430
1737
|
);
|
|
1431
1738
|
return encode(new Uint8Array([...iv, ...encrypted]));
|
|
1432
1739
|
}
|
|
1433
|
-
async function verifySecureToken(token, secretKey,
|
|
1740
|
+
async function verifySecureToken(token, secretKey, now2 = Date.now()) {
|
|
1434
1741
|
try {
|
|
1435
1742
|
const parts = token.split(".");
|
|
1436
1743
|
if (parts.length !== 4 || parts[0] !== "sr3" || parts[1] !== "e" && parts[1] !== "p") {
|
|
@@ -1473,7 +1780,7 @@ async function verifySecureToken(token, secretKey, now = Date.now()) {
|
|
|
1473
1780
|
};
|
|
1474
1781
|
}
|
|
1475
1782
|
const payload = parsed;
|
|
1476
|
-
if (typeof payload.exp === "number" &&
|
|
1783
|
+
if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) {
|
|
1477
1784
|
return {
|
|
1478
1785
|
ok: false,
|
|
1479
1786
|
valid: false,
|
|
@@ -1502,18 +1809,18 @@ async function decryptPayload(body, secret) {
|
|
|
1502
1809
|
}
|
|
1503
1810
|
|
|
1504
1811
|
// src/domain/i18n.ts
|
|
1505
|
-
function resolveLocalizedText(
|
|
1506
|
-
if (
|
|
1507
|
-
if (typeof
|
|
1508
|
-
const fallback = fallbackLocale === void 0 ? Object.values(
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1511
|
-
function toLocalizedString(
|
|
1512
|
-
if (
|
|
1513
|
-
return typeof
|
|
1514
|
-
ja:
|
|
1515
|
-
en:
|
|
1516
|
-
...
|
|
1812
|
+
function resolveLocalizedText(text3, locale, fallbackLocale) {
|
|
1813
|
+
if (text3 === void 0 || text3 === "") return "";
|
|
1814
|
+
if (typeof text3 === "string") return text3;
|
|
1815
|
+
const fallback = fallbackLocale === void 0 ? Object.values(text3).find((value) => typeof value === "string") : text3[fallbackLocale];
|
|
1816
|
+
return text3[locale] || fallback || "";
|
|
1817
|
+
}
|
|
1818
|
+
function toLocalizedString(text3) {
|
|
1819
|
+
if (text3 === void 0) return { ja: "", en: "" };
|
|
1820
|
+
return typeof text3 === "string" ? { ja: text3, en: "" } : {
|
|
1821
|
+
ja: text3["ja"] ?? "",
|
|
1822
|
+
en: text3["en"] ?? "",
|
|
1823
|
+
...text3
|
|
1517
1824
|
};
|
|
1518
1825
|
}
|
|
1519
1826
|
|
|
@@ -1759,7 +2066,7 @@ function validateRallyConfig(config) {
|
|
|
1759
2066
|
function isObject2(value) {
|
|
1760
2067
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1761
2068
|
}
|
|
1762
|
-
function
|
|
2069
|
+
function text2(value) {
|
|
1763
2070
|
if (typeof value === "string") return value;
|
|
1764
2071
|
if (!isObject2(value)) return void 0;
|
|
1765
2072
|
const entries = Object.entries(value).filter(([, item]) => typeof item === "string");
|
|
@@ -1803,11 +2110,11 @@ function condition(value) {
|
|
|
1803
2110
|
function migrateSpot(value, index) {
|
|
1804
2111
|
const source2 = isObject2(value) ? value : {};
|
|
1805
2112
|
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `spot-${index + 1}`;
|
|
1806
|
-
const description =
|
|
1807
|
-
const hint =
|
|
2113
|
+
const description = text2(source2.description);
|
|
2114
|
+
const hint = text2(source2.hint);
|
|
1808
2115
|
return {
|
|
1809
2116
|
id,
|
|
1810
|
-
name:
|
|
2117
|
+
name: text2(source2.name) ?? `Spot ${index + 1}`,
|
|
1811
2118
|
...description === void 0 ? {} : { description },
|
|
1812
2119
|
...hint === void 0 ? {} : { hint },
|
|
1813
2120
|
condition: condition(
|
|
@@ -1829,8 +2136,8 @@ function migrateReward(value, index) {
|
|
|
1829
2136
|
const source2 = isObject2(value) ? value : {};
|
|
1830
2137
|
return {
|
|
1831
2138
|
id: typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : `reward-${index + 1}`,
|
|
1832
|
-
title:
|
|
1833
|
-
description:
|
|
2139
|
+
title: text2(source2.title) ?? `Reward ${index + 1}`,
|
|
2140
|
+
description: text2(source2.description) ?? "",
|
|
1834
2141
|
type: source2.type === "digital" ? "digital" : "in_person",
|
|
1835
2142
|
redemptionMethod: source2.redemptionMethod === "staff_passcode" || source2.redemptionMethod === "view_only" || source2.redemptionMethod === "server_claim" ? source2.redemptionMethod : "manual_slide",
|
|
1836
2143
|
requiredStampCount: typeof source2.requiredStampCount === "number" && Number.isFinite(source2.requiredStampCount) ? Math.max(0, Math.trunc(source2.requiredStampCount)) : 0,
|
|
@@ -1849,8 +2156,8 @@ function migrateRallyConfig(raw) {
|
|
|
1849
2156
|
const rawStamps = Array.isArray(source2.stamps) ? source2.stamps : Array.isArray(source2.spots) ? source2.spots : [];
|
|
1850
2157
|
const rawRewards = Array.isArray(source2.rewards) ? source2.rewards : [];
|
|
1851
2158
|
const id = typeof source2.id === "string" && source2.id.trim() !== "" ? source2.id.trim() : "migrated-rally";
|
|
1852
|
-
const title =
|
|
1853
|
-
const description =
|
|
2159
|
+
const title = text2(source2.title);
|
|
2160
|
+
const description = text2(source2.description);
|
|
1854
2161
|
const theme = isObject2(source2.theme) ? source2.theme : void 0;
|
|
1855
2162
|
return {
|
|
1856
2163
|
id,
|
|
@@ -2251,9 +2558,9 @@ async function importAesKey(secret) {
|
|
|
2251
2558
|
const digest = await cryptoApi2().subtle.digest("SHA-256", webCryptoBytes(secret));
|
|
2252
2559
|
return cryptoApi2().subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
2253
2560
|
}
|
|
2254
|
-
function isExpired(payload,
|
|
2255
|
-
if (typeof payload.exp === "number" &&
|
|
2256
|
-
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <=
|
|
2561
|
+
function isExpired(payload, now2) {
|
|
2562
|
+
if (typeof payload.exp === "number" && now2 >= payload.exp * 1e3) return true;
|
|
2563
|
+
return payload.expiresAt !== void 0 && Date.parse(payload.expiresAt) <= now2;
|
|
2257
2564
|
}
|
|
2258
2565
|
async function createSignedSnapshotToken(payload, secretKey) {
|
|
2259
2566
|
const api = cryptoApi2();
|
|
@@ -2273,7 +2580,7 @@ async function createSignedSnapshotToken(payload, secretKey) {
|
|
|
2273
2580
|
);
|
|
2274
2581
|
return `sr2.${body}.${base64Url(signature)}`;
|
|
2275
2582
|
}
|
|
2276
|
-
async function verifySnapshotToken(token, secretKey,
|
|
2583
|
+
async function verifySnapshotToken(token, secretKey, now2 = Date.now()) {
|
|
2277
2584
|
try {
|
|
2278
2585
|
const parts = token.split(".");
|
|
2279
2586
|
if (parts.length !== 3 || parts[0] !== "sr2") {
|
|
@@ -2315,7 +2622,7 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2315
2622
|
const payload = JSON.parse(new TextDecoder().decode(payloadText));
|
|
2316
2623
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload))
|
|
2317
2624
|
throw new Error("Invalid payload.");
|
|
2318
|
-
if (isExpired(payload,
|
|
2625
|
+
if (isExpired(payload, now2)) {
|
|
2319
2626
|
return {
|
|
2320
2627
|
ok: false,
|
|
2321
2628
|
valid: false,
|
|
@@ -2332,6 +2639,6 @@ async function verifySnapshotToken(token, secretKey, now = Date.now()) {
|
|
|
2332
2639
|
}
|
|
2333
2640
|
}
|
|
2334
2641
|
|
|
2335
|
-
export { CURRENT_RALLY_CONFIG_VERSION, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCheckIn, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, importProgressToken, isAdminRallyConfig, isGeolocationSupported, isNfcSupported, isPublicRallyConfig, isPublicRallyConfigShape, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, migrateRallyConfig, normalizePasscode, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, stripSensitiveConfig, toLocalizedString, toPublicRallyConfig, validateAdminRallyConfig, validatePublicRallyConfig, validateRallyConfig, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2642
|
+
export { CURRENT_RALLY_CONFIG_VERSION, DEFAULT_SHEET_THEME, InMemoryStorage, IndexedDBAdapter, LocalStorageAdapter, StampRallyClient, StorageAdapterError, THEME_PRESETS, UniversalStampRallyClient, calculateDistanceMeters, calculateProgress, consumeReward, createClaimTicketNumber, createSecureToken, createSignedSnapshotToken, createUniqueClaimTicketNumber, evaluateCheckIn, evaluateCondition, evaluateConditionDetailed, exportProgressToken, getCurrentGeoContext, importProgressToken, isAdminRallyConfig, isGeolocationSupported, isNfcSupported, isPublicRallyConfig, isPublicRallyConfigShape, isQrSupported, isRewardState, isStampRallyState, issueClaimTicketNumber, migrateRallyConfig, normalizePasscode, processStamp, readNfcContext, readQrContext, reconcileRewardStates, resolveLocalizedText, stripSensitiveConfig, toLocalizedString, toPublicRallyConfig, validateAdminRallyConfig, validatePublicRallyConfig, validateRallyConfig, verifyPasscode, verifySecureToken, verifySnapshotToken };
|
|
2336
2643
|
//# sourceMappingURL=index.js.map
|
|
2337
2644
|
//# sourceMappingURL=index.js.map
|