@laconius/cart 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +24 -0
  3. package/lib/module/adapter.js +167 -0
  4. package/lib/module/adapter.js.map +1 -0
  5. package/lib/module/components.js +212 -0
  6. package/lib/module/components.js.map +1 -0
  7. package/lib/module/config.js +53 -0
  8. package/lib/module/config.js.map +1 -0
  9. package/lib/module/defaults.js +57 -0
  10. package/lib/module/defaults.js.map +1 -0
  11. package/lib/module/index.js +11 -0
  12. package/lib/module/index.js.map +1 -0
  13. package/lib/module/models.js +4 -0
  14. package/lib/module/models.js.map +1 -0
  15. package/lib/module/normalizer.js +36 -0
  16. package/lib/module/normalizer.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/queries.js +287 -0
  19. package/lib/module/queries.js.map +1 -0
  20. package/lib/module/store.js +42 -0
  21. package/lib/module/store.js.map +1 -0
  22. package/lib/module/translations.js +31 -0
  23. package/lib/module/translations.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/adapter.d.ts +25 -0
  26. package/lib/typescript/src/adapter.d.ts.map +1 -0
  27. package/lib/typescript/src/components.d.ts +49 -0
  28. package/lib/typescript/src/components.d.ts.map +1 -0
  29. package/lib/typescript/src/config.d.ts +57 -0
  30. package/lib/typescript/src/config.d.ts.map +1 -0
  31. package/lib/typescript/src/defaults.d.ts +16 -0
  32. package/lib/typescript/src/defaults.d.ts.map +1 -0
  33. package/lib/typescript/src/index.d.ts +10 -0
  34. package/lib/typescript/src/index.d.ts.map +1 -0
  35. package/lib/typescript/src/models.d.ts +122 -0
  36. package/lib/typescript/src/models.d.ts.map +1 -0
  37. package/lib/typescript/src/normalizer.d.ts +24 -0
  38. package/lib/typescript/src/normalizer.d.ts.map +1 -0
  39. package/lib/typescript/src/queries.d.ts +47 -0
  40. package/lib/typescript/src/queries.d.ts.map +1 -0
  41. package/lib/typescript/src/store.d.ts +25 -0
  42. package/lib/typescript/src/store.d.ts.map +1 -0
  43. package/lib/typescript/src/translations.d.ts +29 -0
  44. package/lib/typescript/src/translations.d.ts.map +1 -0
  45. package/package.json +78 -0
  46. package/src/adapter.ts +187 -0
  47. package/src/components.tsx +237 -0
  48. package/src/config.ts +81 -0
  49. package/src/defaults.tsx +53 -0
  50. package/src/index.ts +61 -0
  51. package/src/models.ts +142 -0
  52. package/src/normalizer.ts +39 -0
  53. package/src/queries.ts +288 -0
  54. package/src/store.ts +46 -0
  55. package/src/translations.ts +28 -0
package/src/queries.ts ADDED
@@ -0,0 +1,288 @@
1
+ import {
2
+ LaconiusHttpError,
3
+ getRuntime,
4
+ laconiusQueryKey,
5
+ useIsLoggedIn,
6
+ } from '@laconius/core';
7
+ import {
8
+ queryOptions,
9
+ useMutation,
10
+ useQuery,
11
+ useQueryClient,
12
+ type QueryClient,
13
+ } from '@tanstack/react-query';
14
+
15
+ import { cartAdapter } from './adapter';
16
+ import type {
17
+ AddToCartInput,
18
+ Cart,
19
+ CartMergeResult,
20
+ CartMutationResult,
21
+ OrderEntry,
22
+ } from './models';
23
+ import { getCartId } from './normalizer';
24
+ import { useActiveCartIdStore } from './store';
25
+
26
+ /**
27
+ * `queryOptions` factory, so an app can `prefetchQuery` the cart without forking the library.
28
+ * The key is `['laconius','cart',{ctx},cartId]` — the context object carries the user, which is
29
+ * what keeps an anonymous cart and a user cart apart in the cache.
30
+ */
31
+ export const cartQueries = {
32
+ detail: (cartId: string) =>
33
+ queryOptions<Cart>({
34
+ queryKey: laconiusQueryKey('cart', cartId),
35
+ /** Prices and stock go stale the moment someone else buys ([chapter 04](../../../docs/spec/04-state.md)). */
36
+ staleTime: 0,
37
+ queryFn: async () => {
38
+ const runtime = getRuntime();
39
+ try {
40
+ const cart = await cartAdapter(runtime).load(runtime, cartId);
41
+ // `current` resolves to a real id; keep the store on it so mutations address it.
42
+ const id = getCartId(cart, runtime.auth.getUserId());
43
+ const store = useActiveCartIdStore.getState();
44
+ if (id && id !== store.cartId) store.setCartId(id);
45
+ return cart;
46
+ } catch (error) {
47
+ // A dead persisted id silently resets to an empty cart; retry already skips 4xx.
48
+ if (error instanceof LaconiusHttpError && error.status === 404) {
49
+ useActiveCartIdStore.getState().clearCartId();
50
+ return {};
51
+ }
52
+ throw error;
53
+ }
54
+ },
55
+ }),
56
+ };
57
+
58
+ /**
59
+ * The active cart. Empty (no request) until an add creates one — the anonymous cart is lazy —
60
+ * except for a logged-in user with no stored id, whose latest cart OCC addresses as the literal
61
+ * `current`.
62
+ */
63
+ export function useActiveCart() {
64
+ const storedId = useActiveCartIdStore((state) => state.cartId);
65
+ const loggedIn = useIsLoggedIn();
66
+ const cartId = storedId ?? (loggedIn ? 'current' : undefined);
67
+ return useQuery({
68
+ ...cartQueries.detail(cartId ?? ''),
69
+ enabled: Boolean(cartId),
70
+ });
71
+ }
72
+
73
+ /** Writes the returned cart under the returned id — never the closed-over one. */
74
+ function writeCart(queryClient: QueryClient, result: CartMutationResult): void {
75
+ queryClient.setQueryData(laconiusQueryKey('cart', result.cartId), result.cart);
76
+ }
77
+
78
+ function requireCartId(): string {
79
+ const cartId = useActiveCartIdStore.getState().cartId;
80
+ if (!cartId) {
81
+ throw new Error('[laconius] No active cart. Add an item before mutating entries.');
82
+ }
83
+ return cartId;
84
+ }
85
+
86
+ /**
87
+ * Not optimistic: stock and price come from the server, and rolling back a `lowStock` result is
88
+ * ugly ([chapter 04](../../../docs/spec/04-state.md)). OCC answers the POST with a
89
+ * `CartModification`, so one follow-up read is what lets `setQueryData` replace invalidation.
90
+ */
91
+ export function useAddToCart() {
92
+ const queryClient = useQueryClient();
93
+ return useMutation({
94
+ mutationFn: async ({
95
+ productCode,
96
+ quantity = 1,
97
+ }: AddToCartInput): Promise<CartMutationResult> => {
98
+ const runtime = getRuntime();
99
+ const adapter = cartAdapter(runtime);
100
+ let cartId = useActiveCartIdStore.getState().cartId;
101
+ if (!cartId) {
102
+ // Lazy: created inside the first add, never eagerly at mount.
103
+ const created = await adapter.create(runtime);
104
+ cartId = getCartId(created, runtime.auth.getUserId());
105
+ useActiveCartIdStore.getState().setCartId(cartId);
106
+ }
107
+ const modification = await adapter.addEntry(runtime, cartId, productCode, quantity);
108
+ const cart = await adapter.load(runtime, cartId);
109
+ return { cart, cartId, modification };
110
+ },
111
+ onSuccess: (result) => writeCart(queryClient, result),
112
+ });
113
+ }
114
+
115
+ type EntrySnapshot = { previous?: Cart; queryKey?: unknown[] };
116
+
117
+ async function snapshotEntries(
118
+ queryClient: QueryClient,
119
+ mutate: (entries: OrderEntry[]) => OrderEntry[],
120
+ ): Promise<EntrySnapshot> {
121
+ const cartId = useActiveCartIdStore.getState().cartId;
122
+ if (!cartId) return {};
123
+ const queryKey = laconiusQueryKey('cart', cartId);
124
+ await queryClient.cancelQueries({ queryKey });
125
+ const previous = queryClient.getQueryData<Cart>(queryKey);
126
+ if (previous?.entries) {
127
+ queryClient.setQueryData<Cart>(queryKey, {
128
+ ...previous,
129
+ entries: mutate(previous.entries),
130
+ });
131
+ }
132
+ return { previous, queryKey };
133
+ }
134
+
135
+ function rollback(queryClient: QueryClient, snapshot: EntrySnapshot | undefined): void {
136
+ if (snapshot?.previous && snapshot.queryKey) {
137
+ queryClient.setQueryData(snapshot.queryKey, snapshot.previous);
138
+ }
139
+ }
140
+
141
+ /** Optimistic: the quantity flips immediately, the totals arrive with the response. */
142
+ export function useUpdateCartEntry() {
143
+ const queryClient = useQueryClient();
144
+ return useMutation({
145
+ mutationFn: async ({
146
+ entryNumber,
147
+ quantity,
148
+ }: {
149
+ entryNumber: number;
150
+ quantity: number;
151
+ }): Promise<CartMutationResult> => {
152
+ const runtime = getRuntime();
153
+ const adapter = cartAdapter(runtime);
154
+ const cartId = requireCartId();
155
+ const modification = await adapter.updateEntry(runtime, cartId, entryNumber, quantity);
156
+ const cart = await adapter.load(runtime, cartId);
157
+ return { cart, cartId, modification };
158
+ },
159
+ onMutate: ({ entryNumber, quantity }) =>
160
+ snapshotEntries(queryClient, (entries) =>
161
+ entries.map((entry) =>
162
+ entry.entryNumber === entryNumber ? { ...entry, quantity } : entry,
163
+ ),
164
+ ),
165
+ onError: (_error, _input, snapshot) => rollback(queryClient, snapshot),
166
+ onSuccess: (result) => writeCart(queryClient, result),
167
+ });
168
+ }
169
+
170
+ /** Optimistic: the row disappears immediately, the totals arrive with the response. */
171
+ export function useRemoveCartEntry() {
172
+ const queryClient = useQueryClient();
173
+ return useMutation({
174
+ mutationFn: async ({
175
+ entryNumber,
176
+ }: {
177
+ entryNumber: number;
178
+ }): Promise<CartMutationResult> => {
179
+ const runtime = getRuntime();
180
+ const adapter = cartAdapter(runtime);
181
+ const cartId = requireCartId();
182
+ await adapter.removeEntry(runtime, cartId, entryNumber);
183
+ const cart = await adapter.load(runtime, cartId);
184
+ return { cart, cartId };
185
+ },
186
+ onMutate: ({ entryNumber }) =>
187
+ snapshotEntries(queryClient, (entries) =>
188
+ entries.filter((entry) => entry.entryNumber !== entryNumber),
189
+ ),
190
+ onError: (_error, _input, snapshot) => rollback(queryClient, snapshot),
191
+ onSuccess: (result) => writeCart(queryClient, result),
192
+ });
193
+ }
194
+
195
+ export function useApplyVoucher() {
196
+ const queryClient = useQueryClient();
197
+ return useMutation({
198
+ mutationFn: async (voucherId: string): Promise<CartMutationResult> => {
199
+ const runtime = getRuntime();
200
+ const adapter = cartAdapter(runtime);
201
+ const cartId = requireCartId();
202
+ await adapter.applyVoucher(runtime, cartId, voucherId);
203
+ const cart = await adapter.load(runtime, cartId);
204
+ return { cart, cartId };
205
+ },
206
+ onSuccess: (result) => writeCart(queryClient, result),
207
+ });
208
+ }
209
+
210
+ export function useRemoveVoucher() {
211
+ const queryClient = useQueryClient();
212
+ return useMutation({
213
+ mutationFn: async (voucherId: string): Promise<CartMutationResult> => {
214
+ const runtime = getRuntime();
215
+ const adapter = cartAdapter(runtime);
216
+ const cartId = requireCartId();
217
+ await adapter.removeVoucher(runtime, cartId, voucherId);
218
+ const cart = await adapter.load(runtime, cartId);
219
+ return { cart, cartId };
220
+ },
221
+ onSuccess: (result) => writeCart(queryClient, result),
222
+ });
223
+ }
224
+
225
+ /**
226
+ * The anonymous cart cannot be fetched once the `Authorization` header exists, so the entries
227
+ * come from the cache snapshot — the "read before starting the flow" of
228
+ * [chapter 05](../../../docs/spec/05-auth-session.md). The query key's context object carries
229
+ * the user, which is what keeps that snapshot addressable after login.
230
+ */
231
+ function findAnonymousCart(queryClient: QueryClient): Cart | undefined {
232
+ for (const [key, data] of queryClient.getQueriesData<Cart>({
233
+ queryKey: ['laconius', 'cart'],
234
+ })) {
235
+ const context = key[2] as { user?: string } | undefined;
236
+ if (context?.user === 'anonymous' && data?.entries?.length) return data;
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ /**
242
+ * Persist-then-replay, never the native merge — the backend rejects a native merge after a
243
+ * redirect login, and every React Native login is one
244
+ * ([chapter 05](../../../docs/spec/05-auth-session.md)). Call it after a successful login;
245
+ * `useLogin()` takes an `onLoggedIn` callback and the template joins them.
246
+ */
247
+ export function useMergeAnonymousCart() {
248
+ const queryClient = useQueryClient();
249
+ return useMutation({
250
+ mutationFn: async (): Promise<CartMergeResult> => {
251
+ const runtime = getRuntime();
252
+ const adapter = cartAdapter(runtime);
253
+ const entries = findAnonymousCart(queryClient)?.entries ?? [];
254
+
255
+ // The anonymous id belonged to the previous session.
256
+ const store = useActiveCartIdStore.getState();
257
+ store.clearCartId();
258
+ if (entries.length === 0) return { replayed: [], failed: [] };
259
+
260
+ let cart: Cart;
261
+ try {
262
+ cart = await adapter.load(runtime, 'current');
263
+ } catch {
264
+ cart = await adapter.create(runtime);
265
+ }
266
+ const cartId = getCartId(cart, runtime.auth.getUserId());
267
+ store.setCartId(cartId);
268
+
269
+ const replayed: OrderEntry[] = [];
270
+ const failed: CartMergeResult['failed'] = [];
271
+ for (const entry of entries) {
272
+ const productCode = entry.product?.code;
273
+ if (!productCode) continue;
274
+ try {
275
+ await adapter.addEntry(runtime, cartId, productCode, entry.quantity ?? 1);
276
+ replayed.push(entry);
277
+ } catch (error) {
278
+ failed.push({ entry, error });
279
+ }
280
+ }
281
+ return { replayed, failed };
282
+ },
283
+ // Login invalidates everything: customer-group pricing depends on the user.
284
+ onSuccess: () => {
285
+ void queryClient.invalidateQueries({ queryKey: ['laconius'] });
286
+ },
287
+ });
288
+ }
package/src/store.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { useSessionStore } from '@laconius/core';
2
+ import AsyncStorage from 'expo-sqlite/kv-store';
3
+ import { create } from 'zustand';
4
+ import { createJSONStorage, persist } from 'zustand/middleware';
5
+
6
+ export type ActiveCartState = {
7
+ /** Cart `code` for a logged-in user, `guid` for an anonymous one. */
8
+ cartId?: string;
9
+ setCartId: (cartId: string | undefined) => void;
10
+ clearCartId: () => void;
11
+ };
12
+
13
+ /**
14
+ * The only client-side cart state: the entity itself is server state in TanStack Query
15
+ * ([chapter 04](../../../docs/spec/04-state.md)). Persisted, so the id survives a restart and
16
+ * OCC reprices; a dead id resets itself through the 404 path in the query.
17
+ */
18
+ export const useActiveCartIdStore = create<ActiveCartState>()(
19
+ persist(
20
+ (set) => ({
21
+ setCartId: (cartId) => set({ cartId }),
22
+ clearCartId: () => set({ cartId: undefined }),
23
+ }),
24
+ {
25
+ name: 'laconius.activeCartId',
26
+ storage: createJSONStorage(() => AsyncStorage),
27
+ partialize: (state) => ({ cartId: state.cartId }),
28
+ },
29
+ ),
30
+ );
31
+
32
+ /**
33
+ * Logout drops the id — it belonged to the previous session ([chapter 04](../../../docs/spec/04-state.md)).
34
+ * Core cannot do it (no `core -> cart` edge exists), so the store watches the session: an access
35
+ * token disappearing is a session ending, whoever caused it. Login is the other direction and is
36
+ * handled by `useMergeAnonymousCart`.
37
+ *
38
+ * This runs at import time, which is why `package.json` lists this module under `sideEffects`
39
+ * rather than declaring the package side-effect-free: without that entry a bundler may drop the
40
+ * module when a consumer imports nothing from it, and the id would outlive the session.
41
+ */
42
+ useSessionStore.subscribe((state, previous) => {
43
+ if (previous.accessToken && !state.accessToken) {
44
+ useActiveCartIdStore.getState().clearCartId();
45
+ }
46
+ });
@@ -0,0 +1,28 @@
1
+ /** English strings for the defaults in this package. Other languages are the app's. */
2
+ export const defaultCartTranslations = {
3
+ en: {
4
+ cart: {
5
+ title: 'Cart',
6
+ addToCart: 'Add to cart',
7
+ itemAdded: 'Added to cart.',
8
+ itemRemoved: 'Removed from cart.',
9
+ quantityUpdated: 'Quantity updated.',
10
+ remove: 'Remove',
11
+ removeItem: 'Remove {{name}} from cart',
12
+ empty: 'Your cart is empty.',
13
+ entryCount_one: '{{count}} item',
14
+ entryCount_other: '{{count}} items',
15
+ subtotal: 'Subtotal',
16
+ discounts: 'Discounts',
17
+ delivery: 'Delivery',
18
+ tax: 'Tax',
19
+ total: 'Total',
20
+ voucher: {
21
+ placeholder: 'Voucher code',
22
+ apply: 'Apply',
23
+ applied: 'Applied vouchers',
24
+ remove: 'Remove voucher {{code}}',
25
+ },
26
+ },
27
+ },
28
+ };