tempest-react-sdk 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.
- package/LICENSE +21 -0
- package/README.md +1375 -0
- package/dist/index.d.ts +2327 -0
- package/dist/styles.css +1 -0
- package/dist/tempest-react-sdk.cjs +15 -0
- package/dist/tempest-react-sdk.cjs.map +1 -0
- package/dist/tempest-react-sdk.js +3425 -0
- package/dist/tempest-react-sdk.js.map +1 -0
- package/package.json +140 -0
package/README.md
ADDED
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
# tempest-react-sdk
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/tempest-react-sdk)
|
|
4
|
+
[](https://github.com/mauriciobenjamin700/tempest-react-sdk/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://react.dev)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](https://bundlephobia.com/package/tempest-react-sdk)
|
|
9
|
+
|
|
10
|
+
Shared React/TypeScript building blocks used across Tempest frontends: UI components, hooks, HTTP client, auth store, query keys, forms (zod), real-time transports (SSE / WebSocket / Web Push / Service Worker), theme, i18n, telemetry, feature flags, offline storage, error boundary, and a curated set of utilities (`cn`, `formatCurrency`, `formatCPF`, etc.).
|
|
11
|
+
|
|
12
|
+
The goal is to start every new React frontend with the same opinionated foundation already in place — no copy-pasting `Button`/`Input` styles, no rewriting the same auth Zustand store, no re-inventing the SSE reconnect loop. The patterns here are a distillation of what was consolidated in **alofans-frontend** and **transport-admin-system** — apps that consume the SDK gain consistency without paying for boilerplate.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Table of contents
|
|
17
|
+
|
|
18
|
+
- [Install](#install)
|
|
19
|
+
- [Peer dependencies](#peer-dependencies)
|
|
20
|
+
- [CSS import](#css-import)
|
|
21
|
+
- [What's inside](#whats-inside)
|
|
22
|
+
- [Architecture overview](#architecture-overview)
|
|
23
|
+
- [Quickstart — wiring the app providers](#quickstart--wiring-the-app-providers)
|
|
24
|
+
- [Recipes](#recipes)
|
|
25
|
+
- [HTTP client](#http-client-recipe)
|
|
26
|
+
- [Response parsing with zod](#response-parsing-with-zod-recipe)
|
|
27
|
+
- [Upload with progress](#upload-with-progress-recipe)
|
|
28
|
+
- [Polling](#polling-recipe)
|
|
29
|
+
- [Retry & idempotency](#retry--idempotency-recipe)
|
|
30
|
+
- [Auth store (Zustand)](#auth-store-recipe)
|
|
31
|
+
- [Route guard](#route-guard-recipe)
|
|
32
|
+
- [JWT helpers & refresh queue](#jwt-helpers--refresh-queue-recipe)
|
|
33
|
+
- [Code-splitting with retry](#code-splitting-with-retry-recipe)
|
|
34
|
+
- [React Query](#react-query-recipe)
|
|
35
|
+
- [Forms (zod)](#forms-zod-recipe)
|
|
36
|
+
- [BR validators & masked inputs](#br-validators--masked-inputs-recipe)
|
|
37
|
+
- [ViaCEP lookup](#viacep-lookup-recipe)
|
|
38
|
+
- [WebSocket](#websocket-recipe)
|
|
39
|
+
- [Server-Sent Events (SSE)](#server-sent-events-sse-recipe)
|
|
40
|
+
- [Web Push](#web-push-recipe)
|
|
41
|
+
- [Service Worker helpers](#service-worker-helpers-recipe)
|
|
42
|
+
- [Audio playback](#audio-playback-recipe)
|
|
43
|
+
- [Offline storage (IndexedDB / Dexie)](#offline-storage-indexeddb--dexie-recipe)
|
|
44
|
+
- [Error boundary](#error-boundary-recipe)
|
|
45
|
+
- [Toast notifications](#toast-notifications-recipe)
|
|
46
|
+
- [Modal & ConfirmDialog](#modal--confirmdialog-recipe)
|
|
47
|
+
- [Tables & pagination](#tables--pagination-recipe)
|
|
48
|
+
- [Layout primitives](#layout-primitives-recipe)
|
|
49
|
+
- [Virtual list](#virtual-list-recipe)
|
|
50
|
+
- [Theming (light / dark)](#theming-light--dark-recipe)
|
|
51
|
+
- [i18n](#i18n-recipe)
|
|
52
|
+
- [Feature flags](#feature-flags-recipe)
|
|
53
|
+
- [Telemetry](#telemetry-recipe)
|
|
54
|
+
- [Logger](#logger-recipe)
|
|
55
|
+
- [Web Share API](#web-share-api-recipe)
|
|
56
|
+
- [Hooks catalogue](#hooks-catalogue-recipe)
|
|
57
|
+
- [Utility helpers (`cn`, `format*`, `storage`)](#utility-helpers-recipe)
|
|
58
|
+
- [Theming reference](#theming-reference)
|
|
59
|
+
- [Conventions](#conventions)
|
|
60
|
+
- [Development](#development)
|
|
61
|
+
- [Release](#release)
|
|
62
|
+
- [License](#license)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install tempest-react-sdk
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Via `package.json`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"tempest-react-sdk": "^0.1.0"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Requires React `>=18` and Node `>=20.19` to build.
|
|
83
|
+
|
|
84
|
+
### Peer dependencies
|
|
85
|
+
|
|
86
|
+
`react` and `react-dom` are **required** peer dependencies. Everything else is **optional** — install only the packages the modules you import actually need. Importing `tempest-react-sdk` without an optional peer never throws; the failure surfaces only when you instantiate the helper that uses it.
|
|
87
|
+
|
|
88
|
+
| Peer | Required by | Status |
|
|
89
|
+
| --------------------------------------------- | ------------------------------------------------------------------- | ------------ |
|
|
90
|
+
| `react`, `react-dom` (`^18.0.0 \|\| ^19.0.0`) | Everything | **Required** |
|
|
91
|
+
| `@tanstack/react-query` (`^5`) | `QueryProvider`, `createQueryKeys` | Optional |
|
|
92
|
+
| `zod` (`^3.23 \|\| ^4`) | `parseResponse`, `validateForm`, `zodResolver`, `useZodForm` | Optional |
|
|
93
|
+
| `zustand` (`^4 \|\| ^5`) | `createAuthStore` | Optional |
|
|
94
|
+
| `dexie` (`^4.4`) | `createOfflineStore` | Optional |
|
|
95
|
+
| `react-hook-form` (`^7.76`) | `zodResolver`, `useZodForm`, masked inputs | Optional |
|
|
96
|
+
| `lucide-react` (`>=0.400`) | Component icons (`leftIcon`/`rightIcon` on `Input`, `Button`, etc.) | Optional |
|
|
97
|
+
|
|
98
|
+
Quick recipe for a "typical" app that uses HTTP + Query + Auth + Forms:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install tempest-react-sdk react react-dom \
|
|
102
|
+
@tanstack/react-query zod zustand react-hook-form lucide-react
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
If a module is missing its peer dep at runtime, the bundler will flag the missing import at build time — there is no "silent fallback" behaviour. Add only what you actually consume.
|
|
106
|
+
|
|
107
|
+
### CSS import
|
|
108
|
+
|
|
109
|
+
Import the base stylesheet **once** at the entry of your app (e.g. `main.tsx` / `src/index.tsx`):
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import "tempest-react-sdk/styles.css";
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This injects the design tokens (`--tempest-primary`, `--tempest-radius-md`, ...), a minimal CSS reset, and the per-component CSS Modules. Tokens live on `:root` and on `[data-tempest-theme="dark"]`, so the app can override them globally or per subtree (see [Theming](#theming-reference)).
|
|
116
|
+
|
|
117
|
+
The styles ship hashed under the `tempest_` namespace — they do **not** collide with Tailwind, Stitches, Linaria, or app-level CSS Modules.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## What's inside
|
|
122
|
+
|
|
123
|
+
Every module is re-exported from the package root — `import { Button, useDebounce, createApiClient } from "tempest-react-sdk"` always works.
|
|
124
|
+
|
|
125
|
+
| Module | Exports |
|
|
126
|
+
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
127
|
+
| `components` | `Avatar`, `Badge`, `Breadcrumbs`, `Button`, `Card`, `Checkbox`, `ChipInput`, `ConfirmDialog`, `Container`, `DatePicker`, `Drawer`, `EmptyState`, `ErrorState`, `FileUpload`, `Grid`, `Input`, `Modal`, `Pagination`, `Progress`, `Radio`, `RadioGroup`, `SearchBar`, `Select`, `Skeleton`, `Spinner`, `Stack`, `Stepper`, `Switch`, `Table`, `Tabs`, `Textarea`, `Toast` (`ToastProvider`, `useToast`), `Tooltip`, `VirtualList` |
|
|
128
|
+
| `hooks` | `useDebounce`, `usePagination`, `useClientFilter`, `useMediaQuery`, `useOnline`, `useDocumentVisibility`, `useIntersectionObserver`, `useResizeObserver`, `useClipboard`, `useKeyboardShortcut`, `useBeforeInstallPrompt`, `useIdle`, `useGeolocation`, `useScrollLock`, `useFocusTrap`, `useStableCallback`, `useDeepMemo` |
|
|
129
|
+
| `http` | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `generateIdempotencyKey`, `usePoll`, types: `ApiClient`, `ApiClientConfig`, `ApiError`, `RequestOptions`, `RetryOptions`, `UploadProgressEvent`, `UploadWithProgressOptions`, `UsePollOptions`, `UsePollResult` |
|
|
130
|
+
| `auth` _(peer: `zustand`)_ | `createAuthStore`, `AuthGuard`, `decodeJWT`, `isJWTExpired`, `lazyWithRetry`, `createRefreshQueue`, types: `AuthState`, `CreateAuthStoreOptions`, `AuthGuardProps`, `DecodedJWT`, `LazyWithRetryOptions` |
|
|
131
|
+
| `query` _(peer: `@tanstack/react-query`)_ | `QueryProvider`, `createQueryKeys`, `STALE_TIME`, `CACHE_TIME`, `REFETCH_TIME` |
|
|
132
|
+
| `forms` _(peer: `zod`, `react-hook-form`)_ | `validateForm`, `zodResolver`, `useZodForm`, `validateCPF`, `validateCNPJ`, `formatCEP`, `formatCNPJ`, `unmask`, `CPFInput`, `CNPJInput`, `PhoneInput`, `CEPInput`, `MoneyInput`, `useViaCEP` |
|
|
133
|
+
| `sse` | `createEventStream`, `useEventStream` |
|
|
134
|
+
| `ws` | `createWebSocket`, `useWebSocket` |
|
|
135
|
+
| `push` | `WebPushClient`, `WebPushUnsupportedError`, `WebPushPermissionDeniedError`, `usePushSubscription`, `urlBase64ToUint8Array`, `isPushSupported` |
|
|
136
|
+
| `sw` | `registerServiceWorker`, `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener` |
|
|
137
|
+
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
|
|
138
|
+
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions` |
|
|
139
|
+
| `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
|
|
140
|
+
| `theme` | `ThemeProvider`, `useTheme`, `getInitialTheme`, `themeInitScript`, types: `ThemeMode`, `ResolvedTheme` |
|
|
141
|
+
| `i18n` | `createI18n`, `I18nProvider`, `useI18n`, `useTranslate`, types: `Catalog`, `Messages`, `I18n`, `InterpolationValues` |
|
|
142
|
+
| `logger` | `createLogger`, `consoleSink`, types: `Logger`, `LogEntry`, `LogLevel`, `LoggerSink` |
|
|
143
|
+
| `telemetry` | `TelemetryProvider`, `useTelemetry`, `consoleTelemetryAdapter`, types: `TelemetryAdapter`, `TelemetryEvent`, `TelemetryUser` |
|
|
144
|
+
| `feature-flags` | `FeatureFlagsProvider`, `useFeatureFlag`, `useFlagValue`, `createInMemoryFlags`, types: `FeatureFlagsAdapter`, `FlagValue` |
|
|
145
|
+
| `share` | `share`, `isShareSupported`, types: `SharePayload`, `ShareResult` |
|
|
146
|
+
| `utils` | `cn`, `formatCurrency`, `formatDate`, `formatDateTime`, `formatPhone`, `formatCPF`, `formatPercent`, `storage` |
|
|
147
|
+
|
|
148
|
+
Full per-module docs in [`docs/`](./docs) (one markdown per module + draw.io diagrams in [`docs/diagrams/`](./docs/diagrams)).
|
|
149
|
+
|
|
150
|
+
A demo app exercising every module lives in [`examples/gallery`](./examples/gallery) — `cd examples/gallery && npm install && npm run dev`.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Architecture overview
|
|
155
|
+
|
|
156
|
+
The SDK is a layered set of building blocks. Apps wire the layers together; the SDK never owns the app shell.
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
160
|
+
│ App entry (main.tsx) │
|
|
161
|
+
│ ├── import "tempest-react-sdk/styles.css" │
|
|
162
|
+
│ └── <ThemeProvider> │
|
|
163
|
+
│ <I18nProvider> │
|
|
164
|
+
│ <FeatureFlagsProvider> │
|
|
165
|
+
│ <TelemetryProvider> │
|
|
166
|
+
│ <QueryProvider> │
|
|
167
|
+
│ <ToastProvider> │
|
|
168
|
+
│ <ErrorBoundary> │
|
|
169
|
+
│ <RouterProvider … /> │
|
|
170
|
+
└─────────────────────────────┬────────────────────────────────┘
|
|
171
|
+
│
|
|
172
|
+
┌───────────────┼───────────────┐
|
|
173
|
+
▼ ▼ ▼
|
|
174
|
+
┌────────────┐ ┌────────────┐ ┌────────────┐
|
|
175
|
+
│ Pages │ │ Features │ │ Layouts │
|
|
176
|
+
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
|
177
|
+
│ │ │
|
|
178
|
+
└───────┬───────┴───────┬───────┘
|
|
179
|
+
▼ ▼
|
|
180
|
+
┌────────────────┐ ┌────────────────┐
|
|
181
|
+
│ Components + │ │ Hooks + │
|
|
182
|
+
│ Forms │ │ Stores │
|
|
183
|
+
└───────┬────────┘ └───────┬────────┘
|
|
184
|
+
│ │
|
|
185
|
+
└────────┬─────────┘
|
|
186
|
+
▼
|
|
187
|
+
┌────────────────────────┐
|
|
188
|
+
│ HTTP client + zod + │
|
|
189
|
+
│ SSE / WS / WebPush / │
|
|
190
|
+
│ Offline / Audio │
|
|
191
|
+
└────────────────────────┘
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- **Presentation layer** (`components`): purely visual; uncontrolled-or-controlled, accessible by default, themable via CSS tokens.
|
|
195
|
+
- **Behaviour layer** (`hooks`, `forms`, `auth`, `error-boundary`): React-aware helpers that orchestrate state.
|
|
196
|
+
- **Transport layer** (`http`, `sse`, `ws`, `push`, `sw`): everything that touches the network or the service worker.
|
|
197
|
+
- **Persistence layer** (`offline`, `auth` persist, `utils/storage`, `i18n` persist): client-side storage abstractions.
|
|
198
|
+
- **Observability layer** (`telemetry`, `logger`, `error-boundary` `onError`): everywhere the app reports on itself.
|
|
199
|
+
|
|
200
|
+
The SDK ships **no `App.tsx`, no router, no global store**. Each layer is opt-in.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Quickstart — wiring the app providers
|
|
205
|
+
|
|
206
|
+
A minimal `main.tsx` for an app using HTTP + Query + Auth + Toast + Theme + i18n:
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
import { StrictMode } from "react";
|
|
210
|
+
import { createRoot } from "react-dom/client";
|
|
211
|
+
import { BrowserRouter } from "react-router-dom";
|
|
212
|
+
import {
|
|
213
|
+
ThemeProvider,
|
|
214
|
+
I18nProvider,
|
|
215
|
+
QueryProvider,
|
|
216
|
+
ToastProvider,
|
|
217
|
+
ErrorBoundary,
|
|
218
|
+
ErrorState,
|
|
219
|
+
createI18n,
|
|
220
|
+
themeInitScript,
|
|
221
|
+
} from "tempest-react-sdk";
|
|
222
|
+
|
|
223
|
+
import "tempest-react-sdk/styles.css";
|
|
224
|
+
import { App } from "./App";
|
|
225
|
+
|
|
226
|
+
const i18n = createI18n({
|
|
227
|
+
locale: "pt-BR",
|
|
228
|
+
fallback: "en",
|
|
229
|
+
messages: {
|
|
230
|
+
"pt-BR": { hello: "Olá, {name}" },
|
|
231
|
+
en: { hello: "Hello, {name}" },
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// No-flash dark mode script. Inject in <head> via index.html, OR call here:
|
|
236
|
+
document.head.insertAdjacentHTML("afterbegin", `<script>${themeInitScript()}</script>`);
|
|
237
|
+
|
|
238
|
+
createRoot(document.getElementById("root")!).render(
|
|
239
|
+
<StrictMode>
|
|
240
|
+
<ThemeProvider>
|
|
241
|
+
<I18nProvider i18n={i18n}>
|
|
242
|
+
<QueryProvider>
|
|
243
|
+
<ToastProvider>
|
|
244
|
+
<ErrorBoundary
|
|
245
|
+
fallback={({ error, reset }) => (
|
|
246
|
+
<ErrorState description={error.message} onRetry={reset} />
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<BrowserRouter>
|
|
250
|
+
<App />
|
|
251
|
+
</BrowserRouter>
|
|
252
|
+
</ErrorBoundary>
|
|
253
|
+
</ToastProvider>
|
|
254
|
+
</QueryProvider>
|
|
255
|
+
</I18nProvider>
|
|
256
|
+
</ThemeProvider>
|
|
257
|
+
</StrictMode>,
|
|
258
|
+
);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
You only wrap with what you use — every provider is independent. The example above is the maximal case.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Recipes
|
|
266
|
+
|
|
267
|
+
Each recipe is self-contained. Pick the ones you need.
|
|
268
|
+
|
|
269
|
+
### HTTP client recipe
|
|
270
|
+
|
|
271
|
+
`createApiClient` returns a typed `ApiClient` instance with `.get` / `.post` / `.put` / `.patch` / `.delete` methods, a typed `ApiError`, automatic JSON serialization, `AbortSignal` support, and pluggable `getToken` / `onUnauthorized` hooks.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { createApiClient } from "tempest-react-sdk";
|
|
275
|
+
import { useAuthStore } from "@/store/auth";
|
|
276
|
+
|
|
277
|
+
export const api = createApiClient({
|
|
278
|
+
baseURL: import.meta.env.VITE_API_URL,
|
|
279
|
+
getToken: () => useAuthStore.getState().token,
|
|
280
|
+
onUnauthorized: () => useAuthStore.getState().logout(),
|
|
281
|
+
withCredentials: true,
|
|
282
|
+
defaultHeaders: { "X-App-Version": __APP_VERSION__ },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const user = await api.get<UserResponse>("/users/me");
|
|
286
|
+
await api.post("/orders", { body: { total: 100, items: [...] } });
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Every method throws `ApiError` (`status`, `body`, `url`, `code`) on non-2xx responses. `onUnauthorized` is called automatically on 401, before the error is thrown — so you can refresh-and-retry or sign out as you choose.
|
|
290
|
+
|
|
291
|
+
### Response parsing with zod recipe
|
|
292
|
+
|
|
293
|
+
The HTTP client returns untyped JSON (`unknown`). `parseResponse` validates against a zod schema and gives you a `ZodError`-aware failure message tied to the request.
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
import { createApiClient, parseResponse } from "tempest-react-sdk";
|
|
297
|
+
import { z } from "zod";
|
|
298
|
+
|
|
299
|
+
const userSchema = z.object({
|
|
300
|
+
id: z.string(),
|
|
301
|
+
name: z.string(),
|
|
302
|
+
email: z.string().email(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
export async function getUser(id: string) {
|
|
306
|
+
const raw = await api.get<unknown>(`/users/${id}`);
|
|
307
|
+
return parseResponse(userSchema, raw, `GET /users/${id}`);
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
On validation failure `parseResponse` throws an `Error` whose `message` includes the request label and the zod issues — exactly the diagnostic you want during a wire-protocol drift.
|
|
312
|
+
|
|
313
|
+
### Upload with progress recipe
|
|
314
|
+
|
|
315
|
+
`fetch` cannot report upload progress in browsers. `uploadWithProgress` falls back to `XMLHttpRequest` internally while keeping the same `ApiError` contract:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import { uploadWithProgress } from "tempest-react-sdk";
|
|
319
|
+
|
|
320
|
+
const formData = new FormData();
|
|
321
|
+
formData.append("file", file);
|
|
322
|
+
formData.append("alo_id", aloId);
|
|
323
|
+
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
|
|
326
|
+
await uploadWithProgress<{ url: string }>({
|
|
327
|
+
url: `${API}/uploads`,
|
|
328
|
+
method: "POST",
|
|
329
|
+
body: formData,
|
|
330
|
+
withCredentials: true,
|
|
331
|
+
getToken: () => useAuthStore.getState().token,
|
|
332
|
+
signal: controller.signal,
|
|
333
|
+
onProgress: ({ fraction, loaded, total }) => {
|
|
334
|
+
if (fraction !== null) setProgress(Math.round(fraction * 100));
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Cancel:
|
|
339
|
+
controller.abort();
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`fraction` is `null` when `total` is unknown (chunked / unsized uploads).
|
|
343
|
+
|
|
344
|
+
### Polling recipe
|
|
345
|
+
|
|
346
|
+
`usePoll` runs a callback on an interval, pauses while the tab is hidden, and exposes start/stop controls. Use it for "kind-of-realtime" data when you don't need a socket.
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
import { usePoll } from "tempest-react-sdk";
|
|
350
|
+
|
|
351
|
+
function ServerStatus() {
|
|
352
|
+
const poll = usePoll({
|
|
353
|
+
interval: 5_000,
|
|
354
|
+
callback: async () => {
|
|
355
|
+
const data = await api.get<Status>("/status");
|
|
356
|
+
setStatus(data);
|
|
357
|
+
},
|
|
358
|
+
immediate: true,
|
|
359
|
+
pauseWhenHidden: true,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<Button onClick={poll.running ? poll.stop : poll.start}>
|
|
364
|
+
{poll.running ? "Pause" : "Resume"}
|
|
365
|
+
</Button>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Retry & idempotency recipe
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { retry, generateIdempotencyKey } from "tempest-react-sdk";
|
|
374
|
+
|
|
375
|
+
const idempotencyKey = generateIdempotencyKey();
|
|
376
|
+
|
|
377
|
+
const result = await retry(
|
|
378
|
+
() =>
|
|
379
|
+
api.post("/payments", {
|
|
380
|
+
body: payload,
|
|
381
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
382
|
+
}),
|
|
383
|
+
{
|
|
384
|
+
retries: 3,
|
|
385
|
+
baseDelay: 400,
|
|
386
|
+
shouldRetry: (error) => error instanceof Error && /status (5\d\d|429)/.test(error.message),
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`generateIdempotencyKey` returns a v4 UUID using `crypto.randomUUID()` when available, with a `Math.random` fallback for older runtimes.
|
|
392
|
+
|
|
393
|
+
### Auth store recipe
|
|
394
|
+
|
|
395
|
+
`createAuthStore<TUser>()` returns a typed Zustand store with the `persist` middleware already wired. The app owns the user shape — the SDK only owns the state shape.
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
import { createAuthStore } from "tempest-react-sdk";
|
|
399
|
+
|
|
400
|
+
type SessionUser = { id: string; name: string; is_admin: boolean };
|
|
401
|
+
|
|
402
|
+
export const useAuthStore = createAuthStore<SessionUser>({
|
|
403
|
+
name: "tempest-app-auth",
|
|
404
|
+
storage: "local",
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Anywhere:
|
|
408
|
+
useAuthStore.getState().setSession({ user, token });
|
|
409
|
+
const isAuthed = useAuthStore((s) => s.isAuthenticated);
|
|
410
|
+
useAuthStore.getState().logout();
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
The store exposes `user`, `token`, `isAuthenticated`, `setSession`, `setUser`, `setToken`, and `logout`. `isAuthenticated` is derived from `token` and rehydrates correctly after page reload.
|
|
414
|
+
|
|
415
|
+
### Route guard recipe
|
|
416
|
+
|
|
417
|
+
```tsx
|
|
418
|
+
import { Navigate, Outlet } from "react-router-dom";
|
|
419
|
+
import { AuthGuard } from "tempest-react-sdk";
|
|
420
|
+
import { useAuthStore } from "@/store/auth";
|
|
421
|
+
|
|
422
|
+
export function ProtectedLayout() {
|
|
423
|
+
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
424
|
+
return (
|
|
425
|
+
<AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" replace />}>
|
|
426
|
+
<Outlet />
|
|
427
|
+
</AuthGuard>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
`AuthGuard` is a pure render gate — no router coupling. Use any redirect mechanism (`react-router`, `next/navigation`, `wouter`, ...).
|
|
433
|
+
|
|
434
|
+
### JWT helpers & refresh queue recipe
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { decodeJWT, isJWTExpired, createRefreshQueue } from "tempest-react-sdk";
|
|
438
|
+
|
|
439
|
+
const decoded = decodeJWT<{ sub: string; exp: number; role: string }>(token);
|
|
440
|
+
const expired = isJWTExpired(token, 30); // 30-second skew
|
|
441
|
+
|
|
442
|
+
// Coalesce concurrent refreshes so only one network call runs at a time:
|
|
443
|
+
const refresh = createRefreshQueue(async () => {
|
|
444
|
+
const newToken = await api.post<{ token: string }>("/auth/refresh");
|
|
445
|
+
useAuthStore.getState().setToken(newToken.token);
|
|
446
|
+
return newToken.token;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Two concurrent calls → one request, both get the same resolved value:
|
|
450
|
+
await Promise.all([refresh(), refresh()]);
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Code-splitting with retry recipe
|
|
454
|
+
|
|
455
|
+
`lazyWithRetry` wraps `React.lazy` so that a stale chunk error retries with exponential backoff instead of crashing the page. A common cause is users on a tab with an old `index.html` after a deploy — the retry usually picks up the new bundle; a final `location.reload()` recovers from a stale `index.html`.
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
import { lazyWithRetry } from "tempest-react-sdk";
|
|
459
|
+
|
|
460
|
+
const Settings = lazyWithRetry(() => import("./Settings"), {
|
|
461
|
+
retries: 3,
|
|
462
|
+
initialDelay: 400,
|
|
463
|
+
reloadOnFinalFailure: true,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
<Route
|
|
467
|
+
path="/settings"
|
|
468
|
+
element={
|
|
469
|
+
<Suspense fallback={<Spinner />}>
|
|
470
|
+
<Settings />
|
|
471
|
+
</Suspense>
|
|
472
|
+
}
|
|
473
|
+
/>;
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### React Query recipe
|
|
477
|
+
|
|
478
|
+
```tsx
|
|
479
|
+
import { QueryProvider, createQueryKeys, STALE_TIME } from "tempest-react-sdk";
|
|
480
|
+
|
|
481
|
+
export function AppProviders({ children }: { children: React.ReactNode }) {
|
|
482
|
+
return (
|
|
483
|
+
<QueryProvider defaultOptions={{ queries: { staleTime: STALE_TIME.MEDIUM } }}>
|
|
484
|
+
{children}
|
|
485
|
+
</QueryProvider>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`STALE_TIME`, `CACHE_TIME`, and `REFETCH_TIME` ship as named constants (`SHORT`, `MEDIUM`, `LONG`) so cache windows stay consistent across features.
|
|
491
|
+
|
|
492
|
+
Typed query-key factory:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { createQueryKeys } from "tempest-react-sdk";
|
|
496
|
+
|
|
497
|
+
export const eventKeys = createQueryKeys("event", {
|
|
498
|
+
all: ["all"] as const,
|
|
499
|
+
list: (filters: { page: number; size: number }) => ["list", filters] as const,
|
|
500
|
+
byId: (id: string) => [id] as const,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// eventKeys.list({ page: 1, size: 20 }) === ["event", "list", { page: 1, size: 20 }]
|
|
504
|
+
// eventKeys.byId("42") === ["event", "42"]
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Forms (zod) recipe
|
|
508
|
+
|
|
509
|
+
Three levels of integration — pick the one that fits the form complexity.
|
|
510
|
+
|
|
511
|
+
**1. Standalone validation** — independent of any form library:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
import { validateForm } from "tempest-react-sdk";
|
|
515
|
+
import { z } from "zod";
|
|
516
|
+
|
|
517
|
+
const schema = z.object({
|
|
518
|
+
email: z.string().email(),
|
|
519
|
+
password: z.string().min(8),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const result = validateForm(schema, formValues);
|
|
523
|
+
if (!result.success) {
|
|
524
|
+
setErrors(result.errors); // { email: "...", password: "..." }
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
await login(result.data);
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
**2. `react-hook-form` resolver** — drop-in replacement for `@hookform/resolvers/zod`:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
import { useForm } from "react-hook-form";
|
|
534
|
+
import { zodResolver } from "tempest-react-sdk";
|
|
535
|
+
|
|
536
|
+
const form = useForm<LoginForm>({ resolver: zodResolver(loginSchema) });
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**3. All-in-one hook** — the schema infers the form type:
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
import { useZodForm } from "tempest-react-sdk";
|
|
543
|
+
|
|
544
|
+
function LoginForm() {
|
|
545
|
+
const form = useZodForm(loginSchema, {
|
|
546
|
+
defaultValues: { email: "", password: "" },
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<form onSubmit={form.handleSubmit((data) => login(data))}>
|
|
551
|
+
<Input {...form.register("email")} label="Email" />
|
|
552
|
+
<Input {...form.register("password")} type="password" label="Senha" />
|
|
553
|
+
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
554
|
+
Entrar
|
|
555
|
+
</Button>
|
|
556
|
+
</form>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
`react-hook-form` is an **optional peer dep** — only install when you use `zodResolver` or `useZodForm`.
|
|
562
|
+
|
|
563
|
+
### BR validators & masked inputs recipe
|
|
564
|
+
|
|
565
|
+
Algorithmic validators (full check-digit math, rejects all-same-digit edge cases) plus masked inputs that play nicely with `react-hook-form`:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
import { validateCPF, validateCNPJ, formatCEP, formatCNPJ, unmask } from "tempest-react-sdk";
|
|
569
|
+
|
|
570
|
+
validateCPF("000.000.000-00"); // false (all-same)
|
|
571
|
+
validateCPF("12345678909"); // true
|
|
572
|
+
validateCNPJ("11.222.333/0001-81"); // true
|
|
573
|
+
formatCEP("01001000"); // "01001-000"
|
|
574
|
+
unmask("(11) 99876-5432"); // "11998765432"
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
```tsx
|
|
578
|
+
import { CPFInput, PhoneInput, CEPInput, CNPJInput, MoneyInput } from "tempest-react-sdk";
|
|
579
|
+
import { Controller, useForm } from "react-hook-form";
|
|
580
|
+
|
|
581
|
+
function CheckoutForm() {
|
|
582
|
+
const { control, register } = useForm<Checkout>();
|
|
583
|
+
return (
|
|
584
|
+
<>
|
|
585
|
+
<CPFInput {...register("cpf")} label="CPF" />
|
|
586
|
+
<PhoneInput {...register("phone")} label="Telefone" />
|
|
587
|
+
<CEPInput {...register("cep")} label="CEP" />
|
|
588
|
+
<Controller
|
|
589
|
+
name="total"
|
|
590
|
+
control={control}
|
|
591
|
+
render={({ field }) => <MoneyInput {...field} label="Total" currency="BRL" />}
|
|
592
|
+
/>
|
|
593
|
+
</>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
`MoneyInput` exposes a numeric value to your form state while rendering a formatted string in the input.
|
|
599
|
+
|
|
600
|
+
### ViaCEP lookup recipe
|
|
601
|
+
|
|
602
|
+
```tsx
|
|
603
|
+
import { useViaCEP } from "tempest-react-sdk";
|
|
604
|
+
|
|
605
|
+
function AddressFields({ form }: { form: UseFormReturn<Address> }) {
|
|
606
|
+
const cep = form.watch("cep");
|
|
607
|
+
const { result, loading, error } = useViaCEP(cep);
|
|
608
|
+
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
if (result) {
|
|
611
|
+
form.setValue("street", result.logradouro);
|
|
612
|
+
form.setValue("neighborhood", result.bairro);
|
|
613
|
+
form.setValue("city", result.localidade);
|
|
614
|
+
form.setValue("state", result.uf);
|
|
615
|
+
}
|
|
616
|
+
}, [result]);
|
|
617
|
+
|
|
618
|
+
return <CEPInput {...form.register("cep")} loading={loading} error={error?.message} />;
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
`useViaCEP` debounces requests, caches by CEP, and ignores partial input (`length < 8`).
|
|
623
|
+
|
|
624
|
+
### WebSocket recipe
|
|
625
|
+
|
|
626
|
+
Wrapper around `WebSocket` with exponential reconnect (up to 10 attempts), optional ping heartbeat, JSON parsing, and `send` that no-ops while the socket isn't open.
|
|
627
|
+
|
|
628
|
+
```tsx
|
|
629
|
+
import { useWebSocket } from "tempest-react-sdk";
|
|
630
|
+
|
|
631
|
+
type ChatEvent = { type: "message"; user: string; text: string };
|
|
632
|
+
|
|
633
|
+
function Chat({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
|
|
634
|
+
const ws = useWebSocket<ChatEvent>(`${apiUrl}/chat`, {
|
|
635
|
+
enabled,
|
|
636
|
+
pingInterval: 30_000,
|
|
637
|
+
onMessage: ({ data }) => console.log(data),
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<button disabled={ws.status !== "open"} onClick={() => ws.send(JSON.stringify({ text: "hi" }))}>
|
|
642
|
+
Enviar
|
|
643
|
+
</button>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
Imperative (outside React):
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
import { createWebSocket } from "tempest-react-sdk";
|
|
652
|
+
|
|
653
|
+
const socket = createWebSocket(`${apiUrl}/chat`, {
|
|
654
|
+
pingInterval: 30_000,
|
|
655
|
+
onMessage: ({ data }) => console.log(data),
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
socket.send("hello");
|
|
659
|
+
socket.close();
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Server-Sent Events (SSE) recipe
|
|
663
|
+
|
|
664
|
+
Stream with exponential reconnect (up to 10 attempts), `ping` heartbeat, JSON parsing by default. For cookie-auth endpoints, pass `withCredentials: true`.
|
|
665
|
+
|
|
666
|
+
```tsx
|
|
667
|
+
import { useEventStream } from "tempest-react-sdk";
|
|
668
|
+
import { useNotificationsStore } from "@/store/notifications";
|
|
669
|
+
|
|
670
|
+
type StreamEvent =
|
|
671
|
+
| { type: "NOTIFY"; message: string }
|
|
672
|
+
| { type: "PAYMENT-SUCCESS"; order_id: string }
|
|
673
|
+
| { type: "PING" };
|
|
674
|
+
|
|
675
|
+
export function NotificationsListener({ apiUrl, enabled }: { apiUrl: string; enabled: boolean }) {
|
|
676
|
+
const add = useNotificationsStore((s) => s.add);
|
|
677
|
+
|
|
678
|
+
useEventStream<StreamEvent>(`${apiUrl}/notifications/stream`, {
|
|
679
|
+
enabled,
|
|
680
|
+
withCredentials: true,
|
|
681
|
+
namedEvents: ["notification", "payment"],
|
|
682
|
+
onMessage: ({ data }) => {
|
|
683
|
+
if (data.type === "PING") return;
|
|
684
|
+
add(data);
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Imperative form (e.g., outside React, in a worker, in tests):
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
import { createEventStream } from "tempest-react-sdk";
|
|
696
|
+
|
|
697
|
+
const stream = createEventStream(`${apiUrl}/notifications/stream`, {
|
|
698
|
+
withCredentials: true,
|
|
699
|
+
onMessage: ({ data }) => console.log(data),
|
|
700
|
+
onError: (event) => console.warn("SSE error", event),
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
stream.close();
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### Web Push recipe
|
|
707
|
+
|
|
708
|
+
The SDK owns the **browser-side flow**: permission, `pushManager.subscribe`, reading the active subscription, and unsubscribing. The **endpoint** is your responsibility — you supply `onSubscribe` / `onUnsubscribe` callbacks that POST/DELETE to your API.
|
|
709
|
+
|
|
710
|
+
Pre-requisite: register the service worker before calling `subscribe()` (via `vite-plugin-pwa`, `registerServiceWorker`, or raw `navigator.serviceWorker.register`).
|
|
711
|
+
|
|
712
|
+
```tsx
|
|
713
|
+
import { usePushSubscription, Button } from "tempest-react-sdk";
|
|
714
|
+
import { api } from "@/services/api";
|
|
715
|
+
|
|
716
|
+
export function PushToggle() {
|
|
717
|
+
const push = usePushSubscription({
|
|
718
|
+
vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
|
|
719
|
+
onSubscribe: (subscription) => api.post("/webpush/subscribe", { body: subscription }),
|
|
720
|
+
onUnsubscribe: () => api.delete("/webpush/my"),
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
if (!push.supported) return <p>Push não suportado neste navegador.</p>;
|
|
724
|
+
|
|
725
|
+
return (
|
|
726
|
+
<Button
|
|
727
|
+
loading={push.loading}
|
|
728
|
+
variant={push.subscribed ? "danger" : "primary"}
|
|
729
|
+
onClick={() => (push.subscribed ? push.unsubscribe() : push.subscribe())}
|
|
730
|
+
>
|
|
731
|
+
{push.subscribed ? "Desinscrever notificações" : "Receber notificações"}
|
|
732
|
+
</Button>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
Imperative version:
|
|
738
|
+
|
|
739
|
+
```ts
|
|
740
|
+
import { WebPushClient } from "tempest-react-sdk";
|
|
741
|
+
|
|
742
|
+
const push = new WebPushClient({
|
|
743
|
+
vapidPublicKey: VAPID_PUBLIC_KEY,
|
|
744
|
+
onSubscribe: (sub) => api.post("/webpush/subscribe", { body: sub }),
|
|
745
|
+
onUnsubscribe: () => api.delete("/webpush/my"),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await push.subscribe();
|
|
749
|
+
const active = await push.isSubscribed();
|
|
750
|
+
await push.unsubscribe();
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Errors thrown: `WebPushUnsupportedError`, `WebPushPermissionDeniedError`. Anything else bubbles up as the underlying browser exception.
|
|
754
|
+
|
|
755
|
+
### Service Worker helpers recipe
|
|
756
|
+
|
|
757
|
+
**Main thread** — register the SW, react to updates, expose a "skip waiting" hook:
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
import { registerServiceWorker, skipWaiting } from "tempest-react-sdk";
|
|
761
|
+
|
|
762
|
+
registerServiceWorker({
|
|
763
|
+
url: "/sw.js",
|
|
764
|
+
onUpdate: (waiting) => {
|
|
765
|
+
if (confirm("Nova versão disponível. Recarregar?")) {
|
|
766
|
+
skipWaiting(waiting);
|
|
767
|
+
window.location.reload();
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
onError: (err) => console.error("SW falhou", err),
|
|
771
|
+
});
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
**Worker thread** — inside `sw.ts`/`sw.js`, install handlers for push, notification click, and the skip-waiting message:
|
|
775
|
+
|
|
776
|
+
```ts
|
|
777
|
+
/// <reference lib="webworker" />
|
|
778
|
+
import {
|
|
779
|
+
installPushHandler,
|
|
780
|
+
installNotificationClickHandler,
|
|
781
|
+
installSkipWaitingListener,
|
|
782
|
+
} from "tempest-react-sdk";
|
|
783
|
+
|
|
784
|
+
installSkipWaitingListener();
|
|
785
|
+
|
|
786
|
+
installPushHandler({
|
|
787
|
+
defaultTitle: "Tempest",
|
|
788
|
+
defaultIcon: "/icons/Logo.png",
|
|
789
|
+
transform: (payload) => {
|
|
790
|
+
if (payload.tag === "silent-ping") return null; // drop silently
|
|
791
|
+
return payload;
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
installNotificationClickHandler({
|
|
796
|
+
focusOrOpenWindow: true,
|
|
797
|
+
fallbackUrl: "/",
|
|
798
|
+
});
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
The SDK does **not** ship the worker file or the bundler config — pair it with `vite-plugin-pwa` (`injectManifest`) or a separately bundled worker.
|
|
802
|
+
|
|
803
|
+
### Audio playback recipe
|
|
804
|
+
|
|
805
|
+
`playAudio` for one-shot sounds (notification chime, payment success), `createAudioPlayer` for isolated channels, `useAudio` for a per-component player.
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
import { playAudio } from "tempest-react-sdk";
|
|
809
|
+
|
|
810
|
+
await playAudio("/audio/plim.wav", { volume: 0.4 });
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
```tsx
|
|
814
|
+
import { useAudio } from "tempest-react-sdk";
|
|
815
|
+
|
|
816
|
+
function NotifyBell() {
|
|
817
|
+
const audio = useAudio();
|
|
818
|
+
return <button onClick={() => audio.play("/audio/bell.wav")}>Notify</button>;
|
|
819
|
+
}
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
Browsers block autoplay until the user interacts with the page. `playAudio` resolves to `null` when blocked — the UI is expected to "unlock" on first click.
|
|
823
|
+
|
|
824
|
+
### Offline storage (IndexedDB / Dexie) recipe
|
|
825
|
+
|
|
826
|
+
Owner-scoped store per domain. Persists SSE history, drafts, offline cache. `dexie` is an **optional peer** — `npm i dexie` only when you import this module.
|
|
827
|
+
|
|
828
|
+
```ts
|
|
829
|
+
import { createOfflineStore } from "tempest-react-sdk";
|
|
830
|
+
|
|
831
|
+
type Notification = {
|
|
832
|
+
message_id: string;
|
|
833
|
+
owner_id: string;
|
|
834
|
+
type: "NOTIFY" | "PAYMENT-SUCCESS";
|
|
835
|
+
message: string;
|
|
836
|
+
created_at: string;
|
|
837
|
+
read: boolean;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
export const notificationsStore = createOfflineStore<Notification, string>({
|
|
841
|
+
databaseName: "TempestNotifications",
|
|
842
|
+
version: 1,
|
|
843
|
+
tableName: "notifications",
|
|
844
|
+
indexes: "&message_id, owner_id, read, created_at",
|
|
845
|
+
keyPath: "message_id",
|
|
846
|
+
ownerField: "owner_id",
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
await notificationsStore.put(
|
|
850
|
+
{
|
|
851
|
+
/* … */
|
|
852
|
+
} as Notification,
|
|
853
|
+
"u1",
|
|
854
|
+
);
|
|
855
|
+
const items = await notificationsStore.list("u1", {
|
|
856
|
+
orderBy: "created_at",
|
|
857
|
+
reverse: true,
|
|
858
|
+
limit: 50,
|
|
859
|
+
});
|
|
860
|
+
await notificationsStore.updateMany("u1", { read: true });
|
|
861
|
+
await notificationsStore.clear("u1");
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
API: `put` / `bulkPut` / `get` / `list` / `update` / `updateMany` / `delete` / `clear` / `count`. `raw` (Dexie table) and `db` (Dexie instance) are exposed for advanced queries.
|
|
865
|
+
|
|
866
|
+
### Error boundary recipe
|
|
867
|
+
|
|
868
|
+
Renders a fallback (static element or render-prop), auto-resets via `resetKeys`, forwards errors to `onError` for telemetry. `useErrorHandler` re-throws async errors inside the nearest boundary.
|
|
869
|
+
|
|
870
|
+
```tsx
|
|
871
|
+
import { ErrorBoundary, ErrorState } from "tempest-react-sdk";
|
|
872
|
+
import { useLocation } from "react-router-dom";
|
|
873
|
+
|
|
874
|
+
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
875
|
+
const location = useLocation();
|
|
876
|
+
return (
|
|
877
|
+
<ErrorBoundary
|
|
878
|
+
resetKeys={[location.pathname]}
|
|
879
|
+
onError={(err, info) => reportToSentry(err, info)}
|
|
880
|
+
fallback={({ error, reset }) => <ErrorState description={error.message} onRetry={reset} />}
|
|
881
|
+
>
|
|
882
|
+
{children}
|
|
883
|
+
</ErrorBoundary>
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
```tsx
|
|
889
|
+
import { useErrorHandler } from "tempest-react-sdk";
|
|
890
|
+
|
|
891
|
+
function Streamer() {
|
|
892
|
+
const throwError = useErrorHandler();
|
|
893
|
+
useEffect(() => {
|
|
894
|
+
const stream = openSocket();
|
|
895
|
+
stream.onerror = (err) => throwError(err);
|
|
896
|
+
return () => stream.close();
|
|
897
|
+
}, [throwError]);
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
### Toast notifications recipe
|
|
903
|
+
|
|
904
|
+
```tsx
|
|
905
|
+
import { ToastProvider, useToast, Button } from "tempest-react-sdk";
|
|
906
|
+
|
|
907
|
+
// Wrap app once (already in the Quickstart):
|
|
908
|
+
<ToastProvider placement="top-right" duration={4000}>
|
|
909
|
+
<App />
|
|
910
|
+
</ToastProvider>;
|
|
911
|
+
|
|
912
|
+
// Use anywhere:
|
|
913
|
+
function SaveButton() {
|
|
914
|
+
const toast = useToast();
|
|
915
|
+
return (
|
|
916
|
+
<Button
|
|
917
|
+
onClick={async () => {
|
|
918
|
+
try {
|
|
919
|
+
await save();
|
|
920
|
+
toast.success("Alterações salvas");
|
|
921
|
+
} catch (error) {
|
|
922
|
+
toast.error(String(error));
|
|
923
|
+
}
|
|
924
|
+
}}
|
|
925
|
+
>
|
|
926
|
+
Salvar
|
|
927
|
+
</Button>
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
`useToast()` returns `{ show, success, info, warning, error, dismiss }` — `show` takes the full `ToastOptions`.
|
|
933
|
+
|
|
934
|
+
### Modal & ConfirmDialog recipe
|
|
935
|
+
|
|
936
|
+
```tsx
|
|
937
|
+
import { useState } from "react";
|
|
938
|
+
import { Modal, ConfirmDialog, Button } from "tempest-react-sdk";
|
|
939
|
+
|
|
940
|
+
function DeleteUser({ user }: { user: User }) {
|
|
941
|
+
const [open, setOpen] = useState(false);
|
|
942
|
+
return (
|
|
943
|
+
<>
|
|
944
|
+
<Button variant="danger" onClick={() => setOpen(true)}>
|
|
945
|
+
Excluir
|
|
946
|
+
</Button>
|
|
947
|
+
<ConfirmDialog
|
|
948
|
+
open={open}
|
|
949
|
+
title="Excluir usuário"
|
|
950
|
+
description={`Esta ação é permanente. Excluir ${user.name}?`}
|
|
951
|
+
confirmLabel="Sim, excluir"
|
|
952
|
+
cancelLabel="Cancelar"
|
|
953
|
+
tone="danger"
|
|
954
|
+
onConfirm={async () => {
|
|
955
|
+
await deleteUser(user.id);
|
|
956
|
+
setOpen(false);
|
|
957
|
+
}}
|
|
958
|
+
onCancel={() => setOpen(false)}
|
|
959
|
+
/>
|
|
960
|
+
</>
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
`Modal` is the lower-level primitive (focus trap + scroll lock + ESC to close + backdrop click). `ConfirmDialog` is the opinionated yes/no wrapper.
|
|
966
|
+
|
|
967
|
+
### Tables & pagination recipe
|
|
968
|
+
|
|
969
|
+
```tsx
|
|
970
|
+
import { Table, Pagination, usePagination } from "tempest-react-sdk";
|
|
971
|
+
|
|
972
|
+
const columns = [
|
|
973
|
+
{ key: "id", label: "ID", align: "right" as const },
|
|
974
|
+
{ key: "name", label: "Nome" },
|
|
975
|
+
{ key: "email", label: "Email" },
|
|
976
|
+
{
|
|
977
|
+
key: "actions",
|
|
978
|
+
label: "",
|
|
979
|
+
render: (row) => (
|
|
980
|
+
<Button size="sm" onClick={() => edit(row.id)}>
|
|
981
|
+
Editar
|
|
982
|
+
</Button>
|
|
983
|
+
),
|
|
984
|
+
},
|
|
985
|
+
];
|
|
986
|
+
|
|
987
|
+
function UsersList() {
|
|
988
|
+
const { page, size, setPage, setSize } = usePagination({ initialSize: 20 });
|
|
989
|
+
const { data } = useQuery({
|
|
990
|
+
queryKey: userKeys.list({ page, size }),
|
|
991
|
+
queryFn: () => api.get<Paginated<User>>(`/users?page=${page}&size=${size}`),
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
return (
|
|
995
|
+
<>
|
|
996
|
+
<Table columns={columns} rows={data?.items ?? []} loading={!data} />
|
|
997
|
+
<Pagination
|
|
998
|
+
page={page}
|
|
999
|
+
pageSize={size}
|
|
1000
|
+
total={data?.total ?? 0}
|
|
1001
|
+
onPageChange={setPage}
|
|
1002
|
+
onPageSizeChange={setSize}
|
|
1003
|
+
/>
|
|
1004
|
+
</>
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
### Layout primitives recipe
|
|
1010
|
+
|
|
1011
|
+
`Stack`, `Grid`, and `Container` are zero-dependency layout helpers (flex, grid, max-width).
|
|
1012
|
+
|
|
1013
|
+
```tsx
|
|
1014
|
+
import { Container, Stack, Grid, Card } from "tempest-react-sdk";
|
|
1015
|
+
|
|
1016
|
+
<Container size="lg">
|
|
1017
|
+
<Stack gap="md" direction="column">
|
|
1018
|
+
<h1>Dashboard</h1>
|
|
1019
|
+
<Grid columns={3} gap="md">
|
|
1020
|
+
<Card>Visitas</Card>
|
|
1021
|
+
<Card>Pedidos</Card>
|
|
1022
|
+
<Card>Receita</Card>
|
|
1023
|
+
</Grid>
|
|
1024
|
+
</Stack>
|
|
1025
|
+
</Container>;
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### Virtual list recipe
|
|
1029
|
+
|
|
1030
|
+
Render thousands of rows without blowing the DOM. Built on top of `useResizeObserver` — supports dynamic row heights.
|
|
1031
|
+
|
|
1032
|
+
```tsx
|
|
1033
|
+
import { VirtualList } from "tempest-react-sdk";
|
|
1034
|
+
|
|
1035
|
+
<VirtualList
|
|
1036
|
+
items={messages}
|
|
1037
|
+
estimatedItemHeight={64}
|
|
1038
|
+
overscan={5}
|
|
1039
|
+
renderItem={(message) => <MessageRow key={message.id} message={message} />}
|
|
1040
|
+
/>;
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### Theming (light / dark) recipe
|
|
1044
|
+
|
|
1045
|
+
```tsx
|
|
1046
|
+
import { ThemeProvider, useTheme } from "tempest-react-sdk";
|
|
1047
|
+
|
|
1048
|
+
<ThemeProvider defaultMode="system" persistKey="tempest-theme">
|
|
1049
|
+
<App />
|
|
1050
|
+
</ThemeProvider>;
|
|
1051
|
+
|
|
1052
|
+
function ThemeToggle() {
|
|
1053
|
+
const { mode, setMode, resolved } = useTheme();
|
|
1054
|
+
return (
|
|
1055
|
+
<select value={mode} onChange={(e) => setMode(e.target.value as ThemeMode)}>
|
|
1056
|
+
<option value="light">Claro</option>
|
|
1057
|
+
<option value="dark">Escuro</option>
|
|
1058
|
+
<option value="system">Sistema ({resolved})</option>
|
|
1059
|
+
</select>
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
`ThemeProvider` writes `data-tempest-theme="dark"` (or removes it) on the root element. To **avoid the white flash** on initial paint, inline `themeInitScript()` in `<head>` before the React bundle:
|
|
1065
|
+
|
|
1066
|
+
```html
|
|
1067
|
+
<script>
|
|
1068
|
+
__INIT_THEME__;
|
|
1069
|
+
</script>
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
```ts
|
|
1073
|
+
// build.ts
|
|
1074
|
+
import { themeInitScript } from "tempest-react-sdk";
|
|
1075
|
+
const html = template.replace("__INIT_THEME__", themeInitScript());
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### i18n recipe
|
|
1079
|
+
|
|
1080
|
+
Minimal in-house i18n (~1.5 KB gzip). Use this when you need light interpolation + a couple of locales; reach for `i18next` when you need plural rules, namespaces, or async loaders.
|
|
1081
|
+
|
|
1082
|
+
```ts
|
|
1083
|
+
import { createI18n } from "tempest-react-sdk";
|
|
1084
|
+
|
|
1085
|
+
const i18n = createI18n({
|
|
1086
|
+
locale: "pt-BR",
|
|
1087
|
+
fallback: "en",
|
|
1088
|
+
messages: {
|
|
1089
|
+
"pt-BR": {
|
|
1090
|
+
greeting: "Olá, {name}",
|
|
1091
|
+
inbox: { empty: "Caixa vazia" },
|
|
1092
|
+
},
|
|
1093
|
+
en: {
|
|
1094
|
+
greeting: "Hello, {name}",
|
|
1095
|
+
inbox: { empty: "Empty inbox" },
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
persistKey: "tempest-locale",
|
|
1099
|
+
});
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
```tsx
|
|
1103
|
+
import { I18nProvider, useTranslate, useI18n } from "tempest-react-sdk";
|
|
1104
|
+
|
|
1105
|
+
<I18nProvider i18n={i18n}>
|
|
1106
|
+
<App />
|
|
1107
|
+
</I18nProvider>;
|
|
1108
|
+
|
|
1109
|
+
function Header() {
|
|
1110
|
+
const t = useTranslate();
|
|
1111
|
+
const { locale, setLocale } = useI18n();
|
|
1112
|
+
return (
|
|
1113
|
+
<header>
|
|
1114
|
+
<span>{t("greeting", { name: "Mauricio" })}</span>
|
|
1115
|
+
<button onClick={() => setLocale(locale === "pt-BR" ? "en" : "pt-BR")}>
|
|
1116
|
+
{locale === "pt-BR" ? "EN" : "PT"}
|
|
1117
|
+
</button>
|
|
1118
|
+
</header>
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
### Feature flags recipe
|
|
1124
|
+
|
|
1125
|
+
`FeatureFlagsProvider` takes an `adapter` matching the `FeatureFlagsAdapter` interface (`isEnabled`, `get`, `subscribe`). Ship the `InMemory` adapter while you build, swap for GrowthBook / LaunchDarkly when you're ready.
|
|
1126
|
+
|
|
1127
|
+
```tsx
|
|
1128
|
+
import {
|
|
1129
|
+
FeatureFlagsProvider,
|
|
1130
|
+
useFeatureFlag,
|
|
1131
|
+
useFlagValue,
|
|
1132
|
+
createInMemoryFlags,
|
|
1133
|
+
} from "tempest-react-sdk";
|
|
1134
|
+
|
|
1135
|
+
const flags = createInMemoryFlags({
|
|
1136
|
+
flags: { "new-checkout": true, "max-items": 10 },
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
<FeatureFlagsProvider adapter={flags}>
|
|
1140
|
+
<App />
|
|
1141
|
+
</FeatureFlagsProvider>;
|
|
1142
|
+
|
|
1143
|
+
function CheckoutButton() {
|
|
1144
|
+
const isNew = useFeatureFlag("new-checkout");
|
|
1145
|
+
const maxItems = useFlagValue<number>("max-items", 5);
|
|
1146
|
+
return isNew ? <NewCheckout maxItems={maxItems} /> : <LegacyCheckout />;
|
|
1147
|
+
}
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
The interface is intentionally tiny — any third-party SDK can be wrapped into an adapter in ~20 lines.
|
|
1151
|
+
|
|
1152
|
+
### Telemetry recipe
|
|
1153
|
+
|
|
1154
|
+
`TelemetryProvider` accepts an adapter matching `TelemetryAdapter` (`identify`, `track`, `captureException`, `flush`). The default `consoleTelemetryAdapter` logs every event — useful for dev and tests.
|
|
1155
|
+
|
|
1156
|
+
```tsx
|
|
1157
|
+
import { TelemetryProvider, useTelemetry, consoleTelemetryAdapter } from "tempest-react-sdk";
|
|
1158
|
+
|
|
1159
|
+
<TelemetryProvider adapter={consoleTelemetryAdapter()}>
|
|
1160
|
+
<App />
|
|
1161
|
+
</TelemetryProvider>;
|
|
1162
|
+
|
|
1163
|
+
function CheckoutForm() {
|
|
1164
|
+
const telemetry = useTelemetry();
|
|
1165
|
+
return (
|
|
1166
|
+
<Button
|
|
1167
|
+
onClick={() => {
|
|
1168
|
+
telemetry.track("checkout.completed", { total: 100 });
|
|
1169
|
+
}}
|
|
1170
|
+
>
|
|
1171
|
+
Pagar
|
|
1172
|
+
</Button>
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
Concrete adapters for Sentry / PostHog / Datadog are part of the v0.2 roadmap — for now you can write one in ~20 lines (see [`docs/telemetry.md`](./docs/telemetry.md)).
|
|
1178
|
+
|
|
1179
|
+
### Logger recipe
|
|
1180
|
+
|
|
1181
|
+
Leveled logger with pluggable sinks:
|
|
1182
|
+
|
|
1183
|
+
```ts
|
|
1184
|
+
import { createLogger, consoleSink } from "tempest-react-sdk";
|
|
1185
|
+
|
|
1186
|
+
export const log = createLogger({
|
|
1187
|
+
level: "info",
|
|
1188
|
+
sinks: [consoleSink({ pretty: true })],
|
|
1189
|
+
context: { app: "alofans", version: __APP_VERSION__ },
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
log.info("user.signed-in", { user_id: user.id });
|
|
1193
|
+
log.error("payment.failed", { reason }, error);
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
A sink is any function `(entry: LogEntry) => void` — wire a sink that POSTs to your log ingestion endpoint, batches every 5 seconds, etc.
|
|
1197
|
+
|
|
1198
|
+
### Web Share API recipe
|
|
1199
|
+
|
|
1200
|
+
Share via the OS share sheet on mobile and supported desktop browsers. Falls back gracefully when `navigator.share` is missing.
|
|
1201
|
+
|
|
1202
|
+
```ts
|
|
1203
|
+
import { share, isShareSupported } from "tempest-react-sdk";
|
|
1204
|
+
|
|
1205
|
+
if (!isShareSupported()) {
|
|
1206
|
+
copyToClipboard(url);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const result = await share({ title: "Tempest", text: "Check this out", url });
|
|
1211
|
+
if (result.shared) toast.success("Compartilhado");
|
|
1212
|
+
if (result.cancelled) {
|
|
1213
|
+
/* user dismissed */
|
|
1214
|
+
}
|
|
1215
|
+
if (result.unsupported) {
|
|
1216
|
+
/* defensive — should not happen after isShareSupported */
|
|
1217
|
+
}
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
### Hooks catalogue recipe
|
|
1221
|
+
|
|
1222
|
+
| Hook | Purpose |
|
|
1223
|
+
| --------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
1224
|
+
| `useDebounce(value, delay)` | Debounce a value (search bars, autosave). |
|
|
1225
|
+
| `usePagination({ initialPage, initialSize })` | `page`/`size`/`setPage`/`setSize` triplet with bounds. |
|
|
1226
|
+
| `useClientFilter(items, predicate)` | Memoised in-memory filter. |
|
|
1227
|
+
| `useMediaQuery(query)` | Subscribe to a `matchMedia` query (`(min-width: 1024px)`). |
|
|
1228
|
+
| `useOnline()` | Returns `true`/`false` from `navigator.onLine` + online/offline events. |
|
|
1229
|
+
| `useDocumentVisibility()` | `"visible"` / `"hidden"`, subscribing to `visibilitychange`. |
|
|
1230
|
+
| `useIntersectionObserver(ref, opts)` | Returns the latest `IntersectionObserverEntry`. |
|
|
1231
|
+
| `useResizeObserver(ref)` | Returns the latest `width`/`height`. |
|
|
1232
|
+
| `useClipboard()` | `{ copy(text), copied, error }` with `execCommand` fallback. |
|
|
1233
|
+
| `useKeyboardShortcut(shortcut, handler)` | `"Meta+K"` / `"Ctrl+Shift+P"` patterns. |
|
|
1234
|
+
| `useBeforeInstallPrompt()` | Capture the PWA install prompt and trigger it later. |
|
|
1235
|
+
| `useIdle(timeout)` | `true` after `timeout` ms of no input. |
|
|
1236
|
+
| `useGeolocation()` | One-shot or watch position with permission state. |
|
|
1237
|
+
| `useScrollLock(active)` | Lock body scroll while a modal is open. |
|
|
1238
|
+
| `useFocusTrap(ref, active)` | Trap focus inside a container. |
|
|
1239
|
+
| `useStableCallback(fn)` | A `useCallback` that always points at the latest `fn` without churning identity. |
|
|
1240
|
+
| `useDeepMemo(value)` | `useMemo` with deep equality. |
|
|
1241
|
+
|
|
1242
|
+
Each hook is independently importable and tested — see `docs/hooks.md` for full signatures and edge cases.
|
|
1243
|
+
|
|
1244
|
+
### Utility helpers recipe
|
|
1245
|
+
|
|
1246
|
+
```ts
|
|
1247
|
+
import {
|
|
1248
|
+
cn,
|
|
1249
|
+
formatCurrency,
|
|
1250
|
+
formatDate,
|
|
1251
|
+
formatDateTime,
|
|
1252
|
+
formatPhone,
|
|
1253
|
+
formatCPF,
|
|
1254
|
+
formatPercent,
|
|
1255
|
+
storage,
|
|
1256
|
+
} from "tempest-react-sdk";
|
|
1257
|
+
|
|
1258
|
+
cn("btn", isPrimary && "btn-primary", { "btn-disabled": disabled });
|
|
1259
|
+
formatCurrency(1234.5, "BRL"); // "R$ 1.234,50"
|
|
1260
|
+
formatDate("2026-05-17"); // "17/05/2026"
|
|
1261
|
+
formatDateTime("2026-05-17T14:30Z"); // "17/05/2026 11:30"
|
|
1262
|
+
formatPhone("11998765432"); // "(11) 99876-5432"
|
|
1263
|
+
formatCPF("12345678909"); // "123.456.789-09"
|
|
1264
|
+
formatPercent(0.1234, 1); // "12,3%"
|
|
1265
|
+
|
|
1266
|
+
storage.set("draft", { title: "..." });
|
|
1267
|
+
const draft = storage.get<Draft>("draft");
|
|
1268
|
+
storage.remove("draft");
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
`storage` is an SSR-safe wrapper over `localStorage` — every call is `try/catch`-protected and returns `null` when `window` is unavailable or quota is exceeded.
|
|
1272
|
+
|
|
1273
|
+
---
|
|
1274
|
+
|
|
1275
|
+
## Theming reference
|
|
1276
|
+
|
|
1277
|
+
All tokens live on `:root` (light) and `[data-tempest-theme="dark"]` (dark). Override globally or in a subtree:
|
|
1278
|
+
|
|
1279
|
+
```css
|
|
1280
|
+
:root {
|
|
1281
|
+
--tempest-primary: #ff3366;
|
|
1282
|
+
--tempest-radius-md: 6px;
|
|
1283
|
+
--tempest-font-family: "Inter", system-ui, sans-serif;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
[data-tempest-theme="dark"] {
|
|
1287
|
+
--tempest-bg-default: #0b0e14;
|
|
1288
|
+
--tempest-text-primary: #e6e6e6;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
[data-tempest-theme="dark"] .my-card {
|
|
1292
|
+
/* per-subtree dark override */
|
|
1293
|
+
}
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
Categories of tokens:
|
|
1297
|
+
|
|
1298
|
+
- **Color**: `--tempest-primary`, `--tempest-success`, `--tempest-warning`, `--tempest-danger`, `--tempest-info`, `--tempest-bg-*`, `--tempest-text-*`, `--tempest-border-*`.
|
|
1299
|
+
- **Radius**: `--tempest-radius-sm` / `-md` / `-lg` / `-full`.
|
|
1300
|
+
- **Shadow**: `--tempest-shadow-sm` / `-md` / `-lg`.
|
|
1301
|
+
- **Spacing**: `--tempest-space-1` … `--tempest-space-8`.
|
|
1302
|
+
- **Typography**: `--tempest-font-family`, `--tempest-font-size-sm` / `-md` / `-lg`, `--tempest-line-height-base`.
|
|
1303
|
+
|
|
1304
|
+
Tokens are stable public API — breaking changes always bump the SDK minor (or major, on rename).
|
|
1305
|
+
|
|
1306
|
+
---
|
|
1307
|
+
|
|
1308
|
+
## Conventions
|
|
1309
|
+
|
|
1310
|
+
These conventions are enforced across the SDK source and are the same patterns the SDK encourages in consumer apps.
|
|
1311
|
+
|
|
1312
|
+
- **TypeScript strict** — no `any` implicit, `verbatimModuleSyntax`, every export typed.
|
|
1313
|
+
- **Double quotes** everywhere — `"foo"` never `'foo'`.
|
|
1314
|
+
- **JSDoc in English** on every public export (description + `@example`).
|
|
1315
|
+
- **CSS Modules** with the `tempest_` prefix — no global class names, no collisions with consumer apps.
|
|
1316
|
+
- **Empty results return `[]`** — never `null`/`undefined` for "no matches".
|
|
1317
|
+
- **No barrel default exports** — always named exports. Apps import from the package root, never from submodules.
|
|
1318
|
+
- **Optional peer deps** for everything that isn't React itself — apps install only what they consume.
|
|
1319
|
+
- **No Storybook** — `docs/` markdown + `examples/gallery` cover the documentation surface.
|
|
1320
|
+
- **Dark mode via `data-tempest-theme="dark"` attribute**, never a `class="dark"` toggle — allows scoped subtree overrides.
|
|
1321
|
+
|
|
1322
|
+
---
|
|
1323
|
+
|
|
1324
|
+
## Development
|
|
1325
|
+
|
|
1326
|
+
```bash
|
|
1327
|
+
npm install
|
|
1328
|
+
npm run dev # vite build --watch
|
|
1329
|
+
npm run build # ESM + CJS + rolled-up d.ts + styles.css
|
|
1330
|
+
npm run typecheck # tsc -b --noEmit (checks tests too)
|
|
1331
|
+
npm run lint # eslint .
|
|
1332
|
+
npm run format # prettier --write .
|
|
1333
|
+
npm test # vitest (watch)
|
|
1334
|
+
npm run test:run # vitest run
|
|
1335
|
+
npm run test:coverage # vitest run --coverage
|
|
1336
|
+
npm run clean # rm -rf dist coverage
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
Snapshot of current health:
|
|
1340
|
+
|
|
1341
|
+
- 444 tests / 164 files. 95% line / 96% function coverage.
|
|
1342
|
+
- ESM 98 KB → 28 KB gzip. CJS 71 KB → 24 KB gzip. CSS 32 KB → 6 KB gzip.
|
|
1343
|
+
- Husky pre-commit runs `lint-staged` (eslint --fix + prettier --write) on staged files.
|
|
1344
|
+
|
|
1345
|
+
The demo gallery lives in `examples/gallery` and consumes the local SDK via `file:../..`:
|
|
1346
|
+
|
|
1347
|
+
```bash
|
|
1348
|
+
cd examples/gallery
|
|
1349
|
+
npm install
|
|
1350
|
+
npm run dev # http://127.0.0.1:5173
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
Every gallery section maps 1-to-1 with a docs page — see [`docs/gallery.md`](./docs/gallery.md).
|
|
1354
|
+
|
|
1355
|
+
---
|
|
1356
|
+
|
|
1357
|
+
## Release
|
|
1358
|
+
|
|
1359
|
+
Versioning is managed with [Changesets](https://github.com/changesets/changesets):
|
|
1360
|
+
|
|
1361
|
+
```bash
|
|
1362
|
+
npx changeset # describe the change (patch / minor / major)
|
|
1363
|
+
npx changeset version # bump versions + update CHANGELOG.md
|
|
1364
|
+
npm run release # build + changeset publish
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
CI workflow `.github/workflows/release.yml` publishes to npm whenever a "Version Packages" PR merges into `main`. The first publish is manual — set the `NPM_TOKEN` secret in _Settings → Secrets and variables → Actions_ and run `npm publish` once.
|
|
1368
|
+
|
|
1369
|
+
Tags follow `vMAJOR.MINOR.PATCH`. CSS tokens (`--tempest-*`) are treated as public API — renames bump the minor (or major, on removal).
|
|
1370
|
+
|
|
1371
|
+
---
|
|
1372
|
+
|
|
1373
|
+
## License
|
|
1374
|
+
|
|
1375
|
+
MIT © Mauricio Benjamin
|