@lotics/app-sdk 0.28.0 → 0.30.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.
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { AppWorkflows, AppQueries } from "./types.js";
1
+ import type { AppWorkflows, AppWorkflowResults, AppQueries } from "./types.js";
2
2
  import type { ResolvedMember } from "./members.js";
3
3
  /** Fields shared by every query hook's return value. */
4
4
  interface QueryStateBase {
@@ -151,17 +151,21 @@ export interface PaginatedQueryOptions extends BaseQueryOptions {
151
151
  */
152
152
  export declare function useWorkflow<K extends keyof AppWorkflows & string>(alias: K): UseWorkflowFn<K>;
153
153
  export declare function useWorkflow(alias: string): (inputs?: Record<string, unknown>) => Promise<WorkflowResult>;
154
- type UseWorkflowFn<K extends keyof AppWorkflows & string> = AppWorkflows[K] extends Record<string, unknown> ? AppWorkflows[K] extends Record<string, never> ? (inputs?: Record<string, never>) => Promise<WorkflowResult> : (inputs: AppWorkflows[K]) => Promise<WorkflowResult> : (inputs?: Record<string, unknown>) => Promise<WorkflowResult>;
154
+ type ResultDataOf<K extends string> = K extends keyof AppWorkflowResults ? AppWorkflowResults[K] : unknown;
155
+ type UseWorkflowFn<K extends keyof AppWorkflows & string> = AppWorkflows[K] extends Record<string, unknown> ? AppWorkflows[K] extends Record<string, never> ? (inputs?: Record<string, never>) => Promise<WorkflowResult<ResultDataOf<K>>> : (inputs: AppWorkflows[K]) => Promise<WorkflowResult<ResultDataOf<K>>> : (inputs?: Record<string, unknown>) => Promise<WorkflowResult<ResultDataOf<K>>>;
155
156
  /**
156
157
  * Result of an app-workflow run — the execute endpoint's response. `files` holds
157
158
  * any document a workflow step generated (e.g. via a `generate_*_from_template`
158
159
  * tool), resolved for download: read `files[0].url` and pass it to `openExternal`.
159
- * A workflow that generates no file resolves with `files` absent.
160
+ * A workflow that generates no file resolves with `files` absent. `data` is the
161
+ * structured value the workflow returned via `return({ data })`, typed per the
162
+ * alias's declared `outputs` schema (`unknown` when none was declared).
160
163
  */
161
- export interface WorkflowResult {
164
+ export interface WorkflowResult<TData = unknown> {
162
165
  status: "success" | "error";
163
166
  message?: string;
164
167
  files?: UploadedFile[];
168
+ data?: TData;
165
169
  }
166
170
  type QueryArgs<K extends keyof AppQueries & string, O> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: O] : [params: AppQueries[K], opts?: O];
167
171
  /**
@@ -21,6 +21,8 @@ export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions
21
21
  export { useComments } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
24
+ export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
25
+ export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
24
26
  export { rpc } from "./rpc.js";
25
27
  export type { RpcOp } from "./rpc.js";
26
28
  export { openExternal } from "./open_external.js";
package/dist/src/index.js CHANGED
@@ -18,6 +18,7 @@ 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
20
  export { useViewer } from "./viewer.js";
21
+ export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
21
22
  export { rpc } from "./rpc.js";
22
23
  export { openExternal } from "./open_external.js";
23
24
  export { readMembers } from "./members.js";
@@ -23,6 +23,27 @@
23
23
  */
24
24
  export interface AppWorkflows {
25
25
  }
26
+ /**
27
+ * App-specific workflow-RESULT augmentation point. Same pattern as AppWorkflows,
28
+ * but maps each alias to the type of the `data` its workflow returns via
29
+ * `return({ data })` — derived from the alias's declared `outputs` schema.
30
+ *
31
+ * The base SDK ships it empty, so `useWorkflow(alias)` resolves `result.data` as
32
+ * `unknown`. Per-app codegen augments it for aliases that declare `outputs`:
33
+ *
34
+ * ```ts
35
+ * // .lotics/app_workflows.d.ts (generated)
36
+ * declare module "@lotics/app-sdk" {
37
+ * interface AppWorkflowResults {
38
+ * "computeQuote": { total: number; lines: ReadonlyArray<{ name: string; amount: number }> };
39
+ * }
40
+ * }
41
+ * ```
42
+ *
43
+ * An alias absent from this map (no declared `outputs`) gets `result.data: unknown`.
44
+ */
45
+ export interface AppWorkflowResults {
46
+ }
26
47
  /**
27
48
  * App-specific named-query augmentation point. Same pattern as AppWorkflows.
28
49
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {