@lotics/app-sdk 0.27.0 → 0.29.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/src/comments.js +1 -18
- package/dist/src/geolocation.d.ts +64 -0
- package/dist/src/geolocation.js +96 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +2 -0
- package/dist/src/viewer.d.ts +25 -0
- package/dist/src/viewer.js +35 -0
- package/package.json +1 -1
package/dist/src/comments.js
CHANGED
|
@@ -24,6 +24,7 @@ import { useCallback } from "react";
|
|
|
24
24
|
import useSWR from "swr";
|
|
25
25
|
import { rpc } from "./rpc.js";
|
|
26
26
|
import { captureAppEvent } from "./analytics.js";
|
|
27
|
+
import { useAppContext } from "./viewer.js";
|
|
27
28
|
/** The reduced storage shape the update endpoint accepts for `files`. */
|
|
28
29
|
function toStorageFiles(files) {
|
|
29
30
|
return (files ?? []).map((f) => ({
|
|
@@ -33,24 +34,6 @@ function toStorageFiles(files) {
|
|
|
33
34
|
mime_type: f.mime_type,
|
|
34
35
|
}));
|
|
35
36
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
38
|
-
* Comments need a signed-in member (members-only) AND the app's declared
|
|
39
|
-
* `comments` capability — both come from here.
|
|
40
|
-
*/
|
|
41
|
-
function useAppContext() {
|
|
42
|
-
const { data } = useSWR("app-context", () => rpc("context", {}), {
|
|
43
|
-
revalidateOnFocus: false,
|
|
44
|
-
revalidateIfStale: false,
|
|
45
|
-
revalidateOnReconnect: false,
|
|
46
|
-
shouldRetryOnError: false,
|
|
47
|
-
});
|
|
48
|
-
return {
|
|
49
|
-
memberId: data?.member_id ?? null,
|
|
50
|
-
commentsEnabled: data?.comments_enabled ?? false,
|
|
51
|
-
resolved: data !== undefined,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
37
|
let optimisticCounter = 0;
|
|
55
38
|
/**
|
|
56
39
|
* ```tsx
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device location for geofenced app actions.
|
|
3
|
+
*
|
|
4
|
+
* The host grants the app iframe the `geolocation` Permissions-Policy, so app
|
|
5
|
+
* code reads the device position directly through the browser — no RPC bridge.
|
|
6
|
+
* This module wraps that with permission handling, a timeout, and a geofence
|
|
7
|
+
* check, and returns a STRUCTURED outcome (`denied` / `unavailable` / `outside`)
|
|
8
|
+
* so the app renders its own guidance. A terse, untranslated "permission denied"
|
|
9
|
+
* dead-end is the single biggest reason a geofenced check-in fails in the field,
|
|
10
|
+
* so the messaging belongs to the app, not buried in here.
|
|
11
|
+
*
|
|
12
|
+
* Self-contained on purpose: `@lotics/app-sdk` is published to npm with a minimal
|
|
13
|
+
* dependency set and cannot pull in the private `@lotics/shared`, so the haversine
|
|
14
|
+
* lives here rather than being imported.
|
|
15
|
+
*/
|
|
16
|
+
/** One allowed circular zone: center `[latitude, longitude]` and a radius in meters. */
|
|
17
|
+
export interface GeofenceZone {
|
|
18
|
+
coordinates: [number, number];
|
|
19
|
+
radius: number;
|
|
20
|
+
}
|
|
21
|
+
/** The resolved device position handed back on a successful check. */
|
|
22
|
+
export interface GeoCoords {
|
|
23
|
+
latitude: number;
|
|
24
|
+
longitude: number;
|
|
25
|
+
/** Accuracy radius of the fix, in meters (browser-reported). */
|
|
26
|
+
accuracy: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Result of `requestGeofencedLocation`. On failure, `reason` is a stable code the
|
|
30
|
+
* app maps to its own (localized) message and recovery UI:
|
|
31
|
+
* - `denied` — the user/browser has not granted location permission.
|
|
32
|
+
* - `unavailable` — no fix: location services off, no GPS, or the request timed out.
|
|
33
|
+
* - `outside` — a fix was obtained but it is not within any allowed zone.
|
|
34
|
+
*/
|
|
35
|
+
export type GeofenceOutcome = {
|
|
36
|
+
ok: true;
|
|
37
|
+
coords: GeoCoords;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: "denied" | "unavailable" | "outside";
|
|
41
|
+
};
|
|
42
|
+
/** Options for `requestGeofencedLocation`. */
|
|
43
|
+
export interface GeofenceOptions {
|
|
44
|
+
/** Max wait for a position fix before resolving `unavailable`. Default 15000ms. */
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
/** True when `[latitude, longitude]` falls within `zone`'s radius of its center. */
|
|
48
|
+
export declare function isWithinZone(latitude: number, longitude: number, zone: GeofenceZone): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Get the device position and check it against the allowed zones.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ ok: true, coords }` when a fix lands inside any zone — the app can
|
|
53
|
+
* forward `coords` to a workflow to record where the action happened. Otherwise
|
|
54
|
+
* `{ ok: false, reason }`; render guidance per `reason`. An empty `zones` array
|
|
55
|
+
* means "no geofence" and resolves `ok` with the raw position (use it for a
|
|
56
|
+
* plain location read).
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const r = await requestGeofencedLocation(DEPOTS);
|
|
60
|
+
* if (!r.ok) return showGeofenceError(r.reason);
|
|
61
|
+
* await chamCong();
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare function requestGeofencedLocation(zones: GeofenceZone[], opts?: GeofenceOptions): Promise<GeofenceOutcome>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device location for geofenced app actions.
|
|
3
|
+
*
|
|
4
|
+
* The host grants the app iframe the `geolocation` Permissions-Policy, so app
|
|
5
|
+
* code reads the device position directly through the browser — no RPC bridge.
|
|
6
|
+
* This module wraps that with permission handling, a timeout, and a geofence
|
|
7
|
+
* check, and returns a STRUCTURED outcome (`denied` / `unavailable` / `outside`)
|
|
8
|
+
* so the app renders its own guidance. A terse, untranslated "permission denied"
|
|
9
|
+
* dead-end is the single biggest reason a geofenced check-in fails in the field,
|
|
10
|
+
* so the messaging belongs to the app, not buried in here.
|
|
11
|
+
*
|
|
12
|
+
* Self-contained on purpose: `@lotics/app-sdk` is published to npm with a minimal
|
|
13
|
+
* dependency set and cannot pull in the private `@lotics/shared`, so the haversine
|
|
14
|
+
* lives here rather than being imported.
|
|
15
|
+
*/
|
|
16
|
+
const EARTH_RADIUS_M = 6_371_000;
|
|
17
|
+
function toRadians(degrees) {
|
|
18
|
+
return (degrees * Math.PI) / 180;
|
|
19
|
+
}
|
|
20
|
+
/** Great-circle distance between two coordinates, in meters (haversine). */
|
|
21
|
+
function distanceMeters(lat1, lon1, lat2, lon2) {
|
|
22
|
+
const dLat = toRadians(lat2 - lat1);
|
|
23
|
+
const dLon = toRadians(lon2 - lon1);
|
|
24
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
25
|
+
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
26
|
+
return EARTH_RADIUS_M * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
27
|
+
}
|
|
28
|
+
/** True when `[latitude, longitude]` falls within `zone`'s radius of its center. */
|
|
29
|
+
export function isWithinZone(latitude, longitude, zone) {
|
|
30
|
+
const [centerLat, centerLon] = zone.coordinates;
|
|
31
|
+
return distanceMeters(latitude, longitude, centerLat, centerLon) <= zone.radius;
|
|
32
|
+
}
|
|
33
|
+
async function readPermissionState() {
|
|
34
|
+
// The Permissions API is unavailable on some browsers (notably older Safari);
|
|
35
|
+
// fall back to `null` and let `getCurrentPosition` surface the real state.
|
|
36
|
+
if (typeof navigator === "undefined" || !navigator.permissions?.query)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const status = await navigator.permissions.query({ name: "geolocation" });
|
|
40
|
+
return status.state;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function getPosition(timeoutMs) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
49
|
+
enableHighAccuracy: false,
|
|
50
|
+
timeout: timeoutMs,
|
|
51
|
+
maximumAge: 0,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get the device position and check it against the allowed zones.
|
|
57
|
+
*
|
|
58
|
+
* Returns `{ ok: true, coords }` when a fix lands inside any zone — the app can
|
|
59
|
+
* forward `coords` to a workflow to record where the action happened. Otherwise
|
|
60
|
+
* `{ ok: false, reason }`; render guidance per `reason`. An empty `zones` array
|
|
61
|
+
* means "no geofence" and resolves `ok` with the raw position (use it for a
|
|
62
|
+
* plain location read).
|
|
63
|
+
*
|
|
64
|
+
* ```tsx
|
|
65
|
+
* const r = await requestGeofencedLocation(DEPOTS);
|
|
66
|
+
* if (!r.ok) return showGeofenceError(r.reason);
|
|
67
|
+
* await chamCong();
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export async function requestGeofencedLocation(zones, opts) {
|
|
71
|
+
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
|
72
|
+
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
|
73
|
+
return { ok: false, reason: "unavailable" };
|
|
74
|
+
}
|
|
75
|
+
// Fast-path a hard denial: once blocked, the browser will not re-prompt, so
|
|
76
|
+
// calling getCurrentPosition would just hang until the timeout.
|
|
77
|
+
if ((await readPermissionState()) === "denied") {
|
|
78
|
+
return { ok: false, reason: "denied" };
|
|
79
|
+
}
|
|
80
|
+
let position;
|
|
81
|
+
try {
|
|
82
|
+
position = await getPosition(timeoutMs);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const code = err?.code;
|
|
86
|
+
// 1 = PERMISSION_DENIED; 2 = POSITION_UNAVAILABLE; 3 = TIMEOUT.
|
|
87
|
+
return { ok: false, reason: code === 1 ? "denied" : "unavailable" };
|
|
88
|
+
}
|
|
89
|
+
const coords = {
|
|
90
|
+
latitude: position.coords.latitude,
|
|
91
|
+
longitude: position.coords.longitude,
|
|
92
|
+
accuracy: position.coords.accuracy,
|
|
93
|
+
};
|
|
94
|
+
const inside = zones.length === 0 || zones.some((z) => isWithinZone(coords.latitude, coords.longitude, z));
|
|
95
|
+
return inside ? { ok: true, coords } : { ok: false, reason: "outside" };
|
|
96
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUplo
|
|
|
20
20
|
export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
|
|
21
21
|
export { useComments } from "./comments.js";
|
|
22
22
|
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs } from "./comments.js";
|
|
23
|
+
export { useViewer } from "./viewer.js";
|
|
24
|
+
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
25
|
+
export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
|
|
23
26
|
export { rpc } from "./rpc.js";
|
|
24
27
|
export type { RpcOp } from "./rpc.js";
|
|
25
28
|
export { openExternal } from "./open_external.js";
|
package/dist/src/index.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
|
|
19
19
|
export { useComments } from "./comments.js";
|
|
20
|
+
export { useViewer } from "./viewer.js";
|
|
21
|
+
export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
|
|
20
22
|
export { rpc } from "./rpc.js";
|
|
21
23
|
export { openExternal } from "./open_external.js";
|
|
22
24
|
export { readMembers } from "./members.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
3
|
+
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
4
|
+
* member and the app's declared capabilities.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useAppContext(): {
|
|
7
|
+
memberId: string | null;
|
|
8
|
+
commentsEnabled: boolean;
|
|
9
|
+
resolved: boolean;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The signed-in member currently viewing the app — the **view-as target** under
|
|
13
|
+
* an admin's "View as" (product UI or `lotics app dev --view-as`), the
|
|
14
|
+
* authenticated member otherwise, and `null` for a public/standalone visitor or
|
|
15
|
+
* until the context resolves (gate viewer-dependent UI on `loading`).
|
|
16
|
+
*
|
|
17
|
+
* Use it to personalize (greet the member, default an assignment to them) or to
|
|
18
|
+
* pass the viewer into a workflow that must attribute to them. **Per-row data
|
|
19
|
+
* scoping does NOT need this** — put an `is_current_member` filter in the query
|
|
20
|
+
* template and the server resolves it to the same member (honoring view-as).
|
|
21
|
+
*/
|
|
22
|
+
export declare function useViewer(): {
|
|
23
|
+
memberId: string | null;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import useSWR from "swr";
|
|
2
|
+
import { rpc } from "./rpc.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
5
|
+
* The host (the product iframe, or `lotics app dev`) supplies the signed-in
|
|
6
|
+
* member and the app's declared capabilities.
|
|
7
|
+
*/
|
|
8
|
+
export function useAppContext() {
|
|
9
|
+
const { data } = useSWR("app-context", () => rpc("context", {}), {
|
|
10
|
+
revalidateOnFocus: false,
|
|
11
|
+
revalidateIfStale: false,
|
|
12
|
+
revalidateOnReconnect: false,
|
|
13
|
+
shouldRetryOnError: false,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
memberId: data?.member_id ?? null,
|
|
17
|
+
commentsEnabled: data?.comments_enabled ?? false,
|
|
18
|
+
resolved: data !== undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The signed-in member currently viewing the app — the **view-as target** under
|
|
23
|
+
* an admin's "View as" (product UI or `lotics app dev --view-as`), the
|
|
24
|
+
* authenticated member otherwise, and `null` for a public/standalone visitor or
|
|
25
|
+
* until the context resolves (gate viewer-dependent UI on `loading`).
|
|
26
|
+
*
|
|
27
|
+
* Use it to personalize (greet the member, default an assignment to them) or to
|
|
28
|
+
* pass the viewer into a workflow that must attribute to them. **Per-row data
|
|
29
|
+
* scoping does NOT need this** — put an `is_current_member` filter in the query
|
|
30
|
+
* template and the server resolves it to the same member (honoring view-as).
|
|
31
|
+
*/
|
|
32
|
+
export function useViewer() {
|
|
33
|
+
const ctx = useAppContext();
|
|
34
|
+
return { memberId: ctx.memberId, loading: !ctx.resolved };
|
|
35
|
+
}
|