react-shopwave-connect 0.1.1
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/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/core/index.cjs +426 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +481 -0
- package/dist/core/index.d.ts +481 -0
- package/dist/core/index.js +399 -0
- package/dist/core/index.js.map +1 -0
- package/dist/hooks/index.cjs +783 -0
- package/dist/hooks/index.cjs.map +1 -0
- package/dist/hooks/index.d.cts +424 -0
- package/dist/hooks/index.d.ts +424 -0
- package/dist/hooks/index.js +749 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.cjs +824 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +618 -0
- package/dist/index.d.ts +618 -0
- package/dist/index.js +763 -0
- package/dist/index.js.map +1 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShopwaveLTD
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# react-shopwave-connect
|
|
2
|
+
|
|
3
|
+
Shopwave API client split into two clearly separated layers:
|
|
4
|
+
|
|
5
|
+
- **`core`** — framework-agnostic async API functions + all TypeScript types. No React anywhere. Works in any TS/JS project (Node script, Vue, Angular, CLI, …).
|
|
6
|
+
- **`hooks`** — thin React wrappers around `core`, with a consistent `{ data, loading, error, refetch }` shape. React is a **peer dependency** and is never bundled.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
src/
|
|
10
|
+
core/ index.ts + one file per domain (category, consumer, employee,
|
|
11
|
+
product, store, report, session, entity, basket) — plus the shared
|
|
12
|
+
request helper (request.ts) and API envelope types (types.ts)
|
|
13
|
+
hooks/ index.ts + one wrapper per domain, all importing logic from core
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install react-shopwave-connect
|
|
20
|
+
# React is only needed if you use the hooks layer:
|
|
21
|
+
npm install react react-dom
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Import paths
|
|
25
|
+
|
|
26
|
+
| Import | What you get |
|
|
27
|
+
| ------------------------------------ | --------------------------------------------- |
|
|
28
|
+
| `react-shopwave-connect/core` | Framework-agnostic functions + types only |
|
|
29
|
+
| `react-shopwave-connect/hooks` | React hooks only |
|
|
30
|
+
| `react-shopwave-connect` | Both (re-exports `core` + `hooks`) |
|
|
31
|
+
|
|
32
|
+
Prefer the subpath imports when you want a hard boundary — e.g. a Node service should import from `/core` so React never enters the dependency graph.
|
|
33
|
+
|
|
34
|
+
## Configuration (`RequestOptions`)
|
|
35
|
+
|
|
36
|
+
Every `core` function (and every hook) accepts an optional `options` argument:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
interface RequestOptions {
|
|
40
|
+
baseUrl?: string; // prefix for every path; omit in the browser to use
|
|
41
|
+
// relative URLs like "/api/products"
|
|
42
|
+
token?: string; // forwarded as the `token` request header
|
|
43
|
+
fetch?: typeof fetch; // custom fetch (Node < 18, tests, interceptors)
|
|
44
|
+
signal?: AbortSignal; // cancellation (hooks pass this automatically)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
In the browser / Next.js you can usually omit `options` entirely (relative URLs resolve against the current origin). Outside the browser, pass an absolute `baseUrl` and a `token`.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 1. Using `core` standalone (no React)
|
|
53
|
+
|
|
54
|
+
Plain async functions — usable in a Node script, CLI, Vue, Angular, a server route, etc.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import {
|
|
58
|
+
fetchProducts,
|
|
59
|
+
fetchStores,
|
|
60
|
+
fetchReport,
|
|
61
|
+
type Product,
|
|
62
|
+
} from "react-shopwave-connect/core";
|
|
63
|
+
|
|
64
|
+
const options = {
|
|
65
|
+
baseUrl: "https://api.shopwave.example",
|
|
66
|
+
token: process.env.SHOPWAVE_TOKEN,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const stores = await fetchStores({}, options);
|
|
71
|
+
console.log(`${stores.length} stores`);
|
|
72
|
+
|
|
73
|
+
const products: Product[] = await fetchProducts(
|
|
74
|
+
{ storeId: stores[0].id },
|
|
75
|
+
options
|
|
76
|
+
);
|
|
77
|
+
console.log(products.map((p) => p.name));
|
|
78
|
+
|
|
79
|
+
const reports = await fetchReport(
|
|
80
|
+
{ sales: { FROM: "Basket", WHERE: { AND: ["Basket.status = 'completed'"] } } },
|
|
81
|
+
options
|
|
82
|
+
);
|
|
83
|
+
console.log(reports.sales?.data.length, "rows");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch(console.error);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
On Node < 18 (no global `fetch`), inject one:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { fetchStores } from "react-shopwave-connect/core";
|
|
93
|
+
import fetch from "node-fetch";
|
|
94
|
+
|
|
95
|
+
await fetchStores({}, { baseUrl: "https://api.merchantstack.com", fetch });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Functions **throw** on network failure or API errors (the error message contains the serialized API errors), so wrap calls in `try/catch`.
|
|
99
|
+
|
|
100
|
+
### Available `core` functions
|
|
101
|
+
|
|
102
|
+
| Domain | Function(s) |
|
|
103
|
+
| --------- | ------------------------------------------------------- |
|
|
104
|
+
| category | `fetchCategories` |
|
|
105
|
+
| consumer | `fetchConsumers` |
|
|
106
|
+
| employee | `fetchEmployees` |
|
|
107
|
+
| product | `fetchProducts`, `fetchProductsMap` (batched, keyed) |
|
|
108
|
+
| store | `fetchStores` |
|
|
109
|
+
| report | `fetchReport` |
|
|
110
|
+
| session | `logout` |
|
|
111
|
+
| entity | `deleteEntity`, `submitEntity` |
|
|
112
|
+
| basket | `buildBasketReportQuery`, `parseBasketReportData`, `combineBasketRows`, `computeBasketSummary`, … (pure transforms) |
|
|
113
|
+
|
|
114
|
+
All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `ReportQueryMap`, `Basket*`, `apiResponse`, …) are exported from `core` too.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 2. Using `hooks` in React / Next.js
|
|
119
|
+
|
|
120
|
+
Every auto-fetching hook returns the same shape:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
{ data, loading, error, refetch }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
"use client";
|
|
128
|
+
import { useProduct, useStore } from "react-shopwave-connect/hooks";
|
|
129
|
+
|
|
130
|
+
export function ProductList({ storeId }: { storeId: number }) {
|
|
131
|
+
const { data: products, loading, error, refetch } = useProduct({ storeId });
|
|
132
|
+
|
|
133
|
+
if (loading) return <p>Loading…</p>;
|
|
134
|
+
if (error) return <p>Error: {error}</p>;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
<button onClick={refetch}>Reload</button>
|
|
139
|
+
<ul>{products?.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
In the browser you don't need `baseUrl` — relative `/api/...` URLs work. To target another host or pass a token, give every hook the same optional second argument:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
const { data } = useProduct({ storeId }, { baseUrl: "https://api.merchantstack.com", token });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Auto-fetch hooks (`useEffect`-based)
|
|
152
|
+
|
|
153
|
+
`useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `useStore`, `useReport`.
|
|
154
|
+
They fetch on mount and re-run when their arguments change. `useConsumer` and `useReport` stay idle until you pass ids / a query (they return `loading: false`, `data: null` until then). `refetch()` replaces the old `reloadFlag` argument.
|
|
155
|
+
|
|
156
|
+
### Manually-triggered hooks (`useCallback`-based)
|
|
157
|
+
|
|
158
|
+
`useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error }` — nothing fires until you call `mutate`:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import { useDelete, useSubmit } from "react-shopwave-connect/hooks";
|
|
162
|
+
|
|
163
|
+
const { mutate: remove, loading } = useDelete();
|
|
164
|
+
await remove("products", productId);
|
|
165
|
+
|
|
166
|
+
const { mutate: save } = useSubmit<Product>();
|
|
167
|
+
const saved = await save({
|
|
168
|
+
endpoint: id ? `/api/products/${id}` : "/api/products",
|
|
169
|
+
method: id ? "PUT" : "POST",
|
|
170
|
+
payload,
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
> The original `useHandleDelete` / `useHandleSubmit` also showed notistack toasts and did validation/payload transforms. Those are app concerns and are intentionally left out of the package — drive them from the returned state or the value `mutate` resolves to.
|
|
175
|
+
|
|
176
|
+
### Composite hook
|
|
177
|
+
|
|
178
|
+
`useBasketReport(options?)` orchestrates the report → consumers → products pipeline and returns rows, summary, filters, pagination and a `refresh()`. All of its data-shaping logic lives in `core/basket` (pure, testable functions); the hook is just the React glue.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Auth & Next.js note
|
|
183
|
+
|
|
184
|
+
The original `useLogin` / `useLogout` were tightly coupled to Next.js (`next/navigation` router + `'use server'` actions) and **cannot** be framework-agnostic, so they are **not** shipped here. Instead:
|
|
185
|
+
|
|
186
|
+
- `core.logout()` / `useLogout()` end the server session (`DELETE /api/session?action=logout`) — no redirect.
|
|
187
|
+
- The redirect to the auth server's login/logout URL stays in your app, since it depends on your server actions and router:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
"use client";
|
|
191
|
+
import { useRouter } from "next/navigation";
|
|
192
|
+
import { useLogout } from "react-shopwave-connect/hooks";
|
|
193
|
+
import { getLogoutUrl } from "@/app/actions";
|
|
194
|
+
|
|
195
|
+
export function LogoutButton() {
|
|
196
|
+
const router = useRouter();
|
|
197
|
+
const { mutate: endSession } = useLogout();
|
|
198
|
+
|
|
199
|
+
const onClick = async () => {
|
|
200
|
+
await endSession();
|
|
201
|
+
router.push(await getLogoutUrl(window.location.origin + "/auth"));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return <button onClick={onClick}>Log out</button>;
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Build
|
|
211
|
+
|
|
212
|
+
Built with [`tsdown`](https://tsdown.dev) into dual ESM + CJS with `.d.ts` types, via two separate entries (`core` with no externals, `hooks` with `react`/`react-dom` marked external so they're never bundled).
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npm run build # emit dist/
|
|
216
|
+
npm run typecheck # tsc --noEmit
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Testing
|
|
222
|
+
|
|
223
|
+
Integration tests run against a live API using [Vitest](https://vitest.dev). Tests are located in `tests/integration/` and cover full CRUD lifecycles for Products, Categories, Consumers, and Employees.
|
|
224
|
+
|
|
225
|
+
### Environment Variables
|
|
226
|
+
|
|
227
|
+
| Variable | Description | Example |
|
|
228
|
+
|-------------|--------------------------------------|-------------------------------------------|
|
|
229
|
+
| `API_URL` | Base URL for the API | `https://api.staging.merchantstack.com` |
|
|
230
|
+
| `API_TOKEN` | Authentication token | `your-staging-token` |
|
|
231
|
+
|
|
232
|
+
### Running Tests
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Set environment variables
|
|
236
|
+
export API_URL=https://api.staging.merchantstack.com
|
|
237
|
+
export API_TOKEN=your-staging-token
|
|
238
|
+
|
|
239
|
+
# Run all integration tests
|
|
240
|
+
npm run test:integration
|
|
241
|
+
|
|
242
|
+
# Run tests in watch mode
|
|
243
|
+
npm test
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Test Structure
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
tests/
|
|
250
|
+
integration/
|
|
251
|
+
setup.ts # Shared config and helpers
|
|
252
|
+
products.integration.test.ts # Products CRUD lifecycle
|
|
253
|
+
categories.integration.test.ts # Categories CRUD lifecycle
|
|
254
|
+
consumers.integration.test.ts # Consumers CRUD lifecycle
|
|
255
|
+
employees.integration.test.ts # Employees CRUD lifecycle
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Each test file uses `describe.sequential` to guarantee execution order (create → read → update → delete) with shared state across tests. An `afterAll` cleanup ensures created resources are deleted even if a test fails midway.
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/core/request.ts
|
|
3
|
+
function resolveFetch(options) {
|
|
4
|
+
const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
|
|
5
|
+
if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
|
|
6
|
+
return f;
|
|
7
|
+
}
|
|
8
|
+
function buildExtras(extras, options) {
|
|
9
|
+
const merged = {
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
...extras
|
|
12
|
+
};
|
|
13
|
+
if (options.token != null && merged.token == null) merged.token = options.token;
|
|
14
|
+
return merged;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Returns a JSON string of API errors if the response carries any, otherwise
|
|
18
|
+
* `null`. Mirrors the original `!responseJson.api.message.errors` checks.
|
|
19
|
+
*/
|
|
20
|
+
function getApiErrors(api) {
|
|
21
|
+
const errors = api?.message?.errors;
|
|
22
|
+
if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* GET helper. Sends request metadata via the `extras` header exactly like the
|
|
27
|
+
* original hooks did, parses JSON, and returns the typed payload.
|
|
28
|
+
*/
|
|
29
|
+
async function apiGet(path, extras, options = {}) {
|
|
30
|
+
const doFetch = resolveFetch(options);
|
|
31
|
+
const url = (options.baseUrl ?? "") + path;
|
|
32
|
+
const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
|
|
33
|
+
console.log("[API GET]", {
|
|
34
|
+
url,
|
|
35
|
+
headers
|
|
36
|
+
});
|
|
37
|
+
const response = await doFetch(url, {
|
|
38
|
+
method: "GET",
|
|
39
|
+
headers,
|
|
40
|
+
signal: options.signal
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
|
43
|
+
return await response.json();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* JSON body helper for POST/PUT mutations.
|
|
47
|
+
*/
|
|
48
|
+
async function apiSend(path, method, payload, options = {}) {
|
|
49
|
+
const doFetch = resolveFetch(options);
|
|
50
|
+
const url = (options.baseUrl ?? "") + path;
|
|
51
|
+
const headers = { "Content-Type": "application/json" };
|
|
52
|
+
if (options.token != null) headers.token = options.token;
|
|
53
|
+
console.log(`[API ${method}]`, {
|
|
54
|
+
url,
|
|
55
|
+
headers,
|
|
56
|
+
payload
|
|
57
|
+
});
|
|
58
|
+
const response = await doFetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers,
|
|
61
|
+
body: JSON.stringify(payload),
|
|
62
|
+
signal: options.signal
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) throw new Error(`Failed to ${method.toLowerCase()}: ${response.statusText}`);
|
|
65
|
+
return await response.json();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* DELETE helper.
|
|
69
|
+
*/
|
|
70
|
+
async function apiDelete(path, options = {}) {
|
|
71
|
+
const doFetch = resolveFetch(options);
|
|
72
|
+
const url = (options.baseUrl ?? "") + path;
|
|
73
|
+
const headers = {};
|
|
74
|
+
if (options.token != null) headers.token = options.token;
|
|
75
|
+
console.log("[API DELETE]", {
|
|
76
|
+
url,
|
|
77
|
+
headers
|
|
78
|
+
});
|
|
79
|
+
const response = await doFetch(url, {
|
|
80
|
+
method: "DELETE",
|
|
81
|
+
headers,
|
|
82
|
+
signal: options.signal
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/core/category.ts
|
|
89
|
+
/**
|
|
90
|
+
* Fetches categories. Framework-agnostic: returns the flattened `Category[]`
|
|
91
|
+
* or throws on API errors.
|
|
92
|
+
*/
|
|
93
|
+
async function fetchCategories(params = {}, options = {}) {
|
|
94
|
+
const extras = { deleted: params.deleted ?? false };
|
|
95
|
+
if (params.categoryIds) extras.categoryIds = params.categoryIds;
|
|
96
|
+
if (params.token) extras.token = params.token;
|
|
97
|
+
const json = await apiGet("/api/categories", extras, options);
|
|
98
|
+
const error = getApiErrors(json.api);
|
|
99
|
+
if (error) throw new Error(error);
|
|
100
|
+
return Object.values(json.categories ?? {});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/core/consumer.ts
|
|
105
|
+
/**
|
|
106
|
+
* Fetches consumers by id. Returns an empty array when no ids are supplied
|
|
107
|
+
* (the original hook short-circuited in that case).
|
|
108
|
+
*/
|
|
109
|
+
async function fetchConsumers(consumerIds, options = {}) {
|
|
110
|
+
if (!consumerIds || consumerIds.length === 0) return [];
|
|
111
|
+
const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
|
|
112
|
+
const error = getApiErrors(json.api);
|
|
113
|
+
if (error) throw new Error(error);
|
|
114
|
+
return Object.values(json.consumers ?? {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/core/employee.ts
|
|
119
|
+
const rolesById = {
|
|
120
|
+
"1": "Owner",
|
|
121
|
+
"2": "Manager",
|
|
122
|
+
"3": "Assistant",
|
|
123
|
+
"4": "Guest",
|
|
124
|
+
"5": "Assistant Manager"
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Fetches employees. Returns the flattened `Employee[]`.
|
|
128
|
+
*/
|
|
129
|
+
async function fetchEmployees(params = {}, options = {}) {
|
|
130
|
+
const extras = { deleted: params.deleted ?? false };
|
|
131
|
+
if (params.employeeIds) extras.employeeIds = params.employeeIds;
|
|
132
|
+
const json = await apiGet("/api/employees", extras, options);
|
|
133
|
+
const error = getApiErrors(json.api);
|
|
134
|
+
if (error) throw new Error(error);
|
|
135
|
+
return Object.values(json.employees ?? {});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/core/product.ts
|
|
140
|
+
/**
|
|
141
|
+
* Fetches products. Returns the flattened `Product[]`.
|
|
142
|
+
*/
|
|
143
|
+
async function fetchProducts(params = {}, options = {}) {
|
|
144
|
+
const extras = { deleted: params.deleted ?? false };
|
|
145
|
+
if (params.productIds) extras.productIds = params.productIds;
|
|
146
|
+
if (params.storeId) extras.storeId = params.storeId;
|
|
147
|
+
if (params.token) extras.token = params.token;
|
|
148
|
+
const json = await apiGet("/api/products", extras, options);
|
|
149
|
+
const error = getApiErrors(json.api);
|
|
150
|
+
if (error) throw new Error(error);
|
|
151
|
+
return Object.values(json.products ?? {});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Fetches products by id and returns them keyed by id, transparently batching
|
|
155
|
+
* the request (default 200 ids per call) to stay within API limits. Extracted
|
|
156
|
+
* from the original `useBasketReport` inline batching loop so it can be reused
|
|
157
|
+
* outside React.
|
|
158
|
+
*/
|
|
159
|
+
async function fetchProductsMap(productIds, options = {}, batchSize = 200) {
|
|
160
|
+
const result = {};
|
|
161
|
+
if (!productIds || productIds.length === 0) return result;
|
|
162
|
+
for (let i = 0; i < productIds.length; i += batchSize) {
|
|
163
|
+
const products = await fetchProducts({ productIds: productIds.slice(i, i + batchSize) }, options);
|
|
164
|
+
for (const product of products) result[product.id] = product;
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/core/promotion.ts
|
|
171
|
+
/** Promotion rule identifiers as stored by the Shopwave API. */
|
|
172
|
+
let PromotionRuleSet = /* @__PURE__ */ function(PromotionRuleSet$1) {
|
|
173
|
+
PromotionRuleSet$1["Percentage"] = "|%^/";
|
|
174
|
+
PromotionRuleSet$1["Combo"] = "&%</";
|
|
175
|
+
PromotionRuleSet$1["MealDeal"] = "&=^/";
|
|
176
|
+
return PromotionRuleSet$1;
|
|
177
|
+
}({});
|
|
178
|
+
/**
|
|
179
|
+
* Fetches promotions. Returns the flattened `Promotion[]`.
|
|
180
|
+
*/
|
|
181
|
+
async function fetchPromotions(params = {}, options = {}) {
|
|
182
|
+
const extras = { deleted: params.deleted ?? false };
|
|
183
|
+
if (params.promotionIds) extras.promotionIds = params.promotionIds;
|
|
184
|
+
if (params.token) extras.token = params.token;
|
|
185
|
+
const json = await apiGet("/api/promotions", extras, options);
|
|
186
|
+
const error = getApiErrors(json.api);
|
|
187
|
+
if (error) throw new Error(error);
|
|
188
|
+
return Object.values(json.promotions ?? {});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/core/store.ts
|
|
193
|
+
/**
|
|
194
|
+
* Fetches stores. Returns the flattened `Store[]`.
|
|
195
|
+
*/
|
|
196
|
+
async function fetchStores(params = {}, options = {}) {
|
|
197
|
+
const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
|
|
198
|
+
const error = getApiErrors(json.api);
|
|
199
|
+
if (error) throw new Error(error);
|
|
200
|
+
return Object.values(json.stores ?? {});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/core/report.ts
|
|
205
|
+
/**
|
|
206
|
+
* Runs one or more report queries and returns the `reports` map keyed by query
|
|
207
|
+
* name. The query is JSON-encoded into the `extras.query` header, matching the
|
|
208
|
+
* original endpoint contract.
|
|
209
|
+
*/
|
|
210
|
+
async function fetchReport(query, options = {}) {
|
|
211
|
+
const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
|
|
212
|
+
const error = getApiErrors(json.api);
|
|
213
|
+
if (error) throw new Error(error);
|
|
214
|
+
return json.reports ?? {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/core/session.ts
|
|
219
|
+
/**
|
|
220
|
+
* Ends the current server session.
|
|
221
|
+
*
|
|
222
|
+
* This is the framework-agnostic half of the original `useLogout` hook. The
|
|
223
|
+
* Next.js-specific redirect logic (router.push to the auth server logout URL,
|
|
224
|
+
* built from server actions) is intentionally NOT included here because it is
|
|
225
|
+
* tied to `next/navigation` and `'use server'` actions — see the README.
|
|
226
|
+
*/
|
|
227
|
+
async function logout(options = {}) {
|
|
228
|
+
await apiDelete("/api/session?action=logout", options);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/core/entity.ts
|
|
233
|
+
/**
|
|
234
|
+
* Generic CRUD-style mutations extracted from `useHandleDelete` /
|
|
235
|
+
* `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
|
|
236
|
+
* UI concern is deliberately left to the hooks/host layer so core stays
|
|
237
|
+
* dependency-free.
|
|
238
|
+
*/
|
|
239
|
+
/**
|
|
240
|
+
* Deletes `/api/{resourcePath}/{entityId}`.
|
|
241
|
+
*/
|
|
242
|
+
async function deleteEntity(resourcePath, entityId, options = {}) {
|
|
243
|
+
if (entityId == null || entityId === "") throw new Error("entityId is missing");
|
|
244
|
+
await apiDelete(`/api/${resourcePath}/${entityId}`, options);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Sends a create/update mutation and returns the parsed `result` field
|
|
248
|
+
* (matching the original `result.result` access).
|
|
249
|
+
*/
|
|
250
|
+
async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
|
|
251
|
+
return (await apiSend(endpoint, method, payload, options)).result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/core/basket.ts
|
|
256
|
+
const DEFAULT_BASKET_FILTERS = {
|
|
257
|
+
search: "",
|
|
258
|
+
dateRange: {
|
|
259
|
+
start: null,
|
|
260
|
+
end: null
|
|
261
|
+
},
|
|
262
|
+
consumerId: null,
|
|
263
|
+
status: "all"
|
|
264
|
+
};
|
|
265
|
+
const DEFAULT_PAGINATION = {
|
|
266
|
+
page: 0,
|
|
267
|
+
pageSize: 25,
|
|
268
|
+
total: 0
|
|
269
|
+
};
|
|
270
|
+
/** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
|
|
271
|
+
function formatDateForQuery(date, isEndOfDay = false) {
|
|
272
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
|
|
273
|
+
}
|
|
274
|
+
/** Builds the `Basket` report query from a set of filters. */
|
|
275
|
+
function buildBasketReportQuery(filters) {
|
|
276
|
+
const whereConditions = [];
|
|
277
|
+
const today = /* @__PURE__ */ new Date();
|
|
278
|
+
const startDate = filters.dateRange.start ?? today;
|
|
279
|
+
const endDate = filters.dateRange.end ?? today;
|
|
280
|
+
whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
|
|
281
|
+
whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
|
|
282
|
+
if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
|
|
283
|
+
if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
|
|
284
|
+
return { baskets: {
|
|
285
|
+
FROM: "Basket",
|
|
286
|
+
WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
|
|
287
|
+
} };
|
|
288
|
+
}
|
|
289
|
+
/** Flattens the raw report rows/keys into structured baskets. */
|
|
290
|
+
function parseBasketReportData(reports) {
|
|
291
|
+
if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
|
|
292
|
+
const { keys, data } = reports.baskets;
|
|
293
|
+
const keyIndices = {};
|
|
294
|
+
keys.forEach((key, index) => {
|
|
295
|
+
keyIndices[key.name] = index;
|
|
296
|
+
});
|
|
297
|
+
const productKey = keys.find((k) => k.name === "Basket.Product");
|
|
298
|
+
const productSubKeys = {};
|
|
299
|
+
if (productKey?.item) productKey.item.forEach((subKey, index) => {
|
|
300
|
+
productSubKeys[subKey.name] = index;
|
|
301
|
+
});
|
|
302
|
+
return data.map((row) => {
|
|
303
|
+
const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
|
|
304
|
+
productId: productRow[productSubKeys["Basket.Product.productId"]],
|
|
305
|
+
productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
|
|
306
|
+
quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
|
|
307
|
+
price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
|
|
308
|
+
tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
|
|
309
|
+
}));
|
|
310
|
+
const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
|
|
311
|
+
const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
|
|
312
|
+
const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
|
|
313
|
+
const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
|
|
314
|
+
return {
|
|
315
|
+
id: row[keyIndices["Basket.id"]],
|
|
316
|
+
name: row[keyIndices["Basket.name"]],
|
|
317
|
+
consumerId: row[keyIndices["Basket.consumerId"]],
|
|
318
|
+
products,
|
|
319
|
+
total,
|
|
320
|
+
taxTotal,
|
|
321
|
+
status: "completed",
|
|
322
|
+
completeDate
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** Unique non-null consumer ids referenced by a set of baskets. */
|
|
327
|
+
function collectConsumerIds(baskets) {
|
|
328
|
+
if (!baskets.length) return null;
|
|
329
|
+
const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
|
|
330
|
+
return ids.length > 0 ? [...new Set(ids)] : null;
|
|
331
|
+
}
|
|
332
|
+
/** Unique product ids referenced by a set of baskets. */
|
|
333
|
+
function collectProductIds(baskets) {
|
|
334
|
+
if (!baskets.length) return null;
|
|
335
|
+
const ids = /* @__PURE__ */ new Set();
|
|
336
|
+
baskets.forEach((basket) => {
|
|
337
|
+
basket.products.forEach((product) => {
|
|
338
|
+
if (product.productId) ids.add(product.productId);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
return ids.size > 0 ? [...ids] : null;
|
|
342
|
+
}
|
|
343
|
+
/** Joins baskets with consumer + product lookups, applies search, sorts. */
|
|
344
|
+
function combineBasketRows({ baskets, consumerMap, productMap, search }) {
|
|
345
|
+
if (!baskets.length) return [];
|
|
346
|
+
let combinedRows = baskets.map((basket) => {
|
|
347
|
+
const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
|
|
348
|
+
const productsWithNames = basket.products.map((p) => {
|
|
349
|
+
const product = productMap[p.productId];
|
|
350
|
+
return {
|
|
351
|
+
...p,
|
|
352
|
+
productName: product?.name || `Product #${p.productId}`
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
id: basket.id,
|
|
357
|
+
basketId: basket.id,
|
|
358
|
+
basketName: basket.name || `Basket #${basket.id}`,
|
|
359
|
+
consumerId: basket.consumerId,
|
|
360
|
+
consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
|
|
361
|
+
consumerEmail: consumer?.email || "-",
|
|
362
|
+
products: productsWithNames,
|
|
363
|
+
basketTotal: (basket.total || 0) / 100,
|
|
364
|
+
tax: basket.taxTotal || 0,
|
|
365
|
+
dateTime: basket.completeDate,
|
|
366
|
+
status: basket.status || "pending"
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
if (search) {
|
|
370
|
+
const searchLower = search.toLowerCase();
|
|
371
|
+
combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
|
|
372
|
+
}
|
|
373
|
+
combinedRows.sort((a, b) => {
|
|
374
|
+
const dateA = new Date(a.dateTime).getTime();
|
|
375
|
+
return new Date(b.dateTime).getTime() - dateA;
|
|
376
|
+
});
|
|
377
|
+
return combinedRows;
|
|
378
|
+
}
|
|
379
|
+
/** Aggregate totals for a set of report rows. */
|
|
380
|
+
function computeBasketSummary(rows) {
|
|
381
|
+
const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
|
|
382
|
+
return {
|
|
383
|
+
totalBaskets: rows.length,
|
|
384
|
+
totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
|
|
385
|
+
totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
|
|
386
|
+
uniqueConsumers: uniqueConsumerIds.size
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/** Builds a consumer id → consumer lookup. */
|
|
390
|
+
function buildConsumerMap(consumers) {
|
|
391
|
+
const map = {};
|
|
392
|
+
if (consumers) consumers.forEach((consumer) => {
|
|
393
|
+
map[consumer.id] = consumer;
|
|
394
|
+
});
|
|
395
|
+
return map;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
|
|
400
|
+
exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
|
|
401
|
+
exports.PromotionRuleSet = PromotionRuleSet;
|
|
402
|
+
exports.apiDelete = apiDelete;
|
|
403
|
+
exports.apiGet = apiGet;
|
|
404
|
+
exports.apiSend = apiSend;
|
|
405
|
+
exports.buildBasketReportQuery = buildBasketReportQuery;
|
|
406
|
+
exports.buildConsumerMap = buildConsumerMap;
|
|
407
|
+
exports.collectConsumerIds = collectConsumerIds;
|
|
408
|
+
exports.collectProductIds = collectProductIds;
|
|
409
|
+
exports.combineBasketRows = combineBasketRows;
|
|
410
|
+
exports.computeBasketSummary = computeBasketSummary;
|
|
411
|
+
exports.deleteEntity = deleteEntity;
|
|
412
|
+
exports.fetchCategories = fetchCategories;
|
|
413
|
+
exports.fetchConsumers = fetchConsumers;
|
|
414
|
+
exports.fetchEmployees = fetchEmployees;
|
|
415
|
+
exports.fetchProducts = fetchProducts;
|
|
416
|
+
exports.fetchProductsMap = fetchProductsMap;
|
|
417
|
+
exports.fetchPromotions = fetchPromotions;
|
|
418
|
+
exports.fetchReport = fetchReport;
|
|
419
|
+
exports.fetchStores = fetchStores;
|
|
420
|
+
exports.formatDateForQuery = formatDateForQuery;
|
|
421
|
+
exports.getApiErrors = getApiErrors;
|
|
422
|
+
exports.logout = logout;
|
|
423
|
+
exports.parseBasketReportData = parseBasketReportData;
|
|
424
|
+
exports.rolesById = rolesById;
|
|
425
|
+
exports.submitEntity = submitEntity;
|
|
426
|
+
//# sourceMappingURL=index.cjs.map
|