diff --git a/README.md b/README.md
index 240bda9..27874b6 100644
--- a/README.md
+++ b/README.md
@@ -986,6 +986,45 @@ return (
For RTK Query to follow tenant changes, recreate the API slice or pass a tenant-aware `getClient` resolver to `createNorbixApi`.
+### Language (`Accept-Language`)
+
+The language is a client setting: the hooks send whatever the wrapped core client sends. Set it with the provider prop (or `norbix.setLanguage(...)`); `Accept-Language` is the only language input (the old `cultureCode` field and `CultureCode` / `norbix-language` headers are gone).
+
+```tsx
+
+
+;
+
+// switching later: change the prop (or call norbix.setLanguage('en')), then drop
+// results read in the old language
+dispatch(norbixApi.util.resetApiState());
+```
+
+Read every translation instead of one language with `allLanguages: true` on record and term reads, e.g. `useFindCollectionQuery({ collectionName: 'articles', allLanguages: true })`.
+
+### Push for end users — `norbix.notifications.push` (core SDK with `notifications.push`)
+
+| Hook | SDK call | Tags |
+| ----------------------------------------------------------------- | --------------------- | -------------------------- |
+| `useRegisterPushDeviceMutation({ provider, token, platform, … })` | `registerDevice` | invalidates `PushDevices` |
+| `useListPushDevicesQuery()` | `listDevices` | provides `PushDevices` |
+| `useRemovePushDeviceMutation(id)` | `removeDevice` | invalidates `PushDevices` |
+| `useRemovePushDeviceByTokenMutation(token)` | `removeDeviceByToken` | invalidates `PushDevices` |
+| `useListPushMessagesQuery({ unreadOnly, pageSize })` | `listMessages` | provides `PushMessages` |
+| `useMarkPushMessageReadMutation(id)` | `markMessageRead` | invalidates `PushMessages` |
+| `useMarkAllPushMessagesReadMutation()` | `markAllMessagesRead` | invalidates `PushMessages` |
+
+```tsx
+const [register] = norbixApi.useRegisterPushDeviceMutation();
+await register({
+ provider: 'expo',
+ token: expoPushToken,
+ platform: 'ios',
+ installationId,
+}).unwrap();
+const { data } = norbixApi.useListPushMessagesQuery({ unreadOnly: true });
+```
+
### Multi-region projects
A Norbix project spans one **primary region** plus any number of **additional regions**. Two concerns, two homes: _which region your requests target_ is configured on the wrapped `norbix` client (this package inherits it — every hook just calls the client you passed to `createNorbixApi`), while _which regions a project spans_ is managed through the two hooks below.
diff --git a/package-lock.json b/package-lock.json
index 7459f74..a4fc262 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@eslint/js": "^10.0.1",
- "@norbix.ai/ts": "^4.25.0",
+ "@norbix.ai/ts": "^4.26.0",
"@reduxjs/toolkit": "^2.12.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/github": "^12.0.9",
@@ -45,7 +45,7 @@
"node": ">=18"
},
"peerDependencies": {
- "@norbix.ai/ts": ">=4.11.0",
+ "@norbix.ai/ts": ">=4.26.0",
"@reduxjs/toolkit": ">=2.0.0",
"react": ">=18.0.0",
"react-redux": ">=9.0.0"
@@ -1490,9 +1490,9 @@
}
},
"node_modules/@norbix.ai/ts": {
- "version": "4.25.0",
- "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.25.0.tgz",
- "integrity": "sha512-izKpx/INFqGyoluT44C7M3lE+29Qelkd40OT6YBHi4TkYcWXSt5P4BUx9B+8j2dm3bAmyL2fw6p5R7zvNaxIsQ==",
+ "version": "4.26.0",
+ "resolved": "https://registry.npmjs.org/@norbix.ai/ts/-/ts-4.26.0.tgz",
+ "integrity": "sha512-1dygy1yW+AGOVoEBhu+k501/u/7qIGpZV8gfQchQ3FsfDxc2VBt4Z3oH7Vbh4oI7Tkcc68Gr1atlnM8kGB+gtA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 2a1f28a..faee0b6 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
"prepublishOnly": "npm run build"
},
"peerDependencies": {
- "@norbix.ai/ts": ">=4.11.0",
+ "@norbix.ai/ts": ">=4.26.0",
"@reduxjs/toolkit": ">=2.0.0",
"react": ">=18.0.0",
"react-redux": ">=9.0.0"
@@ -68,7 +68,7 @@
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@eslint/js": "^10.0.1",
- "@norbix.ai/ts": "^4.25.0",
+ "@norbix.ai/ts": "^4.26.0",
"@reduxjs/toolkit": "^2.12.0",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/github": "^12.0.9",
diff --git a/src/createNorbixApi.ts b/src/createNorbixApi.ts
index 0f5c1ad..85acee2 100644
--- a/src/createNorbixApi.ts
+++ b/src/createNorbixApi.ts
@@ -89,6 +89,8 @@ export function createNorbixApi(
'PushTemplates',
'PushCampaigns',
'PushIntegrations',
+ 'PushDevices',
+ 'PushMessages',
// Sms
'Sms',
'SmsTemplates',
diff --git a/src/hooks/api/push.ts b/src/hooks/api/push.ts
new file mode 100644
index 0000000..2a7944f
--- /dev/null
+++ b/src/hooks/api/push.ts
@@ -0,0 +1,61 @@
+import type { Norbix } from '@norbix.ai/ts';
+
+import type { Builder } from '../index.js';
+import type { Result } from '../types.js';
+
+type Push = Norbix['notifications']['push'];
+type RegisterDevice = Push['registerDevice'];
+type ListDevices = Push['listDevices'];
+type RemoveDevice = Push['removeDevice'];
+type RemoveDeviceByToken = Push['removeDeviceByToken'];
+type ListMessages = Push['listMessages'];
+type MarkMessageRead = Push['markMessageRead'];
+type MarkAllMessagesRead = Push['markAllMessagesRead'];
+
+/**
+ * `notifications.push` — push for the signed-in project user (API host, user
+ * token), 1:1 with `norbix.notifications.push.*` in the core SDK.
+ *
+ * Tags:
+ * - `PushDevices` — the user's registered devices.
+ * - `PushMessages` — the user's push messages (read state included).
+ */
+export const apiPush = (b: Builder) => ({
+ registerPushDevice: b.mutation, Parameters[0]>({
+ query: (device) => (norbix) => norbix.notifications.push.registerDevice(device),
+ invalidatesTags: ['PushDevices'],
+ }),
+
+ listPushDevices: b.query, void>({
+ query: () => (norbix) => norbix.notifications.push.listDevices(),
+ providesTags: ['PushDevices'],
+ }),
+
+ /** Arg: the device id (`pnd_…`). */
+ removePushDevice: b.mutation, string>({
+ query: (id) => (norbix) => norbix.notifications.push.removeDevice(id),
+ invalidatesTags: ['PushDevices'],
+ }),
+
+ /** Arg: the push token the device was registered with (e.g. on sign-out). */
+ removePushDeviceByToken: b.mutation, string>({
+ query: (token) => (norbix) => norbix.notifications.push.removeDeviceByToken(token),
+ invalidatesTags: ['PushDevices'],
+ }),
+
+ listPushMessages: b.query, Parameters[0] | void>({
+ query: (args) => (norbix) => norbix.notifications.push.listMessages(args ?? {}),
+ providesTags: ['PushMessages'],
+ }),
+
+ /** Arg: the message id. */
+ markPushMessageRead: b.mutation, string>({
+ query: (id) => (norbix) => norbix.notifications.push.markMessageRead(id),
+ invalidatesTags: ['PushMessages'],
+ }),
+
+ markAllPushMessagesRead: b.mutation, void>({
+ query: () => (norbix) => norbix.notifications.push.markAllMessagesRead(),
+ invalidatesTags: ['PushMessages'],
+ }),
+});
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 53c7cd2..357177e 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -10,6 +10,7 @@ import { apiDatabase } from './api/database.js';
import { apiFiles } from './api/files.js';
import { apiMembership } from './api/membership.js';
import { apiPublic } from './api/public.js';
+import { apiPush } from './api/push.js';
import { hubAccount } from './hub/account.js';
import { hubAi } from './hub/ai.js';
import { hubApikeys } from './hub/apikeys.js';
@@ -88,6 +89,8 @@ export type AllTags =
| 'PushTemplates'
| 'PushCampaigns'
| 'PushIntegrations'
+ | 'PushDevices'
+ | 'PushMessages'
// ---- Sms ----
| 'Sms'
| 'SmsTemplates'
@@ -141,6 +144,7 @@ export function buildEndpoints(builder: Builder) {
...apiApikeys(builder),
...apiFiles(builder),
...apiPublic(builder),
+ ...apiPush(builder),
// Hub surface (control plane)
...hubAccount(builder),
...hubAi(builder),
diff --git a/src/provider.tsx b/src/provider.tsx
index 7bc45fe..ea80bfc 100644
--- a/src/provider.tsx
+++ b/src/provider.tsx
@@ -14,6 +14,14 @@ const NorbixContext = createContext(null);
export interface NorbixProviderProps {
/** The Norbix SDK client instance. Construct it once at app startup. */
client: Norbix;
+ /**
+ * Language to ask for (`lt`, `lt-LT, en;q=0.8`), set on the client with
+ * `client.setLanguage(...)` so every hook's request carries it as
+ * `Accept-Language`. Omit it to leave the client's own setting alone.
+ * Cached results do not know the language they were read in: after a
+ * change, refetch with `dispatch(norbixApi.util.resetApiState())`.
+ */
+ language?: string;
children?: ReactNode;
}
@@ -43,9 +51,14 @@ export interface NorbixProviderProps {
* }
* ```
*/
-export function NorbixProvider({ client, children }: NorbixProviderProps) {
+export function NorbixProvider({ client, language, children }: NorbixProviderProps) {
// Stable identity: re-render only when the client identity actually changes.
const value = useMemo(() => client, [client]);
+ // Set during render (not in an effect) so the first queries of the
+ // children already go out with the language.
+ if (language !== undefined && client.getLanguage() !== language) {
+ client.setLanguage(language || undefined);
+ }
return {children};
}
diff --git a/tests/push-language.test.tsx b/tests/push-language.test.tsx
new file mode 100644
index 0000000..b4b2888
--- /dev/null
+++ b/tests/push-language.test.tsx
@@ -0,0 +1,144 @@
+import { Norbix } from '@norbix.ai/ts';
+import { renderHook } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { apiPush } from '../src/hooks/api/push.js';
+import { buildEndpoints } from '../src/hooks/index.js';
+import { NorbixProvider, useNorbix } from '../src/provider.js';
+
+function fakeBuilder() {
+ return {
+ query: (def: Record) => ({ ...def, __kind: 'query' }),
+ mutation: (def: Record) => ({ ...def, __kind: 'mutation' }),
+ } as never;
+}
+
+type Def = {
+ __kind: 'query' | 'mutation';
+ query: (args: unknown) => (norbix: unknown) => unknown;
+ providesTags?: unknown;
+ invalidatesTags?: unknown;
+};
+
+function fakeNorbix() {
+ const calls: Array<{ method: string; args: unknown[] }> = [];
+ const push = new Proxy(
+ {},
+ {
+ get:
+ (_t, method: string) =>
+ (...args: unknown[]) => {
+ calls.push({ method, args });
+ return Promise.resolve({ ok: true });
+ },
+ },
+ );
+ return { client: { notifications: { push } }, calls };
+}
+
+describe('push hooks (notifications.push)', () => {
+ const defs = apiPush(fakeBuilder()) as unknown as Record;
+
+ it('ships every push endpoint, queries and mutations as expected', () => {
+ expect(Object.keys(defs).sort()).toEqual([
+ 'listPushDevices',
+ 'listPushMessages',
+ 'markAllPushMessagesRead',
+ 'markPushMessageRead',
+ 'registerPushDevice',
+ 'removePushDevice',
+ 'removePushDeviceByToken',
+ ]);
+ expect(defs.listPushDevices!.__kind).toBe('query');
+ expect(defs.listPushMessages!.__kind).toBe('query');
+ expect(defs.registerPushDevice!.__kind).toBe('mutation');
+ expect(defs.markAllPushMessagesRead!.__kind).toBe('mutation');
+ });
+
+ it('are part of the full endpoint set', () => {
+ const all = buildEndpoints(fakeBuilder()) as unknown as Record;
+ expect(all.registerPushDevice).toBeDefined();
+ expect(all.listPushMessages).toBeDefined();
+ });
+
+ it('call the matching SDK method with the hook argument', async () => {
+ const { client, calls } = fakeNorbix();
+ const device = { provider: 'expo', token: 't1', platform: 'ios' };
+ await defs.registerPushDevice!.query(device)(client);
+ await defs.listPushDevices!.query(undefined)(client);
+ await defs.removePushDevice!.query('pnd_1')(client);
+ await defs.removePushDeviceByToken!.query('t1')(client);
+ await defs.listPushMessages!.query({ unreadOnly: true })(client);
+ await defs.listPushMessages!.query(undefined)(client);
+ await defs.markPushMessageRead!.query('m1')(client);
+ await defs.markAllPushMessagesRead!.query(undefined)(client);
+ expect(calls).toEqual([
+ { method: 'registerDevice', args: [device] },
+ { method: 'listDevices', args: [] },
+ { method: 'removeDevice', args: ['pnd_1'] },
+ { method: 'removeDeviceByToken', args: ['t1'] },
+ { method: 'listMessages', args: [{ unreadOnly: true }] },
+ { method: 'listMessages', args: [{}] },
+ { method: 'markMessageRead', args: ['m1'] },
+ { method: 'markAllMessagesRead', args: [] },
+ ]);
+ });
+
+ it('device writes invalidate PushDevices, message writes PushMessages', () => {
+ expect(defs.listPushDevices!.providesTags).toEqual(['PushDevices']);
+ expect(defs.registerPushDevice!.invalidatesTags).toEqual(['PushDevices']);
+ expect(defs.removePushDeviceByToken!.invalidatesTags).toEqual(['PushDevices']);
+ expect(defs.listPushMessages!.providesTags).toEqual(['PushMessages']);
+ expect(defs.markPushMessageRead!.invalidatesTags).toEqual(['PushMessages']);
+ expect(defs.markAllPushMessagesRead!.invalidatesTags).toEqual(['PushMessages']);
+ });
+
+ it('reach the real SDK routes', async () => {
+ const fetchImpl = vi.fn(
+ async () => new globalThis.Response('{}', { status: 200 }),
+ ) as unknown as typeof globalThis.fetch;
+ const norbix = new Norbix({
+ projectId: 'p1',
+ bearerToken: 'jwt',
+ apiVersion: 'v3',
+ baseUrl: { api: 'https://api.example.test', hub: 'https://hub.example.test' },
+ fetch: fetchImpl,
+ });
+ await defs.markAllPushMessagesRead!.query(undefined)(norbix);
+ const [url, init] = (
+ fetchImpl as unknown as { mock: { calls: [string, { method?: string }][] } }
+ ).mock.calls[0]!;
+ expect(url).toBe('https://api.example.test/v3/notifications/push/messages/read');
+ expect(init.method).toBe('POST');
+ });
+});
+
+describe('', () => {
+ function client() {
+ return new Norbix(
+ { projectId: 'p1', apiKey: 'k', fetch: vi.fn() as unknown as typeof globalThis.fetch },
+ { envSource: {} },
+ );
+ }
+
+ it('sets the client language so every hook request sends Accept-Language', () => {
+ const norbix = client();
+ renderHook(() => useNorbix(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ expect(norbix.getLanguage()).toBe('lt-LT, en;q=0.8');
+ });
+
+ it('leaves the client language alone when the prop is omitted', () => {
+ const norbix = client();
+ norbix.setLanguage('de');
+ renderHook(() => useNorbix(), {
+ wrapper: ({ children }) => {children},
+ });
+ expect(norbix.getLanguage()).toBe('de');
+ });
+});