@laconius/core 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 (90) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +31 -0
  3. package/lib/module/auth.js +182 -0
  4. package/lib/module/auth.js.map +1 -0
  5. package/lib/module/client.js +99 -0
  6. package/lib/module/client.js.map +1 -0
  7. package/lib/module/config.js +72 -0
  8. package/lib/module/config.js.map +1 -0
  9. package/lib/module/converters.js +31 -0
  10. package/lib/module/converters.js.map +1 -0
  11. package/lib/module/create-config.js +36 -0
  12. package/lib/module/create-config.js.map +1 -0
  13. package/lib/module/endpoints.js +89 -0
  14. package/lib/module/endpoints.js.map +1 -0
  15. package/lib/module/errors.js +76 -0
  16. package/lib/module/errors.js.map +1 -0
  17. package/lib/module/i18n.js +84 -0
  18. package/lib/module/i18n.js.map +1 -0
  19. package/lib/module/index.js +18 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/media.js +182 -0
  22. package/lib/module/media.js.map +1 -0
  23. package/lib/module/models.js +2 -0
  24. package/lib/module/models.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/provider.js +182 -0
  27. package/lib/module/provider.js.map +1 -0
  28. package/lib/module/runtime.js +43 -0
  29. package/lib/module/runtime.js.map +1 -0
  30. package/lib/module/single-flight.js +23 -0
  31. package/lib/module/single-flight.js.map +1 -0
  32. package/lib/module/site-context.js +74 -0
  33. package/lib/module/site-context.js.map +1 -0
  34. package/lib/module/stores.js +66 -0
  35. package/lib/module/stores.js.map +1 -0
  36. package/lib/module/translations.js +42 -0
  37. package/lib/module/translations.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/auth.d.ts +31 -0
  40. package/lib/typescript/src/auth.d.ts.map +1 -0
  41. package/lib/typescript/src/client.d.ts +31 -0
  42. package/lib/typescript/src/client.d.ts.map +1 -0
  43. package/lib/typescript/src/config.d.ts +150 -0
  44. package/lib/typescript/src/config.d.ts.map +1 -0
  45. package/lib/typescript/src/converters.d.ts +17 -0
  46. package/lib/typescript/src/converters.d.ts.map +1 -0
  47. package/lib/typescript/src/create-config.d.ts +11 -0
  48. package/lib/typescript/src/create-config.d.ts.map +1 -0
  49. package/lib/typescript/src/endpoints.d.ts +36 -0
  50. package/lib/typescript/src/endpoints.d.ts.map +1 -0
  51. package/lib/typescript/src/errors.d.ts +43 -0
  52. package/lib/typescript/src/errors.d.ts.map +1 -0
  53. package/lib/typescript/src/i18n.d.ts +20 -0
  54. package/lib/typescript/src/i18n.d.ts.map +1 -0
  55. package/lib/typescript/src/index.d.ts +17 -0
  56. package/lib/typescript/src/index.d.ts.map +1 -0
  57. package/lib/typescript/src/media.d.ts +81 -0
  58. package/lib/typescript/src/media.d.ts.map +1 -0
  59. package/lib/typescript/src/models.d.ts +327 -0
  60. package/lib/typescript/src/models.d.ts.map +1 -0
  61. package/lib/typescript/src/provider.d.ts +37 -0
  62. package/lib/typescript/src/provider.d.ts.map +1 -0
  63. package/lib/typescript/src/runtime.d.ts +26 -0
  64. package/lib/typescript/src/runtime.d.ts.map +1 -0
  65. package/lib/typescript/src/single-flight.d.ts +7 -0
  66. package/lib/typescript/src/single-flight.d.ts.map +1 -0
  67. package/lib/typescript/src/site-context.d.ts +28 -0
  68. package/lib/typescript/src/site-context.d.ts.map +1 -0
  69. package/lib/typescript/src/stores.d.ts +54 -0
  70. package/lib/typescript/src/stores.d.ts.map +1 -0
  71. package/lib/typescript/src/translations.d.ts +40 -0
  72. package/lib/typescript/src/translations.d.ts.map +1 -0
  73. package/package.json +77 -0
  74. package/src/auth.ts +223 -0
  75. package/src/client.ts +133 -0
  76. package/src/config.ts +191 -0
  77. package/src/converters.ts +47 -0
  78. package/src/create-config.ts +29 -0
  79. package/src/endpoints.ts +133 -0
  80. package/src/errors.ts +104 -0
  81. package/src/i18n.ts +108 -0
  82. package/src/index.ts +123 -0
  83. package/src/media.ts +209 -0
  84. package/src/models.ts +384 -0
  85. package/src/provider.tsx +229 -0
  86. package/src/runtime.ts +57 -0
  87. package/src/single-flight.ts +23 -0
  88. package/src/site-context.ts +77 -0
  89. package/src/stores.ts +94 -0
  90. package/src/translations.ts +40 -0
package/src/models.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Shared domain model types. Types only: zero runtime, no converters, no adapters.
3
+ *
4
+ * Ported from Spartacus `core-libs/core/src/model/{image,product,misc,address,consent}.model.ts`
5
+ * (Apache-2.0, see NOTICE). Two deliberate divergences:
6
+ * - `enum` becomes a string union, so this module emits nothing at runtime.
7
+ * - Date-typed fields become `string`: OCC returns ISO strings and never `Date`.
8
+ */
9
+
10
+ export type ImageType = 'PRIMARY' | 'GALLERY';
11
+
12
+ export interface Image {
13
+ altText?: string;
14
+ role?: string;
15
+ format?: string;
16
+ galleryIndex?: number;
17
+ imageType?: ImageType;
18
+ url?: string;
19
+ width?: number;
20
+ height?: number;
21
+ }
22
+
23
+ /** One format code (`thumbnail`, `zoom`, …) per entry. */
24
+ export interface ImageGroup {
25
+ [format: string]: Image;
26
+ }
27
+
28
+ export interface Images {
29
+ [imageType: string]: ImageGroup | ImageGroup[];
30
+ }
31
+
32
+ export type PriceType = 'BUY' | 'FROM';
33
+
34
+ export interface Price {
35
+ currencyIso?: string;
36
+ formattedValue?: string;
37
+ maxQuantity?: number;
38
+ minQuantity?: number;
39
+ priceType?: PriceType;
40
+ value?: number;
41
+ }
42
+
43
+ export interface PriceRange {
44
+ maxPrice?: Price;
45
+ minPrice?: Price;
46
+ }
47
+
48
+ export interface Stock {
49
+ isValueRounded?: boolean;
50
+ stockLevel?: number;
51
+ stockLevelStatus?: string;
52
+ }
53
+
54
+ export interface Currency {
55
+ active?: boolean;
56
+ isocode?: string;
57
+ name?: string;
58
+ symbol?: string;
59
+ }
60
+
61
+ export interface Language {
62
+ active?: boolean;
63
+ isocode?: string;
64
+ name?: string;
65
+ nativeName?: string;
66
+ }
67
+
68
+ export interface Country {
69
+ isocode?: string;
70
+ name?: string;
71
+ }
72
+
73
+ export type CountryType = 'BILLING' | 'SHIPPING';
74
+
75
+ export interface Region {
76
+ countryIso?: string;
77
+ isocode?: string;
78
+ isocodeShort?: string;
79
+ name?: string;
80
+ }
81
+
82
+ export interface City {
83
+ regionIso?: string;
84
+ isocode?: string;
85
+ name?: string;
86
+ }
87
+
88
+ export interface CityDistrict {
89
+ cityIso?: string;
90
+ isocode?: string;
91
+ name?: string;
92
+ }
93
+
94
+ export interface Title {
95
+ code?: string;
96
+ name?: string;
97
+ }
98
+
99
+ export interface Address {
100
+ id?: string;
101
+ title?: string;
102
+ titleCode?: string;
103
+ email?: string;
104
+ firstName?: string;
105
+ lastName?: string;
106
+ companyName?: string;
107
+ line1?: string;
108
+ line2?: string;
109
+ postalCode?: string;
110
+ town?: string;
111
+ city?: City;
112
+ region?: Region;
113
+ district?: string;
114
+ cityDistrict?: CityDistrict;
115
+ country?: Country;
116
+ cellphone?: string;
117
+ defaultAddress?: boolean;
118
+ shippingAddress?: boolean;
119
+ formattedAddress?: string;
120
+ phone?: string;
121
+ visibleInAddressBook?: boolean;
122
+ }
123
+
124
+ export interface User {
125
+ currency?: Currency;
126
+ customerId?: string;
127
+ deactivationDate?: string;
128
+ defaultAddress?: Address;
129
+ displayUid?: string;
130
+ firstName?: string;
131
+ language?: Language;
132
+ lastName?: string;
133
+ name?: string;
134
+ title?: string;
135
+ titleCode?: string;
136
+ uid?: string;
137
+ roles?: string[];
138
+ lastCartId?: string;
139
+ hasOrder?: boolean;
140
+ }
141
+
142
+ export interface Principal {
143
+ name?: string;
144
+ uid?: string;
145
+ }
146
+
147
+ export interface CaptchaConfig {
148
+ enabled: boolean;
149
+ publicKey?: string;
150
+ }
151
+
152
+ export interface BaseStore {
153
+ currencies?: Currency[];
154
+ defaultCurrency?: Currency;
155
+ languages?: Language[];
156
+ defaultLanguage?: Language;
157
+ paymentProvider?: string;
158
+ }
159
+
160
+ export interface BaseSite {
161
+ channel?: string;
162
+ defaultLanguage?: Language;
163
+ locale?: string;
164
+ name?: string;
165
+ theme?: string;
166
+ uid?: string;
167
+ stores?: BaseStore[];
168
+ urlPatterns?: string[];
169
+ urlEncodingAttributes?: string[];
170
+ baseStore?: BaseStore;
171
+ requiresAuthentication?: boolean;
172
+ isolated?: boolean;
173
+ captchaConfig?: CaptchaConfig;
174
+ }
175
+
176
+ export interface PaginationModel {
177
+ currentPage?: number;
178
+ pageSize?: number;
179
+ sort?: string;
180
+ totalPages?: number;
181
+ totalResults?: number;
182
+ }
183
+
184
+ export interface SortModel {
185
+ code?: string;
186
+ name?: string;
187
+ selected?: boolean;
188
+ }
189
+
190
+ export interface Category {
191
+ code?: string;
192
+ name?: string;
193
+ image?: Image;
194
+ url?: string;
195
+ }
196
+
197
+ export interface FeatureUnit {
198
+ name?: string;
199
+ symbol?: string;
200
+ unitType?: string;
201
+ }
202
+
203
+ export interface FeatureValue {
204
+ value?: string;
205
+ }
206
+
207
+ export interface Feature {
208
+ code?: string;
209
+ comparable?: boolean;
210
+ description?: string;
211
+ featureUnit?: FeatureUnit;
212
+ featureValues?: FeatureValue[];
213
+ name?: string;
214
+ range?: boolean;
215
+ type?: string;
216
+ }
217
+
218
+ export interface Classification {
219
+ code?: string;
220
+ features?: Feature[];
221
+ name?: string;
222
+ }
223
+
224
+ export interface PromotionRestriction {
225
+ description?: string;
226
+ restrictionType?: string;
227
+ }
228
+
229
+ export interface Promotion {
230
+ code?: string;
231
+ couldFireMessages?: string[];
232
+ description?: string;
233
+ enabled?: boolean;
234
+ endDate?: string;
235
+ firedMessages?: string[];
236
+ priority?: number;
237
+ productBanner?: Image;
238
+ promotionGroup?: string;
239
+ promotionType?: string;
240
+ restrictions?: PromotionRestriction[];
241
+ startDate?: string;
242
+ title?: string;
243
+ }
244
+
245
+ export type VariantType =
246
+ | 'ApparelSizeVariantProduct'
247
+ | 'ApparelStyleVariantProduct'
248
+ | 'ElectronicsColorVariantProduct';
249
+
250
+ export type VariantQualifier =
251
+ | 'size'
252
+ | 'style'
253
+ | 'color'
254
+ | 'thumbnail'
255
+ | 'product'
256
+ | 'styleSwatch'
257
+ | 'rollupProperty';
258
+
259
+ export interface VariantOptionQualifier {
260
+ image?: Image;
261
+ name?: string;
262
+ qualifier?: VariantQualifier;
263
+ value?: string;
264
+ }
265
+
266
+ export interface VariantOption {
267
+ code?: string;
268
+ priceData?: Price;
269
+ stock?: Stock;
270
+ url?: string;
271
+ variantOptionQualifiers?: VariantOptionQualifier[];
272
+ }
273
+
274
+ export interface VariantCategory {
275
+ hasImage?: boolean;
276
+ name?: string;
277
+ priority?: number;
278
+ }
279
+
280
+ export interface VariantValueCategory {
281
+ name?: string;
282
+ sequence?: number;
283
+ superCategories?: VariantCategory[];
284
+ }
285
+
286
+ export interface VariantMatrixElement {
287
+ elements?: VariantMatrixElement[];
288
+ isLeaf?: boolean;
289
+ parentVariantCategory?: VariantCategory;
290
+ variantOption?: VariantOption;
291
+ variantValueCategory?: VariantValueCategory;
292
+ }
293
+
294
+ export interface BaseOption {
295
+ options?: VariantOption[];
296
+ selected?: VariantOption;
297
+ variantType?: VariantType;
298
+ }
299
+
300
+ export interface Review {
301
+ alias?: string;
302
+ comment?: string;
303
+ date?: string;
304
+ headline?: string;
305
+ id?: string;
306
+ principal?: User;
307
+ rating?: number;
308
+ }
309
+
310
+ export interface ProductReference {
311
+ description?: string;
312
+ preselected?: boolean;
313
+ quantity?: number;
314
+ referenceType?: string;
315
+ target?: Product;
316
+ }
317
+
318
+ export interface ProductReferences {
319
+ [referenceType: string]: ProductReference[];
320
+ }
321
+
322
+ export interface FutureStock {
323
+ date?: string;
324
+ formattedDate?: string;
325
+ stock?: Stock;
326
+ }
327
+
328
+ /** Unit of measure used for product stock availability (`productAvailabilities`). */
329
+ export interface SapUnit {
330
+ code?: string;
331
+ name?: string;
332
+ availabilityCode?: string;
333
+ sapCode?: string;
334
+ }
335
+
336
+ export type ProductType = 'PHYSICAL' | 'SERVICE' | 'SUBSCRIPTION';
337
+
338
+ export interface Product {
339
+ availableForPickup?: boolean;
340
+ averageRating?: number;
341
+ baseOptions?: BaseOption[];
342
+ baseProduct?: string;
343
+ categories?: Category[];
344
+ classifications?: Classification[];
345
+ code?: string;
346
+ description?: string;
347
+ futureStocks?: FutureStock[];
348
+ images?: Images;
349
+ manufacturer?: string;
350
+ multidimensional?: boolean;
351
+ name?: string;
352
+ nameHtml?: string;
353
+ numberOfReviews?: number;
354
+ potentialPromotions?: Promotion[];
355
+ price?: Price;
356
+ priceRange?: PriceRange;
357
+ productReferences?: ProductReferences;
358
+ purchasable?: boolean;
359
+ reviews?: Review[];
360
+ sapUnit?: SapUnit;
361
+ stock?: Stock;
362
+ summary?: string;
363
+ url?: string;
364
+ variantMatrix?: VariantMatrixElement[];
365
+ variantOptions?: VariantOption[];
366
+ variantType?: VariantType;
367
+ volumePrices?: Price[];
368
+ volumePricesFlag?: boolean;
369
+ productTypes?: ProductType;
370
+ }
371
+
372
+ export interface Consent {
373
+ code?: string;
374
+ consentGivenDate?: string;
375
+ consentWithdrawnDate?: string;
376
+ }
377
+
378
+ export interface ConsentTemplate {
379
+ id?: string;
380
+ name?: string;
381
+ description?: string;
382
+ version?: number;
383
+ currentConsent?: Consent;
384
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ QueryClient,
3
+ QueryClientProvider,
4
+ focusManager,
5
+ onlineManager,
6
+ useQueryClient,
7
+ } from '@tanstack/react-query';
8
+ import * as Network from 'expo-network';
9
+ import {
10
+ createContext,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useState,
15
+ useSyncExternalStore,
16
+ type ReactNode,
17
+ } from 'react';
18
+ import { AppState, type AppStateStatus } from 'react-native';
19
+
20
+ import { createAuthClient } from './auth';
21
+ import { createHttpClient } from './client';
22
+ import type { LaconiusConfig } from './config';
23
+ import { createConverterRegistry } from './converters';
24
+ import { LaconiusHttpError } from './errors';
25
+ import { createTranslator, type Translator } from './i18n';
26
+ import { setActiveRuntime, type LaconiusRuntime } from './runtime';
27
+ import {
28
+ resolveCurrency,
29
+ resolveLanguage,
30
+ useSessionStore,
31
+ useSiteContextStore,
32
+ } from './stores';
33
+
34
+ const LaconiusContext = createContext<LaconiusRuntime | undefined>(undefined);
35
+
36
+ /**
37
+ * Fails loud at mount: without a backend the app does not exist, and failing at request forty
38
+ * only hides the cause. Individual unconfigured endpoints degrade through `isConfigured()`.
39
+ */
40
+ function assertValidConfig(config: LaconiusConfig): void {
41
+ const missing: string[] = [];
42
+ if (!config.backend?.occ?.baseUrl) missing.push('backend.occ.baseUrl');
43
+ if (!config.backend?.occ?.baseSite) missing.push('backend.occ.baseSite');
44
+ if (config.auth) {
45
+ for (const key of ['clientId', 'redirectUri', 'authServerUrl'] as const) {
46
+ if (!config.auth[key]) missing.push(`auth.${key}`);
47
+ }
48
+ }
49
+ if (missing.length > 0) {
50
+ throw new Error(
51
+ `[laconius] Incomplete configuration: ${missing.join(', ')} ${
52
+ missing.length === 1 ? 'is' : 'are'
53
+ } required.`,
54
+ );
55
+ }
56
+ }
57
+
58
+ /** Retry policy of chapter 09, overridable through `queryClient.setQueryDefaults`. */
59
+ export function createDefaultQueryClient(): QueryClient {
60
+ return new QueryClient({
61
+ defaultOptions: {
62
+ queries: {
63
+ staleTime: 5 * 60_000,
64
+ gcTime: 30 * 60_000,
65
+ retry: (failureCount, error) => {
66
+ if (error instanceof LaconiusHttpError) {
67
+ // 401 is not retried here: the client already refreshes once and retries once.
68
+ if (error.status >= 400 && error.status < 500) return false;
69
+ }
70
+ return failureCount < 2;
71
+ },
72
+ retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
73
+ },
74
+ // A retried `POST /entries` can double an order line.
75
+ mutations: { retry: false },
76
+ },
77
+ });
78
+ }
79
+
80
+ export type LaconiusProviderProps = {
81
+ config: LaconiusConfig;
82
+ children: ReactNode;
83
+ /** Rendered while the stored session is exchanged. Defaults to nothing. */
84
+ fallback?: ReactNode;
85
+ };
86
+
87
+ export function LaconiusProvider({
88
+ config,
89
+ children,
90
+ fallback = null,
91
+ }: LaconiusProviderProps) {
92
+ const runtime = useMemo<LaconiusRuntime>(() => {
93
+ assertValidConfig(config);
94
+ const auth = createAuthClient(config);
95
+ const runtime: LaconiusRuntime = {
96
+ config,
97
+ auth,
98
+ client: createHttpClient(config, auth),
99
+ converters: createConverterRegistry(config),
100
+ };
101
+ setActiveRuntime(runtime);
102
+ return runtime;
103
+ }, [config]);
104
+
105
+ useEffect(() => () => setActiveRuntime(undefined), []);
106
+
107
+ const queryClient = useMemo(
108
+ () => config.query?.queryClient ?? createDefaultQueryClient(),
109
+ [config.query?.queryClient],
110
+ );
111
+
112
+ useEffect(() => {
113
+ if (config.query?.wireOnlineManager === false) return;
114
+ return onlineManager.setEventListener((setOnline) => {
115
+ const subscription = Network.addNetworkStateListener((state) => {
116
+ setOnline(state.isInternetReachable ?? state.isConnected ?? true);
117
+ });
118
+ return () => subscription.remove();
119
+ });
120
+ }, [config.query?.wireOnlineManager]);
121
+
122
+ useEffect(() => {
123
+ if (config.query?.wireFocusManager === false) return;
124
+ const subscription = AppState.addEventListener(
125
+ 'change',
126
+ (status: AppStateStatus) => focusManager.setFocused(status === 'active'),
127
+ );
128
+ return () => subscription.remove();
129
+ }, [config.query?.wireFocusManager]);
130
+
131
+ // Cold start: exchange the stored refresh token once, before the first authenticated call.
132
+ const [hydrated, setHydrated] = useState(false);
133
+ useEffect(() => {
134
+ let cancelled = false;
135
+ runtime.auth
136
+ .hydrate()
137
+ .catch((error: unknown) => config.onError?.(error, {}))
138
+ .finally(() => {
139
+ if (!cancelled) setHydrated(true);
140
+ });
141
+ return () => {
142
+ cancelled = true;
143
+ };
144
+ }, [runtime, config]);
145
+
146
+ return (
147
+ <LaconiusContext.Provider value={runtime}>
148
+ <QueryClientProvider client={queryClient}>
149
+ {hydrated ? children : fallback}
150
+ </QueryClientProvider>
151
+ </LaconiusContext.Provider>
152
+ );
153
+ }
154
+
155
+ export function useLaconius(): LaconiusRuntime {
156
+ const runtime = useContext(LaconiusContext);
157
+ if (!runtime) {
158
+ throw new Error(
159
+ '[laconius] useLaconius must be used inside <LaconiusProvider>.',
160
+ );
161
+ }
162
+ return runtime;
163
+ }
164
+
165
+ export const useLaconiusConfig = (): LaconiusConfig => useLaconius().config;
166
+
167
+ /** Re-renders on a language change, so a switch retranslates and refetches in one move. */
168
+ export function useTranslation(): Translator {
169
+ const { config } = useLaconius();
170
+ // Subscribed for the re-render; the value itself comes from `resolveLanguage`, which is where
171
+ // a stored value that the config no longer offers is dropped.
172
+ const language = useSiteContextStore((state) => state.language);
173
+ return useMemo(
174
+ () =>
175
+ createTranslator({
176
+ translations: config.i18n?.translations,
177
+ language: resolveLanguage(config) ?? 'en',
178
+ fallbackLanguage: config.i18n?.fallbackLanguage,
179
+ translate: config.i18n?.translate,
180
+ }),
181
+ [config, language],
182
+ );
183
+ }
184
+
185
+ export function useSiteContext() {
186
+ const { config } = useLaconius();
187
+ // Subscriptions, not values: reading the store directly here would show a currency the client
188
+ // is no longer sending, because `resolveCurrency` drops one the config no longer offers.
189
+ useSiteContextStore((state) => state.language);
190
+ useSiteContextStore((state) => state.currency);
191
+ const setLanguage = useSiteContextStore((state) => state.setLanguage);
192
+ const setCurrency = useSiteContextStore((state) => state.setCurrency);
193
+ return {
194
+ language: resolveLanguage(config),
195
+ currency: resolveCurrency(config),
196
+ languages: config.context.languages,
197
+ currencies: config.context.currencies,
198
+ baseSite: config.backend.occ.baseSite,
199
+ setLanguage,
200
+ setCurrency,
201
+ };
202
+ }
203
+
204
+ /** `true` while the session store holds an access token. */
205
+ export function useIsLoggedIn(): boolean {
206
+ return useSessionStore((state) => Boolean(state.accessToken));
207
+ }
208
+
209
+ /** The app owns offline UI; the provider only wires `onlineManager`. */
210
+ export function useIsOnline(): boolean {
211
+ return useSyncExternalStore(
212
+ (onChange) => onlineManager.subscribe(onChange),
213
+ () => onlineManager.isOnline(),
214
+ () => true,
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Revoke, clear, and drop Laconius' queries — never `queryClient.clear()`, which would wipe the
220
+ * app's own. Site context and recently-viewed deliberately survive.
221
+ */
222
+ export function useLogout(): () => Promise<void> {
223
+ const { auth } = useLaconius();
224
+ const queryClient = useQueryClient();
225
+ return async () => {
226
+ await auth.logout();
227
+ queryClient.removeQueries({ queryKey: ['laconius'] });
228
+ };
229
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { AuthClient } from './auth';
2
+ import type { LaconiusHttpClient } from './client';
3
+ import type { LaconiusConfig } from './config';
4
+ import type { ConverterRegistry } from './converters';
5
+ import { resolveCurrency, resolveLanguage } from './stores';
6
+
7
+ /** Everything a query factory needs without a React context to read it from. */
8
+ export type LaconiusRuntime = {
9
+ config: LaconiusConfig;
10
+ client: LaconiusHttpClient;
11
+ auth: AuthClient;
12
+ converters: ConverterRegistry;
13
+ };
14
+
15
+ let active: LaconiusRuntime | undefined;
16
+
17
+ /** Set by `LaconiusProvider` during render, so factories work before any effect runs. */
18
+ export function setActiveRuntime(runtime: LaconiusRuntime | undefined): void {
19
+ active = runtime;
20
+ }
21
+
22
+ /**
23
+ * The runtime, for `queryOptions` factories used outside React (`prefetchQuery`,
24
+ * `ensureQueryData`).
25
+ */
26
+ export function getRuntime(): LaconiusRuntime {
27
+ if (!active) {
28
+ throw new Error(
29
+ '[laconius] No LaconiusProvider is mounted. Wrap your app in <LaconiusProvider config={…}>.',
30
+ );
31
+ }
32
+ return active;
33
+ }
34
+
35
+ /**
36
+ * `['laconius', <feature>, { site, lang, curr, user }, ...args]`.
37
+ *
38
+ * The context object is in every key, including product: customer-group pricing depends on the
39
+ * user. The `'laconius'` prefix is what makes `removeQueries(['laconius'])` safe.
40
+ */
41
+ export function laconiusQueryKey(
42
+ feature: string,
43
+ ...args: unknown[]
44
+ ): unknown[] {
45
+ const { config, auth } = getRuntime();
46
+ return [
47
+ 'laconius',
48
+ feature,
49
+ {
50
+ site: config.backend.occ.baseSite,
51
+ lang: resolveLanguage(config),
52
+ curr: resolveCurrency(config),
53
+ user: auth.getUserId(),
54
+ },
55
+ ...args,
56
+ ];
57
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Collapses concurrent calls onto one in-flight promise: N 401s cause one refresh.
3
+ *
4
+ * The next call after settlement starts a fresh one.
5
+ */
6
+ export function singleFlight<T>(fn: () => Promise<T>): () => Promise<T> {
7
+ let inFlight: Promise<T> | undefined;
8
+
9
+ return () => {
10
+ if (!inFlight) {
11
+ const promise = fn();
12
+ inFlight = promise;
13
+ promise
14
+ .finally(() => {
15
+ if (inFlight === promise) inFlight = undefined;
16
+ })
17
+ .catch(() => {
18
+ // The caller owns the rejection; this chain exists only to clear the slot.
19
+ });
20
+ }
21
+ return inFlight;
22
+ };
23
+ }