@void-snippets/react 0.2.2 → 0.6.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/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import * as _tanstack_react_query from '@tanstack/react-query';
2
- import { ResourceAdapters, TQueryParams, TPagination, ResourceListResult } from '@void-snippets/core';
2
+ import { InfiniteData } from '@tanstack/react-query';
3
+ import { VSAdapters, VSQueryParams, VSPagination, VSListResult } from '@void-snippets/core';
4
+ import { Socket } from 'socket.io-client';
5
+ import { ReactNode } from 'react';
3
6
 
4
- type CapitalizeStr<S extends string> = S extends `${infer F}${infer Rest}` ? `${Uppercase<F>}${Rest}` : S;
5
7
  interface WithResourceTypes {
6
8
  readonly __types: {
7
9
  id: unknown;
@@ -20,88 +22,621 @@ type Create<S extends WithResourceTypes> = S["__types"]["create"];
20
22
  type Update<S extends WithResourceTypes> = S["__types"]["update"];
21
23
  type ListRaw<S extends WithResourceTypes> = S["__types"]["listRaw"];
22
24
  type SingleRaw<S extends WithResourceTypes> = S["__types"]["singleRaw"];
23
- type UseListReturn<K extends string, TBase> = {
24
- [P in K]: TBase[];
25
- } & {
26
- pagination: TPagination;
27
- } & {
28
- [P in `is${CapitalizeStr<K>}Loading`]: boolean;
29
- } & {
30
- [P in `${K}Error`]: Error | null;
31
- } & {
32
- [P in `invalidate${CapitalizeStr<K>}`]: () => void;
25
+ interface VSUseListReturn<TBase> {
26
+ list: TBase[];
27
+ pagination: VSPagination;
28
+ /**
29
+ * True only on the very first fetch when there is no cached data yet.
30
+ * Use this to render a full-page spinner or skeleton.
31
+ * Does NOT become true during background refetches — for that see `isRefetching`.
32
+ */
33
+ isLoading: boolean;
34
+ /**
35
+ * True during any fetch in progress — initial load or background refetch.
36
+ * Use for a subtle progress indicator that doesn't blank out the list.
37
+ */
38
+ isFetching: boolean;
39
+ /**
40
+ * True during a background refetch while cached data is already present.
41
+ * Equivalent to `isFetching && !isLoading`.
42
+ * Use for a lightweight "Refreshing…" badge overlaid on the existing list.
43
+ */
44
+ isRefetching: boolean;
45
+ /** True when the last fetch attempt resulted in an error. */
46
+ isError: boolean;
47
+ error: Error | null;
48
+ /**
49
+ * Re-runs this specific query. Wire to a retry button in your error state.
50
+ * Narrower than `invalidate` — only refetches this exact param variant.
51
+ */
52
+ refetch: () => Promise<unknown>;
53
+ /**
54
+ * Marks all active queries under this resource prefix as stale and
55
+ * triggers a background refetch. Broader than `refetch` — affects every
56
+ * mounted param variant of this resource simultaneously.
57
+ */
58
+ invalidate: () => void;
59
+ }
60
+ interface VSUseGetReturn<TDetail> {
61
+ item: TDetail | undefined;
62
+ /** True only on the first fetch when no cached data exists. */
63
+ isLoading: boolean;
64
+ /** True during any fetch in progress. */
65
+ isFetching: boolean;
66
+ /** True during a background refetch while cached data is already present. */
67
+ isRefetching: boolean;
68
+ /** True when the last fetch attempt resulted in an error. */
69
+ isError: boolean;
70
+ error: Error | null;
71
+ /** Re-runs this query. */
72
+ refetch: () => Promise<unknown>;
73
+ }
74
+ /**
75
+ * The operation context passed to `onError` and `onSuccess` callbacks.
76
+ * Discriminate by `kind` to handle each mutation type differently.
77
+ */
78
+ type VSOptimisticOperation<TId, TCreate, TUpdate> = {
79
+ kind: "create";
80
+ payload: TCreate;
81
+ tempId: string;
82
+ } | {
83
+ kind: "update";
84
+ _id: TId;
85
+ payload: TUpdate;
86
+ } | {
87
+ kind: "remove";
88
+ _id: TId;
33
89
  };
34
- interface CreateResourceHooksOptions<TListRaw, TBase, TSingleRaw, TDetail> {
90
+ interface VSOptimisticHandlers<TBase, TId, TUpdate, TCreate = Partial<TBase>, TDetail = TBase> {
35
91
  /**
36
- * Adapters map your API's raw response shapes to the library's internal
37
- * format. Omit this entirely if your API matches the default shape:
38
- * List: { data: { items, page, limit, totalPages, totalDocuments } }
39
- * Single: { data: <item> }
92
+ * Optimistically transforms the list when `update.mutate()` fires.
93
+ * `_id` is the mutation target it is **separate** from `payload`.
94
+ * Applied to every active `useList` and `useInfinite` cache.
95
+ * The `useGet` cache is shallow-merged automatically; override with `updateSingle`.
96
+ * Return a new array — never mutate `cache` in place.
40
97
  *
41
98
  * @example
42
- * createResourceHooks("contacts", ContactsApis, {
43
- * adapters: {
44
- * fromList: (raw) => ({
45
- * items: raw.results,
46
- * pagination: {
47
- * page: raw.meta.page,
48
- * limit: raw.meta.perPage,
49
- * totalPages: raw.meta.lastPage,
50
- * totalDocuments: raw.meta.total,
51
- * },
52
- * }),
53
- * fromSingle: (raw) => raw.payload,
54
- * },
55
- * })
56
- */
57
- adapters?: ResourceAdapters<TListRaw, TBase, TSingleRaw, TDetail>;
58
- /**
59
- * Default params passed to useList and useInfinite when none are provided.
60
- * @default { page: 1, limit: 10 }
61
- */
62
- defaultParams?: TQueryParams;
99
+ * update: (cache, { _id, payload }) =>
100
+ * cache.map(item => item._id === _id ? { ...item, ...payload } : item)
101
+ */
102
+ update?: (cache: TBase[], args: {
103
+ _id: TId;
104
+ payload: TUpdate;
105
+ }) => TBase[];
106
+ /**
107
+ * Overrides the default `{ ...current, ...payload }` shallow-merge for the
108
+ * `useGet` cache. Only needed when `TDetail` has nested objects requiring
109
+ * a deep merge. Ignored if `update` is not also provided.
110
+ */
111
+ updateSingle?: (current: TDetail, payload: TUpdate) => TDetail;
112
+ /**
113
+ * Optimistically removes an item when `remove.mutate()` fires.
114
+ * `totalDocuments` / `totalPages` are patched automatically from the diff.
115
+ * The matching `useGet` entry is staled (keeps showing data until confirmed).
116
+ * Return a new array never mutate `cache` in place.
117
+ *
118
+ * @example
119
+ * remove: (cache, id) => cache.filter(item => item._id !== id)
120
+ */
121
+ remove?: (cache: TBase[], id: TId) => TBase[];
122
+ /**
123
+ * Optimistically inserts an item when `create.mutate()` fires.
124
+ * Applied to every `useList` cache and the **first page** of every
125
+ * `useInfinite` cache. `totalDocuments` / `totalPages` are patched automatically.
126
+ * `tempId` is a library-generated UUID — use it to set `_id` on the item.
127
+ * Return a new array — never mutate `cache` in place.
128
+ *
129
+ * @example
130
+ * create: (cache, { payload, tempId }) => [
131
+ * { ...payload, _id: tempId as Contact.Id },
132
+ * ...cache,
133
+ * ]
134
+ */
135
+ create?: (cache: TBase[], args: {
136
+ payload: TCreate;
137
+ tempId: string;
138
+ }) => TBase[];
139
+ /**
140
+ * Called after the optimistic rollback completes when a mutation fails.
141
+ * The cache is already restored to the correct state when this fires.
142
+ * Use for resource-level error notifications across all call sites.
143
+ *
144
+ * @example
145
+ * onError: (error, operation) => {
146
+ * toast.error(`Failed to ${operation.kind} item: ${error.message}`)
147
+ * }
148
+ */
149
+ onError?: (error: Error, operation: VSOptimisticOperation<TId, TCreate, TUpdate>) => void;
150
+ /**
151
+ * Called after `effectiveBase` has been advanced for this operation.
152
+ * Fires once per successfully confirmed mutation, before the final
153
+ * invalidation when all pending operations have settled.
154
+ *
155
+ * @example
156
+ * onSuccess: (operation) => {
157
+ * if (operation.kind === 'create') analytics.track('item_created')
158
+ * }
159
+ */
160
+ onSuccess?: (operation: VSOptimisticOperation<TId, TCreate, TUpdate>) => void;
63
161
  }
64
- /**
65
- * Creates a set of TanStack Query hooks for a resource.
66
- * All types are fully inferred from the `apiService` instance — no generics
67
- * need to be passed manually.
68
- *
69
- * @param queryKeyPrefix - TanStack Query cache key prefix and the base name
70
- * for the returned hook properties.
71
- * e.g. "contacts" → { contacts, isContactsLoading, ... }
72
- * @param apiService - An instance of ResourceService (or a subclass).
73
- * @param options - Optional adapters and default params.
74
- *
75
- * @example
76
- * // contacts.hooks.ts
77
- * import { createResourceHooks } from '@void-snippets/react';
78
- * import { ContactsApis } from './contacts.api';
79
- *
80
- * // No generics needed — all types are inferred from ContactsApis
81
- * export const contactHooks = createResourceHooks('contacts', ContactsApis);
82
- *
83
- * // In a component:
84
- * const { contacts, isContactsLoading } = contactHooks.useList();
85
- * const { data } = contactHooks.useGet(id); // data: Contact.WithCreatedBy
86
- * const { create, update, delete: remove } = contactHooks.useMutations();
87
- */
88
- declare function createResourceHooks<K extends string, S extends WithResourceTypes>(queryKeyPrefix: K, apiService: S, options?: CreateResourceHooksOptions<ListRaw<S>, Base<S>, SingleRaw<S>, Detail<S>>): {
89
- useList: (params?: TQueryParams) => UseListReturn<K, Base<S>>;
90
- useGet: (id: Id<S>, staleTime?: number) => {
91
- data: _tanstack_react_query.NoInfer<Detail<S>> | undefined;
92
- isLoading: boolean;
93
- error: Error | null;
94
- refetch: (options?: _tanstack_react_query.RefetchOptions) => Promise<_tanstack_react_query.QueryObserverResult<_tanstack_react_query.NoInfer<Detail<S>>, Error>>;
95
- };
162
+ interface VSResourceHooksOptions<TListRaw, TBase, TSingleRaw, TDetail, TId = unknown, TUpdate = unknown, TCreate = unknown> {
163
+ adapters?: VSAdapters<TListRaw, TBase, TSingleRaw, TDetail>;
164
+ defaultParams?: VSQueryParams;
165
+ optimistic?: VSOptimisticHandlers<TBase, TId, TUpdate, TCreate, TDetail>;
166
+ }
167
+ type MutationContext = {
168
+ operationId: symbol;
169
+ };
170
+ declare function createResourceHooks<K extends string, S extends WithResourceTypes>(queryKeyPrefix: K, apiService: S, options?: VSResourceHooksOptions<ListRaw<S>, Base<S>, SingleRaw<S>, Detail<S>, Id<S>, Update<S>, Create<S>>): {
171
+ useList: (params?: VSQueryParams) => VSUseListReturn<Base<S>>;
172
+ useGet: (id: Id<S>, staleTime?: number) => VSUseGetReturn<Detail<S>>;
96
173
  useMutations: () => {
97
- create: _tanstack_react_query.UseMutationResult<Detail<S>, Error, Create<S>, unknown>;
174
+ create: _tanstack_react_query.UseMutationResult<Detail<S>, Error, Create<S>, MutationContext | undefined>;
98
175
  update: _tanstack_react_query.UseMutationResult<Detail<S>, Error, {
99
176
  _id: Id<S>;
100
177
  payload: Update<S>;
101
- }, unknown>;
102
- delete: _tanstack_react_query.UseMutationResult<Detail<S>, Error, Id<S>, unknown>;
178
+ }, MutationContext | undefined>;
179
+ remove: _tanstack_react_query.UseMutationResult<Detail<S>, Error, Id<S>, MutationContext | undefined>;
180
+ };
181
+ useInfinite: (params?: VSQueryParams) => _tanstack_react_query.UseInfiniteQueryResult<InfiniteData<VSListResult<Base<S>>, unknown>, Error>;
182
+ };
183
+
184
+ type EventParams<TEvents, K extends keyof TEvents> = TEvents[K] extends (...args: infer P) => void ? P : never;
185
+ type LastIsCallback<P extends readonly unknown[]> = P extends [...infer _Rest, infer Last] ? Last extends (...args: any[]) => void ? true : false : false;
186
+ type NoAckArgs<TEvents, K extends keyof TEvents> = EventParams<TEvents, K> extends [...infer Rest, infer Last] ? Last extends (...args: any[]) => void ? Rest : EventParams<TEvents, K> : EventParams<TEvents, K>;
187
+ type AckEventKeys<TEvents> = {
188
+ [K in keyof TEvents]: LastIsCallback<TEvents[K] extends (...args: infer P) => void ? P : never> extends true ? K : never;
189
+ }[keyof TEvents];
190
+ type AckResponseType<TEvents, K extends keyof TEvents> = EventParams<TEvents, K> extends [...infer _Rest, infer Last] ? Last extends (arg: infer R, ...rest: any[]) => void ? R : never : never;
191
+ interface VSSocketConnectionReturn {
192
+ /** True when the socket has an active, confirmed connection. */
193
+ isConnected: boolean;
194
+ /**
195
+ * True while a connection or reconnection attempt is in progress.
196
+ * Resets to false when `connect` or `connect_error` fires.
197
+ */
198
+ isConnecting: boolean;
199
+ /** The socket ID assigned by the server. Undefined when disconnected. */
200
+ socketId: string | undefined;
201
+ /** The error from the last failed connection attempt. Null on success or before any attempt. */
202
+ error: Error | null;
203
+ /**
204
+ * Initiates a connection. No-op if already connected.
205
+ * Sets `isConnecting: true` until `connect` or `connect_error` fires.
206
+ */
207
+ connect: () => void;
208
+ /** Gracefully closes the connection and stops all reconnection attempts. */
209
+ disconnect: () => void;
210
+ }
211
+ /**
212
+ * Creates three type-safe Socket.IO hooks bound to a specific socket instance.
213
+ *
214
+ * Call once at module level — the returned hooks close over the socket and both
215
+ * event-map generics, so no type parameters are needed at individual call sites.
216
+ *
217
+ * Requires socket.io-client ≥4.6.0 (for native `socket.emitWithAck` support).
218
+ *
219
+ * @example
220
+ * // socket-hooks.ts (create once, export and use everywhere)
221
+ * import { createSocketHooks } from '@void-snippets/react';
222
+ * import { io } from 'socket.io-client';
223
+ *
224
+ * const socket = io(process.env.SOCKET_URL, { autoConnect: false });
225
+ *
226
+ * export const { useSocketEmit, useSocketListener, useSocketConnection } =
227
+ * createSocketHooks<IClientToServerEvents, IServerToClientEvents>(socket);
228
+ */
229
+ declare function createSocketHooks<TClientEvents extends Record<string, (...args: any[]) => void>, TServerEvents extends Record<string, (...args: any[]) => void>>(socket: Socket<TServerEvents, TClientEvents>): {
230
+ useSocketEmit: () => {
231
+ emit: <K extends keyof TClientEvents>(event: K, ...args: NoAckArgs<TClientEvents, K>) => void;
232
+ emitWithAck: <K extends AckEventKeys<TClientEvents>>(event: K, ...args: NoAckArgs<TClientEvents, K>) => Promise<AckResponseType<TClientEvents, K>>;
103
233
  };
104
- useInfinite: (params?: TQueryParams) => _tanstack_react_query.UseInfiniteQueryResult<_tanstack_react_query.InfiniteData<ResourceListResult<Base<S>>, unknown>, Error>;
234
+ useSocketListener: <K extends keyof TServerEvents>(event: K, handler: TServerEvents[K], options?: {
235
+ enabled?: boolean;
236
+ }) => void;
237
+ useSocketConnection: () => VSSocketConnectionReturn;
238
+ };
239
+
240
+ /**
241
+ * Recursively extracts named path parameters from a route path string using
242
+ * TypeScript template literal inference. Handles both required and optional
243
+ * segments, and composes correctly across nested slashes.
244
+ *
245
+ * '/users/:userId/posts/:postId' → { userId: string | number; postId: string | number }
246
+ * '/files/:path?' → { path?: string | number }
247
+ */
248
+ type ExtractRouteParams<T extends string> = T extends `${infer _Start}:${infer Param}/${infer Rest}` ? (Param extends `${infer P}?` ? {
249
+ [K in P]?: string | number;
250
+ } : {
251
+ [K in Param]: string | number;
252
+ }) & ExtractRouteParams<`/${Rest}`> : T extends `${infer _Start}:${infer Param}` ? Param extends `${infer P}?` ? {
253
+ [K in P]?: string | number;
254
+ } : {
255
+ [K in Param]: string | number;
256
+ } : {};
257
+ /** Collapses intersection types into a single object for cleaner IDE tooltips. */
258
+ type Prettify<T> = {
259
+ [K in keyof T]: T[K];
260
+ } & {};
261
+ /** All named path parameters for a given route path literal. */
262
+ type RouteParams<Path extends string> = Prettify<ExtractRouteParams<Path>>;
263
+ /** True when the path string contains at least one named parameter. */
264
+ type HasParams<Path extends string> = keyof RouteParams<Path> extends never ? false : true;
265
+ /**
266
+ * True when Search is the `never` type — meaning no search params were
267
+ * declared on this route. Array wrapping avoids distributive evaluation.
268
+ */
269
+ type SearchIsAbsent<Search> = [Search] extends [never] ? true : false;
270
+ /**
271
+ * True when Search has at least one required (non-optional) key.
272
+ * { page: number; sort?: string } → true (page is required)
273
+ * { sort?: string } → false (all optional)
274
+ * never → false
275
+ */
276
+ type HasRequiredSearchKeys<Search> = [
277
+ Search
278
+ ] extends [never] ? false : {} extends Search ? false : true;
279
+ /** Params portion of the build() argument. Empty object when path has no params. */
280
+ type ParamsPart<Path extends string> = HasParams<Path> extends true ? {
281
+ params: RouteParams<Path>;
282
+ } : {};
283
+ /**
284
+ * Search portion of the build() argument.
285
+ * Absent when Search is never.
286
+ * Required when Search has at least one required key.
287
+ * Optional when all Search keys are optional.
288
+ */
289
+ type SearchPart<Search> = SearchIsAbsent<Search> extends true ? {} : HasRequiredSearchKeys<Search> extends true ? {
290
+ search: Search;
291
+ } : {
292
+ search?: Search;
293
+ };
294
+ /** Full combined build() options, flattened for IDE readability. */
295
+ type BuildArgs<Path extends string, Search> = Prettify<ParamsPart<Path> & SearchPart<Search>>;
296
+ /**
297
+ * True when build() can be called with zero arguments:
298
+ * no path params AND (no search OR all search keys are optional).
299
+ */
300
+ type ArgIsOptional<Path extends string, Search> = HasParams<Path> extends true ? false : SearchIsAbsent<Search> extends true ? true : HasRequiredSearchKeys<Search> extends true ? false : true;
301
+ /**
302
+ * True when BuildArgs produces an empty object — meaning build()
303
+ * takes no arguments whatsoever (no params, no search).
304
+ */
305
+ type BuildArgIsEmpty<Path extends string, Search> = HasParams<Path> extends false ? SearchIsAbsent<Search> extends true ? true : false : false;
306
+ /**
307
+ * The fully conditioned build() function signature.
308
+ *
309
+ * Four cases:
310
+ * no params + no search → () => string
311
+ * no params + all-optional search → (options?: { search?: S }) => string
312
+ * params (+ optional search) → (options: { params: P; search?: S }) => string
313
+ * required search key → (options: { search: S }) => string
314
+ */
315
+ type BuildFn<Path extends string, Search> = BuildArgIsEmpty<Path, Search> extends true ? () => string : ArgIsOptional<Path, Search> extends true ? (options?: BuildArgs<Path, Search>) => string : (options: BuildArgs<Path, Search>) => string;
316
+ /** Optional metadata that can be attached to any route definition. */
317
+ interface RouteMetadata {
318
+ /**
319
+ * Permission identifiers required to access this route.
320
+ * Read via the `handle` property in your React Router config.
321
+ *
322
+ * @example
323
+ * permissions: ['ADMIN', 'SUPER_ADMIN']
324
+ */
325
+ permissions?: string[];
326
+ /**
327
+ * Human-readable label used for breadcrumb navigation.
328
+ * Read via the `handle` property in your React Router config.
329
+ */
330
+ breadcrumb?: string;
331
+ /**
332
+ * Document title for the page.
333
+ * Read via the `handle` property in your React Router config.
334
+ */
335
+ title?: string;
336
+ /**
337
+ * Arbitrary custom metadata — analytics tags, loader IDs, feature flags, etc.
338
+ * Read via the `handle` property in your React Router config.
339
+ *
340
+ * @example
341
+ * meta: { analyticsId: 'user-detail-view', loaderKey: 'userLoader' }
342
+ */
343
+ meta?: Record<string, unknown>;
344
+ }
345
+ /**
346
+ * The intermediate type returned by `defineRoute()`.
347
+ * Consumed directly by `createRouteContract()` — you do not use this type
348
+ * directly in application code.
349
+ *
350
+ * @internal
351
+ */
352
+ type RouteDefinition<Path extends string = string, Search = never> = RouteMetadata & {
353
+ /** The absolute path string for this route. */
354
+ readonly path: Path;
355
+ /**
356
+ * Phantom type anchor — carries the Search type for downstream inference.
357
+ * Always `undefined` at runtime. Do not read or write this directly.
358
+ * @internal
359
+ */
360
+ readonly _search: Search;
361
+ /**
362
+ * Declares typed search parameters for this route. Chain immediately after
363
+ * `defineRoute()`. The generic type argument is the only input — no value
364
+ * needs to be passed.
365
+ *
366
+ * @example
367
+ * defineRoute('/users').search<{ page: number; sort?: 'asc' | 'desc' }>()
368
+ */
369
+ search<S>(): RouteDefinition<Path, S>;
370
+ };
371
+ /**
372
+ * A fully processed route node produced by `createRouteContract()`.
373
+ * This is the type you interact with throughout the application.
374
+ */
375
+ type ProcessedRoute<Path extends string = string, Search = never> = RouteMetadata & {
376
+ /** The absolute path string — use this in `createBrowserRouter`. */
377
+ readonly path: Path;
378
+ /**
379
+ * Phantom type anchor — used by `useTypedSearchParams` to infer `Search`.
380
+ * Always `undefined` at runtime. Do not read or write this directly.
381
+ * @internal
382
+ */
383
+ readonly _search: Search;
384
+ /**
385
+ * Builds a fully qualified URL for this route.
386
+ *
387
+ * TypeScript enforces at compile time that:
388
+ * - `params` is provided and fully satisfied when the path has dynamic segments.
389
+ * - `search` matches the exact shape declared via `.search<T>()`.
390
+ * - No argument is needed for routes with neither params nor required search.
391
+ */
392
+ readonly build: BuildFn<Path, Search>;
393
+ };
394
+ /** The input shape `createRouteContract` accepts. */
395
+ type RouteTree = {
396
+ [K: string]: RouteDefinition<string, any> | RouteTree;
397
+ };
398
+ /** Maps a RouteTree recursively to a tree of ProcessedRoutes. */
399
+ type ProcessedTree<T> = {
400
+ [K in keyof T]: T[K] extends RouteDefinition<infer Path, infer Search> ? ProcessedRoute<Path, Search> : T[K] extends RouteTree ? ProcessedTree<T[K]> : never;
401
+ };
402
+ /**
403
+ * Defines a single route. Pass directly to `createRouteContract()`.
404
+ *
405
+ * The second argument is purely metadata — permissions, breadcrumbs, titles.
406
+ * Chain `.search<SearchType>()` to declare typed search parameters for the route.
407
+ *
408
+ * **Use absolute paths.** Concatenating parent/child paths via template literals
409
+ * causes TypeScript server slowdowns on large apps. Be explicit.
410
+ *
411
+ * @example
412
+ * // Plain route — no params, no search
413
+ * defineRoute('/dashboard', { breadcrumb: 'Home', title: 'Dashboard' })
414
+ *
415
+ * // Route with typed search params
416
+ * defineRoute('/users', { permissions: ['ADMIN'] })
417
+ * .search<{ page: number; sort?: 'asc' | 'desc' }>()
418
+ *
419
+ * // Route with path params and optional search
420
+ * defineRoute('/users/:userId', { breadcrumb: 'User Detail' })
421
+ * .search<{ tab?: 'profile' | 'settings' }>()
422
+ *
423
+ * // Route with path params, no search
424
+ * defineRoute('/users/:userId/posts/:postId')
425
+ */
426
+ declare function defineRoute<Path extends string>(path: Path, config?: RouteMetadata): RouteDefinition<Path, never>;
427
+ /**
428
+ * Processes a tree of `defineRoute()` definitions into a fully typed contract.
429
+ *
430
+ * Every route leaf gains a `build()` function whose signature is automatically
431
+ * conditioned on the presence of path params and search params. Nested groups
432
+ * are preserved as plain objects. All metadata (`path`, `permissions`,
433
+ * `breadcrumb`, `title`, `meta`) flows through to the output unchanged.
434
+ *
435
+ * Call once at module level and export. Import `AppRoutes` wherever you need
436
+ * to build a URL, wire up the router, or access route metadata.
437
+ *
438
+ * @example
439
+ * // routes.ts
440
+ * export const AppRoutes = createRouteContract({
441
+ * auth: {
442
+ * login: defineRoute('/auth/login').search<{ redirect?: string }>(),
443
+ * register: defineRoute('/auth/register'),
444
+ * },
445
+ * dashboard: {
446
+ * root: defineRoute('/dashboard', { breadcrumb: 'Home', title: 'Dashboard' }),
447
+ * users: {
448
+ * list: defineRoute('/dashboard/users', {
449
+ * permissions: ['ADMIN'],
450
+ * breadcrumb: 'Users',
451
+ * }).search<{ page: number; sort?: 'asc' | 'desc' }>(),
452
+ * detail: defineRoute('/dashboard/users/:userId', {
453
+ * permissions: ['ADMIN'],
454
+ * breadcrumb: 'User Detail',
455
+ * }).search<{ tab?: 'profile' | 'settings' }>(),
456
+ * },
457
+ * },
458
+ * });
459
+ */
460
+ declare function createRouteContract<T extends RouteTree>(tree: T): ProcessedTree<T>;
461
+ /**
462
+ * Returns the current URL search params typed to the shape declared on the
463
+ * route, plus a type-safe setter and a clear function.
464
+ *
465
+ * Pass any processed route that was created with `.search<T>()`. TypeScript
466
+ * infers `T` automatically — no generics needed at the call site.
467
+ *
468
+ * **`setSearch` merges** — it does not replace the entire query string.
469
+ * Pass only the keys you want to change; everything else is preserved.
470
+ * Set a key to `undefined` or `null` to remove it from the URL.
471
+ *
472
+ * ⚠️ **Runtime coercion note:** React Router's `useSearchParams` returns all
473
+ * values as strings. If you declare `page: number`, `search.page` will be
474
+ * the string `"1"` at runtime even though TypeScript types it as `number`.
475
+ * Coerce where needed: `Number(search.page)`. This is a deliberate trade-off
476
+ * that avoids requiring a runtime schema library.
477
+ *
478
+ * @example
479
+ * // Inside the /dashboard/users page component
480
+ * const { search, setSearch, clearSearch } =
481
+ * useTypedSearchParams(AppRoutes.dashboard.users.list);
482
+ *
483
+ * // search.page is typed as `number | undefined`
484
+ * // but is a string at runtime — coerce explicitly:
485
+ * const page = Number(search.page ?? 1);
486
+ *
487
+ * setSearch({ page: 2 }) // keeps sort, q; updates page
488
+ * setSearch({ page: 1, sort: 'asc' }) // updates page and sort; keeps q
489
+ * setSearch({ sort: undefined }) // removes sort from the URL
490
+ * clearSearch() // wipes all search params
491
+ */
492
+ declare function useTypedSearchParams<P extends string, S>(_route: ProcessedRoute<P, S>): {
493
+ /** Current search params, typed as a partial of the declared search shape. */
494
+ readonly search: Readonly<Partial<S>>;
495
+ /**
496
+ * Merges the given partial update into the current search params and
497
+ * pushes a new URL entry. Set a key to `undefined` to remove it.
498
+ */
499
+ setSearch: (update: Partial<S>) => void;
500
+ /** Removes all search parameters from the URL. */
501
+ clearSearch: () => void;
502
+ };
503
+
504
+ type VSAlertVariant = "success" | "info" | "error";
505
+ interface VSAlertState {
506
+ message: ReactNode | string;
507
+ type: VSAlertVariant;
508
+ isVisible: boolean;
509
+ }
510
+ /**
511
+ * Manages alert/toast message state with optional auto-hide.
512
+ *
513
+ * @param autoHideDuration - ms before alert hides automatically. Pass 0 to disable. Default: 3000
514
+ *
515
+ * @example
516
+ * const { alert, showAlert, hideAlert } = useAlertMessage();
517
+ * showAlert('Saved successfully!', 'success');
518
+ * showAlert(<b>Something went wrong</b>, 'error');
519
+ */
520
+ declare function useAlertMessage(autoHideDuration?: number): {
521
+ alert: VSAlertState;
522
+ showAlert: (message: ReactNode | string, type?: VSAlertVariant) => void;
523
+ hideAlert: () => void;
105
524
  };
106
525
 
107
- export { type CreateResourceHooksOptions, type UseListReturn, createResourceHooks };
526
+ type VSAsyncStatus = "idle" | "pending" | "success" | "error";
527
+ interface VSAsyncState<T> {
528
+ data: T | null;
529
+ status: VSAsyncStatus;
530
+ error: Error | null;
531
+ }
532
+ interface VSUseAsyncStateReturn<T> extends VSAsyncState<T> {
533
+ isLoading: boolean;
534
+ isSuccess: boolean;
535
+ isError: boolean;
536
+ setData: (data: T | null) => void;
537
+ setError: (error: Error | null) => void;
538
+ reset: () => void;
539
+ /**
540
+ * Executes an async function, updates state, and returns a [err, data] tuple.
541
+ * Allows immediate result handling without try/catch.
542
+ *
543
+ * @example
544
+ * const [err, data] = await execute(() => ContactsApis.create(payload));
545
+ * if (err) return showAlert(err.message, 'error');
546
+ * showAlert('Created!', 'success');
547
+ */
548
+ execute: (asyncFn: () => Promise<T>, options?: {
549
+ onSuccess?: (data: T) => void;
550
+ onError?: (error: Error) => void;
551
+ }) => Promise<[Error, null] | [null, T]>;
552
+ }
553
+ /**
554
+ * Generic async state machine — tracks data, status, and error for any async operation.
555
+ * Pair with any async function: API calls, file reads, timers, etc.
556
+ *
557
+ * @param initialData - Optional initial data value. Default: null
558
+ *
559
+ * @example
560
+ * const { data, isLoading, isError, execute } = useAsyncState<User>();
561
+ *
562
+ * const handleSubmit = async () => {
563
+ * const [err, user] = await execute(() => fetchUser(id));
564
+ * if (err) return;
565
+ * console.log(user.name);
566
+ * };
567
+ */
568
+ declare function useAsyncState<T>(initialData?: T | null): VSUseAsyncStateReturn<T>;
569
+
570
+ /**
571
+ * Tracks elapsed time from a given start timestamp — useful for call durations,
572
+ * countdowns, or any elapsed-time display.
573
+ *
574
+ * @param startedAt - Unix timestamp in ms (e.g. Date.now()). Pass null/undefined to reset.
575
+ * @returns Formatted duration string "MM:SS"
576
+ *
577
+ * @example
578
+ * const duration = useCallTimer(call.startedAt);
579
+ * // duration → "02:45"
580
+ *
581
+ * // Reset when no active call
582
+ * const duration = useCallTimer(activeCall ? activeCall.startedAt : null);
583
+ */
584
+ declare function useCallTimer(startedAt?: number | null): string;
585
+
586
+ interface VSModalReturn<T> {
587
+ isOpen: boolean;
588
+ data: T | null;
589
+ isLoading: boolean;
590
+ openCreateModal: () => void;
591
+ openEditModal: (editData: T) => void;
592
+ setLoading: (loading: boolean) => void;
593
+ closeModal: () => void;
594
+ setModal: (open: boolean, editData?: T | null) => void;
595
+ }
596
+ /**
597
+ * Manages modal open/close state with optional data payload and loading state.
598
+ * Works for both create and edit modals — pass data to distinguish the mode.
599
+ *
600
+ * @typeParam T - The type of data the modal operates on (e.g. a Contact, User, etc.)
601
+ *
602
+ * @example
603
+ * const modal = useModal<Contact.Base>();
604
+ *
605
+ * modal.openCreateModal(); // data → null (create mode)
606
+ * modal.openEditModal(contact); // data → contact (edit mode)
607
+ *
608
+ * if (modal.data) {
609
+ * // Edit mode — modal.data is Contact.Base
610
+ * } else {
611
+ * // Create mode
612
+ * }
613
+ */
614
+ declare function useModal<T = unknown>(): VSModalReturn<T>;
615
+
616
+ interface VSPaginationReturn {
617
+ page: number;
618
+ limit: number;
619
+ onPaginationChange: (newPage: number, newLimit: number) => void;
620
+ resetPagination: () => void;
621
+ setPage: (page: number) => void;
622
+ setLimit: (limit: number) => void;
623
+ /** Ready-to-use query params object — pass directly to useList() */
624
+ queryParams: VSQueryParams;
625
+ }
626
+ /**
627
+ * Manages pagination state and produces a ready-to-use queryParams object
628
+ * compatible with createResourceHooks' useList() and useInfinite().
629
+ *
630
+ * @param initialPage - Starting page. Default: 1
631
+ * @param initialLimit - Items per page. Default: 10
632
+ *
633
+ * @example
634
+ * const { queryParams, onPaginationChange } = usePagination(1, 20);
635
+ *
636
+ * const { list, isLoading } = contactHooks.useList(queryParams);
637
+ *
638
+ * <Pagination onChange={onPaginationChange} total={pagination.totalDocuments} />
639
+ */
640
+ declare function usePagination(initialPage?: number, initialLimit?: number): VSPaginationReturn;
641
+
642
+ export { type ProcessedRoute, type RouteDefinition, type RouteMetadata, type VSAlertState, type VSAlertVariant, type VSAsyncStatus, type VSModalReturn, type VSOptimisticHandlers, type VSOptimisticOperation, type VSPaginationReturn, type VSResourceHooksOptions, type VSSocketConnectionReturn, type VSUseAsyncStateReturn, type VSUseGetReturn, type VSUseListReturn, createResourceHooks, createRouteContract, createSocketHooks, defineRoute, useAlertMessage, useAsyncState, useCallTimer, useModal, usePagination, useTypedSearchParams };