@qumra/fanar 0.0.0 → 0.0.2

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.
@@ -0,0 +1,846 @@
1
+ import * as react from 'react';
2
+ import { ComponentType, ReactNode } from 'react';
3
+ import { T as Tone } from './lib-DOXggTK6.js';
4
+
5
+ /**
6
+ * Schema version.
7
+ *
8
+ * Stamped on every message so the receiver knows how to read it. A message
9
+ * with no `v` is read as `1` — those were sent before the field existed.
10
+ */
11
+ declare const NOTIFICATION_SCHEMA_VERSION = 1;
12
+ /**
13
+ * Notification priority — three levels, each with real behaviour.
14
+ *
15
+ * `low` arrives silently
16
+ * `normal` the default
17
+ * `critical` chimes 35% louder, is **not** cleared by "mark all read",
18
+ * and counts toward `needsAction`
19
+ *
20
+ * ## There used to be four
21
+ *
22
+ * `high` existed in the union type and nowhere else — not one line read it.
23
+ * Four levels with one that does nothing is worse than three that all work:
24
+ * whoever picks agonises over a distinction that isn't there, and whoever
25
+ * reads the code hunts for behaviour they will never find.
26
+ *
27
+ * A message still sending `high` is read as `normal` with a warning, not
28
+ * rejected.
29
+ */
30
+ type NotificationPriority = 'low' | 'normal' | 'critical';
31
+ declare const NOTIFICATION_PRIORITIES: NotificationPriority[];
32
+ /**
33
+ * An action — a destination, not a callback.
34
+ *
35
+ * `href` is an internal path (`/orders/1052`) or a full URL. The library
36
+ * never navigates on its own: it calls `onAction` and you wire that to your
37
+ * router. That is what lets the same message work in a React Router app, a
38
+ * Next app, and a static page.
39
+ */
40
+ interface NotificationAction {
41
+ label: string;
42
+ href: string;
43
+ }
44
+ /**
45
+ * The message on the wire — what the server sends and the database stores.
46
+ *
47
+ * Every optional field has a default in `parseNotification`, so the server
48
+ * sends the minimum and the rest is derived.
49
+ *
50
+ * @example
51
+ * {
52
+ * "v": 1,
53
+ * "id": "ntf_01HZY8K3",
54
+ * "kind": "order.created",
55
+ * "at": "2026-08-10T14:32:00.000Z",
56
+ * "title": "New order #1052",
57
+ * "body": "Paid in full, awaiting fulfilment.",
58
+ * "priority": "normal",
59
+ * "data": { "orderId": "1052", "total": 1480, "currency": "EGP" },
60
+ * "action": { "label": "Open the order", "href": "/orders/1052" }
61
+ * }
62
+ */
63
+ interface NotificationJSON {
64
+ /** Schema version — absent reads as `1` */
65
+ v?: number;
66
+ /** Stable unique id. Deduplication keys off it, so it must come from the server */
67
+ id: string;
68
+ /** Event name, `domain.event` — `order.created`, `stock.depleted` */
69
+ kind: string;
70
+ /** ISO 8601 in UTC — `2026-08-10T14:32:00.000Z` */
71
+ at: string;
72
+ title: string;
73
+ body?: string;
74
+ /**
75
+ * When it was read, ISO — `null` or absent means unread.
76
+ *
77
+ * ## Why a timestamp and not a boolean
78
+ *
79
+ * `read: true` is one-way: there is no way to say "make it unread again",
80
+ * and if you tried, the next sync would flip it back — because
81
+ * "read wins" has no reference to compare against.
82
+ *
83
+ * A timestamp gives the same guarantee (newest wins) and three more things:
84
+ * "unread" becomes a newer event, an optimistic rollback can tell its own
85
+ * mark from another device's mark that landed in the same instant, and
86
+ * time-to-read falls out for free.
87
+ */
88
+ readAt?: string | null;
89
+ /** @deprecated Use `readAt` — this is still read and converted, and will be removed */
90
+ read?: boolean;
91
+ priority?: NotificationPriority;
92
+ /**
93
+ * Grouping key — collapses **toasts only**.
94
+ *
95
+ * A new message replaces the previous one from the same group in the toast
96
+ * queue. Three toasts from the same source landing together is exactly the
97
+ * noise that makes a merchant turn notifications off.
98
+ *
99
+ * ## The inbox does not collapse
100
+ *
101
+ * All three stay as separate rows. A toast interrupts, so collapsing it is
102
+ * a kindness; a row is read at leisure, so collapsing it hides information.
103
+ *
104
+ * Durable collapsing ("3 new messages" as one row) is the server's job — it
105
+ * is the only side that sees the whole time window. Client-side collapsing
106
+ * only sees what reached this tab. Toasts don't have that problem because
107
+ * they are per-tab by nature, live for seconds, and nobody counts them.
108
+ */
109
+ group?: string;
110
+ /** Event payload — the library never reads it, it hands it back to you */
111
+ data?: Record<string, unknown>;
112
+ action?: NotificationAction;
113
+ /**
114
+ * Silence one message — `'none'` and nothing else.
115
+ *
116
+ * Sound normally comes from the kind registry. This is for the exception:
117
+ * the same kind arriving silently during a bulk import, say. There is no
118
+ * other value — the chime itself is not swappable per message.
119
+ */
120
+ sound?: 'none';
121
+ }
122
+ /**
123
+ * The message after validation — this is what components work with.
124
+ *
125
+ * The difference from `NotificationJSON`: dates are `Date`, and optionals are
126
+ * filled with their defaults. So no component ever writes `?? 'normal'` or
127
+ * `new Date(...)` again — normalisation happens once, at the boundary.
128
+ */
129
+ interface Notification {
130
+ v: number;
131
+ id: string;
132
+ kind: string;
133
+ at: Date;
134
+ title: string;
135
+ body?: string;
136
+ /** When it was read — `null` means unread */
137
+ readAt: Date | null;
138
+ /** Shorthand for `readAt !== null` — derived, never stored */
139
+ read: boolean;
140
+ priority: NotificationPriority;
141
+ group?: string;
142
+ data: Record<string, unknown>;
143
+ action?: NotificationAction;
144
+ sound?: 'none';
145
+ }
146
+ /**
147
+ * The translate function — yours, not the library's.
148
+ *
149
+ * ## Why the library does not translate
150
+ *
151
+ * The first implementation was `locale.startsWith('ar') ? '…' : '…'` inside
152
+ * the registry. That works for two kinds and falls apart at the twelfth:
153
+ *
154
+ * - Strings end up buried in code, where no translator can reach them
155
+ * - Arabic has a dual as well as a plural, and each inflects the noun
156
+ * differently (talab · talabaan · thalaath talabaat, for one, two and
157
+ * three orders). No `startsWith` check can produce that
158
+ * - And if the project already has an i18n system, this builds a second
159
+ * one beside it
160
+ *
161
+ * So the library owns the **hook point**, not the translation: it calls `t`
162
+ * with a key and values, and `t` comes from `NotificationProvider`. Strings
163
+ * stay in your catalogue, pluralisation stays with the system built for it,
164
+ * and the library stays unaware of language — exactly as it stays unaware of
165
+ * what `order.created` means.
166
+ */
167
+ type NotificationTranslate = (key: string, vars?: Record<string, unknown>) => ReactNode;
168
+ /**
169
+ * A kind's sound — one chime, or silence.
170
+ *
171
+ * `notify` is the only notification chime. It is inlined as a data URI in
172
+ * `chime.ts`, so there is no file to copy and no path to break when the
173
+ * library moves to another project. `none` silences the whole kind — for
174
+ * things worth reading but not worth interrupting for.
175
+ *
176
+ * The chime itself is **not swappable**: it is part of Qumra's identity, like
177
+ * the colours and the typeface. What a project decides is which kinds ring
178
+ * and which arrive quietly.
179
+ */
180
+ type NotificationSoundName = 'notify' | 'none';
181
+ /**
182
+ * A notification kind — the mapping from an event name to its look and sound.
183
+ *
184
+ * This is what makes the system portable: the library does not know what
185
+ * `order.created` means, it knows how to read the registry. Each project
186
+ * registers its own kinds.
187
+ */
188
+ interface NotificationKind {
189
+ /** The event name exactly as the server sends it — `order.created` */
190
+ kind: string;
191
+ /** Human-readable name — shown in notification settings and filters */
192
+ label: string;
193
+ icon: ComponentType<{
194
+ size?: number;
195
+ strokeWidth?: number;
196
+ }>;
197
+ /**
198
+ * Status tone — neutral by default, on purpose.
199
+ *
200
+ * This says "this one is worrying", not "this one is from the orders
201
+ * module". If every kind took a colour, the inbox becomes a rainbow and the
202
+ * urgent item is lost in it.
203
+ */
204
+ tone?: Tone;
205
+ /** The kind's priority, used when the message does not set one */
206
+ priority?: NotificationPriority;
207
+ /**
208
+ * Whether this kind rings — `notify` by default.
209
+ *
210
+ * This is the only decision sound exposes, and it is a product decision,
211
+ * not an identity one: "app updated" is not worth interrupting for,
212
+ * "payment failed" is. The chime, its loudness, and its rhythm are fixed in
213
+ * the library.
214
+ */
215
+ sound?: NotificationSoundName;
216
+ /**
217
+ * Build the title from `data` instead of the server's text — **optional**.
218
+ *
219
+ * ## The problem it solves
220
+ *
221
+ * `title` is stored in whatever language was active when it was sent. The
222
+ * merchant switches language and finds last month's notifications still in
223
+ * the old one. This happens in Qumra for real — the apps ship in Arabic and
224
+ * English.
225
+ *
226
+ * ## Why it is opt-in and not the default
227
+ *
228
+ * If every kind rendered on the client, fixing a wording or a typo would
229
+ * need a **frontend deploy**. As it stands that is a server-side edit that
230
+ * lands immediately. So a kind with a template translates, a kind without
231
+ * takes the server's text — and you adopt it kind by kind, where it earns
232
+ * its keep.
233
+ *
234
+ * An unregistered kind keeps working off the server's text, so the backend
235
+ * can ship a new kind without waiting for the frontend.
236
+ *
237
+ * ## The template calls `t`, it does not translate
238
+ *
239
+ * Key and values only. The string lives in your catalogue, and plurals and
240
+ * inflection are your i18n system's job.
241
+ *
242
+ * If the project never passes `t` to the provider, the template is
243
+ * **ignored** and the server's text is shown — safer than a raw key in
244
+ * front of a merchant.
245
+ *
246
+ * @example
247
+ * {
248
+ * kind: 'order.created',
249
+ * title: (n, t) => t('notif.order.created', { id: n.data.orderId }),
250
+ * }
251
+ */
252
+ title?: (n: Notification, t: NotificationTranslate) => ReactNode;
253
+ /** Same idea as `title`, for the line underneath */
254
+ body?: (n: Notification, t: NotificationTranslate) => ReactNode;
255
+ }
256
+ /**
257
+ * A kind after resolution — look and sound guaranteed, templates not.
258
+ *
259
+ * A missing `title`/`body` is **meaning**, not absence: it says "use the
260
+ * server's text". So no default is filled in for them — one would overwrite
261
+ * text that came from the server with an empty string.
262
+ */
263
+ interface ResolvedKind extends Required<Omit<NotificationKind, 'title' | 'body'>> {
264
+ title?: NotificationKind['title'];
265
+ body?: NotificationKind['body'];
266
+ /** This kind is not registered, and these values come from the fallback */
267
+ unknown: boolean;
268
+ }
269
+ interface NotificationKindRegistry {
270
+ /** Returns the kind, or the fallback with `unknown: true` if unregistered */
271
+ get: (kind: string) => ResolvedKind;
272
+ /** Every registered kind — for a notification settings screen */
273
+ list: () => ResolvedKind[];
274
+ has: (kind: string) => boolean;
275
+ }
276
+
277
+ type NotificationParseResult = {
278
+ ok: true;
279
+ value: Notification;
280
+ warnings: string[];
281
+ } | {
282
+ ok: false;
283
+ issues: string[];
284
+ };
285
+ interface NotificationBatch {
286
+ items: Notification[];
287
+ /** اللي اتعزل — الرسالة الخام وسبب الرفض، عشان يتسجّل مش يتبلع */
288
+ rejected: {
289
+ raw: unknown;
290
+ issues: string[];
291
+ }[];
292
+ /** تحذيرات على رسايل عدّت — حقل اتجاهل، إصدار أحدث */
293
+ warnings: string[];
294
+ }
295
+ /**
296
+ * بيحقّق رسالة واحدة ويطبّعها.
297
+ *
298
+ * المطلوب: `id` و`kind` و`at` و`title`. الباقي بياخد افتراضي.
299
+ * `at` لازم يكون تاريخ ISO مقروء — الرقم أو النص المكسور بيترفض، لأن
300
+ * `Invalid Date` بيعدّي صامت وبيطلع «NaN من كذا دقيقة» في الواجهة.
301
+ */
302
+ interface ParseOptions {
303
+ /**
304
+ * سجلّ الأنواع — منه بييجي افتراضي الأولوية لما الرسالة ما تحدّدش.
305
+ *
306
+ * من غيره الرسالة اللي مافيهاش `priority` بتاخد `normal` مهما كان
307
+ * نوعها — يعني `payment.failed` المسجّل `critical` بيوصل عادي،
308
+ * والسجلّ بيبقى زينة. وده بيكسر أساس النظام: المكتبة مابتعرفش
309
+ * `order.created` يعني إيه، بتعرف تقرا السجلّ.
310
+ */
311
+ kinds?: NotificationKindRegistry;
312
+ }
313
+ declare function parseNotification(raw: unknown, options?: ParseOptions): NotificationParseResult;
314
+ /**
315
+ * بيحقّق دفعة ويعزل الوحش.
316
+ *
317
+ * بيقبل مصفوفة، أو نص JSON، أو كائن فيه `items`/`notifications` —
318
+ * التلات أشكال اللي الـAPIs بتبعت بيهم. وبيرتّب بالأحدث، وبيلغّي
319
+ * تكرار الـ`id` (آخر نسخة بتكسب: التحديث بيوصل بنفس المعرّف).
320
+ */
321
+ declare function parseNotifications(raw: unknown, options?: ParseOptions): NotificationBatch;
322
+ /**
323
+ * بيرجّع الرسالة لصيغتها على السلك — للتخزين أو إعادة الإرسال.
324
+ *
325
+ * عكس `parseNotification` بالظبط: `parse(serialize(n))` بيدّي `n`.
326
+ * لازم يفضل كده، وإلا الرسالة اللي اتخزّنت مابترجعش زي ما دخلت.
327
+ */
328
+ declare function serializeNotification(n: Notification): NotificationJSON;
329
+
330
+ /**
331
+ * بينشئ سجلّ أنواع.
332
+ *
333
+ * @param kinds الأنواع المسجّلة
334
+ * @param fallback تجاوز الشكل الاحتياطي للنوع المش معروف
335
+ *
336
+ * @example
337
+ * const KINDS = createKindRegistry([
338
+ * { kind: 'ticket.assigned', label: 'تذكرة', icon: Ticket, priority: 'critical' },
339
+ * ])
340
+ * KINDS.get('ticket.assigned').icon // Ticket
341
+ * KINDS.get('حاجة.غريبة').unknown // true
342
+ */
343
+ declare function createKindRegistry(kinds: NotificationKind[], fallback?: Partial<Omit<ResolvedKind, 'kind' | 'unknown'>>): NotificationKindRegistry;
344
+ /**
345
+ * أنواع التجارة الإلكترونية — طقم جاهز لمنتجات قمرة.
346
+ *
347
+ * ده **مثال مسجّل** مش جزء من العقد. المشروع التاني بيبني سجلّه
348
+ * بـ`createKindRegistry` ومابياخدش الطقم ده.
349
+ *
350
+ * ── واللون هنا استثناء مش قاعدة ─────────────────────────────────────────
351
+ * أربعة بس من اتناشر ليهم نغمة: فشل الدفع والمخزون الخالص والطلب
352
+ * المتعثّر والمرتجع. الباقي محايد. لو كل نوع خد لونه، مافيش حاجة
353
+ * بتتشاف قبل حاجة — واللون بيبطّل يعني حالة.
354
+ *
355
+ * ── و`critical` تلاتة بس ────────────────────────────────────────────────
356
+ * القاعدة: نزيف **مستمرّ لحد ما حد يتصرّف**. فشل التحصيل والمخزون
357
+ * الخالص والطلب المتعثّر بيكلّفوا كل يوم بيعدّي؛ المرتجع حصل وخلاص.
358
+ * والفرق مش أكاديمي: `critical` مابتتمسحش بـ«تعليم الكل»، فلو اتوسّعت
359
+ * الحماية بتبقى ضجيج مالوش مفتاح إطفاء.
360
+ *
361
+ * ── والقوالب على اتنين بس ───────────────────────────────────────────────
362
+ * `order.created` و`payout.sent` عندهم قالب بينادي `t` بمفتاح وقيم،
363
+ * فبيترجموا من كتالوج المشروع. الباقي بياخد نصّ السيرفر — عشان تصليح
364
+ * صياغة يفضل تعديل سيرفر بيوصل فورًا مش نشر فرونت.
365
+ *
366
+ * ومن غير `t` على المزوّد، القوالب بتتجاهل والكل بياخد نصّ السيرفر.
367
+ *
368
+ * ── والصوت واحد أو ولا حاجة ─────────────────────────────────────────────
369
+ * النغمة نفسها لكل نوع بيستاهل مقاطعة، و`none` للي بيتقرا ومابيستاهلش:
370
+ * تحديث تطبيق وملخّص المجتمع. نغمات متعدّدة بتفترض إن التاجر هيحفظ
371
+ * إن النازلة يعني دفع فشل — وده مابيحصلش، بيسمع رنّة فبيبصّ.
372
+ * والإلحاح بيتقال بالعلوّ: `critical` بتعلى ٣٥٪.
373
+ */
374
+ declare const QUMRA_KINDS: NotificationKind[];
375
+ /** سجلّ قمرة جاهز — `QUMRA_NOTIFICATIONS.get('order.created')` */
376
+ declare const QUMRA_NOTIFICATIONS: NotificationKindRegistry;
377
+
378
+ /** الصوت الوحيد في المكتبة — `none` بتسكّت النوع */
379
+ declare const NOTIFICATION_SOUNDS: Exclude<NotificationSoundName, 'none'>[];
380
+ interface UseNotificationSoundOptions {
381
+ /** سجلّ الأنواع — منه بييجي صوت كل نوع (`notify` أو `none`) */
382
+ kinds: NotificationKindRegistry;
383
+ }
384
+ interface NotificationSoundApi {
385
+ /** الصوت اتفكّ وجاهز — قبلها أي `play` بتتبلع */
386
+ ready: boolean;
387
+ muted: boolean;
388
+ setMuted: (muted: boolean) => void;
389
+ /**
390
+ * بيفكّ الصوت — **لازم من معالج حدث مستخدم حقيقي**.
391
+ *
392
+ * بتتنادى لوحدها على أول ضغطة لو `autoUnlock`. النداء اليدوي لزرار
393
+ * «شغّل الصوت» في الإعدادات.
394
+ */
395
+ unlock: () => Promise<boolean>;
396
+ /** بيرنّ حسب النوع — بيحترم الكتم والحدّ الأدنى والأولوية */
397
+ play: (n: Notification) => void;
398
+ /** بيرنّ حالاً — لزرار «جرّب الصوت» في الإعدادات */
399
+ preview: () => void;
400
+ }
401
+ declare function useNotificationSound({ kinds }: UseNotificationSoundOptions): NotificationSoundApi;
402
+
403
+ interface NotificationsApi {
404
+ /** كل الرسايل، الأحدث الأول */
405
+ items: Notification[];
406
+ /** عدد غير المقروء — ده اللي الجرس بيعرضه */
407
+ unread: number;
408
+ /**
409
+ * غير المقروء اللي «تعليم الكل» مش بيمسحه — الحرج.
410
+ *
411
+ * مفصول عن `unread` عن قصد: بعد «تعليم الكل» العدّاد بينزل لكن
412
+ * مش لصفر، وده بيتقرا عطل لو مافيش رقم تاني بيفسّره. والفصل بيسمح
413
+ * بوزن بصري مختلف — نقطة حمرا مابتروحش أبداً بترجّع نفس الضغط
414
+ * اللي بيخلّي التاجر يطفّي الإشعارات.
415
+ */
416
+ needsAction: number;
417
+ /** الوارد لسه — اللي الكروت الطايرة بتعرضه */
418
+ arrivals: Notification[];
419
+ /** المرفوض من آخر `hydrate`/`receive` — للتسجيل مش للعرض */
420
+ rejected: NotificationBatch['rejected'];
421
+ /**
422
+ * بيحمّل الموجود من قبل — **صامت وإضافي**.
423
+ *
424
+ * بيضيف ويحدّث ومابيشيلش. ده نداء أول تحميل، وتحميل صفحة تانية،
425
+ * وأي دفعة قديمة.
426
+ */
427
+ hydrate: (raw: unknown) => NotificationBatch;
428
+ /**
429
+ * مزامنة — **صامتة وبتشيل**.
430
+ *
431
+ * زي `hydrate` بس بتشيل كمان اللي السيرفر مابعتوش، **جوّه المدى
432
+ * الزمني اللي الدفعة بتغطّيه وبس**. فمزامنة الصفحة الأولى مابتمسحش
433
+ * الصفحات اللي تحتها.
434
+ */
435
+ sync: (raw: unknown) => NotificationBatch;
436
+ /**
437
+ * بيستقبل وارد جديد — بيرنّ وبيطلع طاير.
438
+ *
439
+ * بياخد رسالة واحدة أو دفعة. الرسالة المرفوضة بتتعزل ومابتوقّعش
440
+ * الباقي.
441
+ */
442
+ receive: (raw: unknown) => NotificationBatch;
443
+ /**
444
+ * إشعار محلّي من غير سيرفر — بيملى `id` و`at` و`v` لوحده.
445
+ *
446
+ * للأفعال اللي التطبيق بيعرف نتيجتها فوراً. بيعدّي على نفس
447
+ * التحقّق، فالمحلّي مابياخدش طريق جانبي.
448
+ */
449
+ push: (input: Omit<NotificationJSON, 'id' | 'at' | 'v'> & {
450
+ id?: string;
451
+ at?: string;
452
+ }) => string | null;
453
+ markRead: (id: string) => void;
454
+ markAllRead: () => void;
455
+ /**
456
+ * رجّعهم غير مقروئين — للتراجع، ولزرار «علّمه كغير مقروء».
457
+ *
458
+ * فعل باسمه زي باقي الـAPI (`dismiss` · `markAllRead` · `sync`)
459
+ * بدل `setRead(ids, false)`: العلَم البوليان بيخلّي مكان النداء
460
+ * يحتاج قراية التوقيع عشان يتفهم.
461
+ */
462
+ markUnread: (ids: string[]) => void;
463
+ /** بيلغي شاهد الشيل وبيرجّع الرسالة — للتراجع لما السيرفر يرفض */
464
+ restore: (n: Notification) => void;
465
+ /**
466
+ * السيرفر أكّد الشيل — الشاهد مابقاش لازم.
467
+ *
468
+ * من غيرها الشاهد بيستنّى انتهاء صلاحيته على الفاضي، والمجموعة
469
+ * بتكبر مع كل شيل في الجلسة.
470
+ */
471
+ confirmDismiss: (id: string) => void;
472
+ dismiss: (id: string) => void;
473
+ clearArrival: (id: string) => void;
474
+ clearAllArrivals: () => void;
475
+ }
476
+ interface UseNotificationsOptions {
477
+ /**
478
+ * سجلّ الأنواع — منه بييجي افتراضي الأولوية.
479
+ *
480
+ * من غيره أي رسالة مافيهاش `priority` بتاخد `normal`، فالنوع
481
+ * المسجّل `critical` بيوصل عادي و`needsAction` بتعدّ غلط.
482
+ */
483
+ kinds?: NotificationKindRegistry;
484
+ /** رسايل أولية — بتتحمّل صامتة زي `hydrate` */
485
+ initial?: unknown;
486
+ /** بيتنادى لكل وارد جديد اتقبل — ده مكان الصوت */
487
+ onReceive?: (n: Notification) => void;
488
+ /** بيتنادى لما رسالة تترفض — سجّلها، متبلعهاش */
489
+ onReject?: (rejected: NotificationBatch['rejected']) => void;
490
+ }
491
+ /** `critical` مابيتعلّمش مقروء بـ«تعليم الكل» — «شفته» مش «عالجته» */
492
+ declare const isBulkReadable: (n: Notification) => boolean;
493
+ declare function useNotifications({ kinds, initial, onReceive, onReject, }?: UseNotificationsOptions): NotificationsApi;
494
+
495
+ /**
496
+ * تحديث تفاؤلي بتراجع.
497
+ *
498
+ * الواجهة بتتغيّر فوراً، والمزامنة بتجري. لو فشلت، `undo` بترجّع
499
+ * الحالة و`onError` بيقول للمستخدم — لأن التراجع الصامت أوحش من
500
+ * الفشل نفسه: العدّاد بيرجع يطلع والمستخدم مايعرفش ليه.
501
+ *
502
+ * `run` ممكن يرجّع `void` أو وعد، وممكن يرمي متزامن. التلات حالات
503
+ * بتتعامل واحد — المستدعي اللي مابيزامنش مابيدفعش تمن، واللي
504
+ * بيزامن مابيحتاجش يلفّ نداءه في `try`.
505
+ */
506
+ declare function optimistic(run: (() => void | Promise<void>) | undefined, undo: () => void, onError?: (error: unknown) => void): void;
507
+ interface UseNotificationCenterOptions extends UseNotificationsOptions {
508
+ /** سجلّ الأنواع — بيروح للصوت وبيترجّع عشان يتحطّ في المزوّد */
509
+ kinds: NotificationKindRegistry;
510
+ /**
511
+ * اتقرا إشعار — بالفتح أو بـ«تعليم كمقروء».
512
+ *
513
+ * ده مكان المزامنة مع السيرفر. العلامة بتتحطّ **قبل** النداء
514
+ * (تفاؤلياً) عشان الواجهة ما تستناش الشبكة.
515
+ *
516
+ * ── ولو السيرفر رفض ─────────────────────────────────────────────────
517
+ * ارمي أو رجّع وعداً بيترفض، والمكتبة بترجّع العلامة لوحدها. من
518
+ * غير كده الواجهة بتوعد بحاجة السيرفر مانفّذهاش: التاجر بيشوف
519
+ * العدّاد وقع، وبيفتح من جهاز تاني فيلاقيه زي ما هو.
520
+ *
521
+ * @example
522
+ * onRead: async (n) => { await api.markRead(n.id) } // الفشل بيرجّع العلامة
523
+ */
524
+ onRead?: (n: Notification) => void | Promise<void>;
525
+ /** اتقرا الكل — نداء واحد بدل نداء لكل رسالة. الفشل بيرجّع الكل */
526
+ onReadAll?: (ids: string[]) => void | Promise<void>;
527
+ /** اتشال من الصندوق. الفشل بيرجّع الرسالة لمكانها */
528
+ onDismiss?: (n: Notification) => void | Promise<void>;
529
+ /**
530
+ * فشلت مزامنة مع السيرفر واتراجعنا — اعرض توست هنا.
531
+ *
532
+ * التراجع الصامت أوحش من الفشل نفسه: العدّاد بيرجع يطلع والمستخدم
533
+ * مايعرفش ليه.
534
+ *
535
+ * و`items` جوّه السياق عشان الرسالة تبقى مفيدة: «تعذّر حفظ قراية»
536
+ * بتسأل «قراية إيه؟»، و«تعذّر حفظ قراية ٣ إشعارات» بترد.
537
+ */
538
+ onSyncError?: (error: unknown, context: {
539
+ action: 'read' | 'readAll' | 'dismiss';
540
+ items: Notification[];
541
+ }) => void;
542
+ /**
543
+ * اتضغط إجراء الإشعار — الوجهة في `n.action.href`.
544
+ *
545
+ * وصّلها براوترك. الإشعار بيتعلّم مقروء لوحده قبل النداء.
546
+ */
547
+ onOpen?: (n: Notification) => void;
548
+ }
549
+ interface NotificationCenter extends NotificationsApi {
550
+ sound: NotificationSoundApi;
551
+ /** نفس السجلّ اللي دخل — عشان `NotificationProvider` ياخده من مصدر واحد */
552
+ kinds: NotificationKindRegistry;
553
+ /**
554
+ * الخصائص الجاهزة للمزوّد والأسطح — بتوصّل الفتح والقراءة لوحدها.
555
+ *
556
+ * `<NotificationProvider {...inbox.bind.provider}>` بدل ما تكتب
557
+ * `onAction` و`onOpen` في كل سطح.
558
+ */
559
+ bind: {
560
+ provider: {
561
+ kinds: NotificationKindRegistry;
562
+ onAction: (n: Notification) => void;
563
+ };
564
+ surface: {
565
+ items: Notification[];
566
+ unread: number;
567
+ needsAction: number;
568
+ sound: NotificationSoundApi;
569
+ onMarkAllRead: () => void;
570
+ onDismiss: (id: string) => void;
571
+ onOpen: (n: Notification) => void;
572
+ };
573
+ };
574
+ }
575
+ /**
576
+ * الحالة والصوت موصولين — نداء واحد.
577
+ *
578
+ * @example
579
+ * const inbox = useNotificationCenter({
580
+ * kinds: QUMRA_NOTIFICATIONS,
581
+ * initial: await api.notifications(), // بيتحمّل صامت
582
+ * })
583
+ *
584
+ * <NotificationProvider kinds={inbox.kinds} onAction={(n) => navigate(n.action.href)}>
585
+ * <NotificationMenu unread={inbox.unread} items={inbox.items} sound={inbox.sound} … />
586
+ * <NotificationToastHost items={inbox.arrivals} onClose={inbox.clearArrival} />
587
+ * </NotificationProvider>
588
+ *
589
+ * // والوارد من السوكت — بيرنّ وبيطلع طاير
590
+ * socket.on('notification', inbox.receive)
591
+ */
592
+ declare function useNotificationCenter({ kinds, onReceive, onRead, onReadAll, onDismiss, onOpen, onSyncError, ...rest }: UseNotificationCenterOptions): NotificationCenter;
593
+
594
+ /**
595
+ * الوقت النسبي — «من دقيقتين»، «إمبارح»، «من ٣ شهور».
596
+ *
597
+ * أقل من نصّ دقيقة بيتقري «الآن» بدل «قبل ٣٠ ثانية»: الفرق مالوش
598
+ * معنى للقارئ، والرقم اللي بيتغيّر كل ثانية بيسحب العين. والنصّ من
599
+ * `Intl` — فبيشتغل على أي لغة من غير ما المكتبة تعرفها.
600
+ *
601
+ * @param at وقت الإشعار
602
+ * @param locale اللغة — الافتراضي `ar`
603
+ * @param now لحقن الوقت في الاختبار
604
+ */
605
+ declare function formatRelativeTime(at: Date, locale?: string, now?: Date): string;
606
+ /**
607
+ * كل كام يجب إعادة الرسم عشان النصّ يفضل صح.
608
+ *
609
+ * «من دقيقة» بيبقى غلط بعد دقيقة، و«من ٣ شهور» بيفضل صح لأسبوع.
610
+ * المؤقّت بيتظبط على دقّة الوحدة بدل ما يشتغل كل ثانية على قايمة
611
+ * فيها أربعين سطر.
612
+ */
613
+ declare function relativeTickMs(at: Date, now?: Date): number;
614
+
615
+ interface NotificationContext {
616
+ kinds: NotificationKindRegistry;
617
+ locale: string;
618
+ t?: NotificationTranslate;
619
+ onAction?: (n: Notification) => void;
620
+ }
621
+ interface NotificationProviderProps {
622
+ /** The kind registry — Qumra's registry by default */
623
+ kinds?: NotificationKindRegistry;
624
+ /** Locale for relative time — `ar` by default */
625
+ locale?: string;
626
+ /**
627
+ * The translate function from your own i18n system — for kind title templates.
628
+ *
629
+ * Without it templates are ignored and the server's text is shown. The
630
+ * library does not translate on its own: it calls `t(key, vars)` and
631
+ * nothing more, so strings stay in your catalogue and pluralisation stays
632
+ * your system's job.
633
+ */
634
+ t?: NotificationTranslate;
635
+ /**
636
+ * Navigation to a notification's destination.
637
+ *
638
+ * The library does not navigate on its own, because a message carries an
639
+ * `href` and not a callback. Take it from `n.action.href` and hand it to
640
+ * your router.
641
+ */
642
+ onAction?: (n: Notification) => void;
643
+ children: ReactNode;
644
+ }
645
+ /** Distributes registry, locale and navigation to every surface — once, at the root */
646
+ declare function NotificationProvider({ kinds, locale, t, onAction, children, }: NotificationProviderProps): react.JSX.Element;
647
+ declare const useNotificationContext: () => NotificationContext;
648
+ interface NotificationBellProps {
649
+ unread: number;
650
+ /**
651
+ * How many of them need action — this changes the badge's **shape**, not its
652
+ * number.
653
+ *
654
+ * A red dot that never goes away invites exactly the press that makes a
655
+ * merchant turn notifications off. So the badge stays the unread count, and
656
+ * critical items are marked with a ring around it — a second signal on the
657
+ * same number, not a second number.
658
+ */
659
+ needsAction?: number;
660
+ onClick: () => void;
661
+ /** Reaches `aria-expanded` — a bell that opens a panel has to say so */
662
+ expanded?: boolean;
663
+ label?: string;
664
+ /** "3 unread" — the number belongs in `aria-label`, not only in the shape */
665
+ unreadLabel?: (n: number) => string;
666
+ className?: string;
667
+ }
668
+ /**
669
+ * The bell button with its unread badge.
670
+ *
671
+ * The badge is red and ringed in the background colour — without the ring the
672
+ * number collides with whatever icon is behind it and half of it becomes
673
+ * unreadable. The number is in `aria-label` too: a screen reader never sees
674
+ * the badge, and a bell that only says "notifications" makes a listener open
675
+ * the inbox every time just to find out whether anything is there.
676
+ */
677
+ declare function NotificationBell({ unread, needsAction, onClick, expanded, label, unreadLabel, className, }: NotificationBellProps): react.JSX.Element;
678
+ interface NotificationRowProps {
679
+ item: Notification;
680
+ /** Called after the destination opens — mark it read here */
681
+ onOpen?: (n: Notification) => void;
682
+ onDismiss?: (id: string) => void;
683
+ dismissLabel?: string;
684
+ unreadLabel?: string;
685
+ }
686
+ /**
687
+ * A single notification row.
688
+ *
689
+ * Unread is marked twice: with a dot and with heavier text. Colour alone is
690
+ * not enough — one man in twelve cannot separate two close greys, and weight
691
+ * reads regardless.
692
+ *
693
+ * And the row is **not one big button**. An inbox holds rows that open
694
+ * something and rows that are only read, and if the whole row is pressable
695
+ * someone who meant to read lands on another page instead. The action is a
696
+ * button with a name.
697
+ */
698
+ declare function NotificationRow({ item, onOpen, onDismiss, dismissLabel, unreadLabel, }: NotificationRowProps): react.JSX.Element;
699
+ interface NotificationListProps {
700
+ items: Notification[];
701
+ onOpen?: (n: Notification) => void;
702
+ onDismiss?: (id: string) => void;
703
+ emptyTitle?: ReactNode;
704
+ emptyDescription?: ReactNode;
705
+ className?: string;
706
+ }
707
+ /** The list itself — identical in the dropdown panel and in the side drawer */
708
+ declare function NotificationList({ items, onOpen, onDismiss, emptyTitle, emptyDescription, className, }: NotificationListProps): react.JSX.Element;
709
+ interface NotificationPanelProps extends NotificationListProps {
710
+ title?: ReactNode;
711
+ unread?: number;
712
+ /**
713
+ * The unread that "mark all read" does not clear.
714
+ *
715
+ * This has to be shown: the badge drops from 12 to 2 and stops, and anyone
716
+ * who does not know why presses again and nothing happens, so it reads as a
717
+ * bug. One line says why, once.
718
+ */
719
+ needsAction?: number;
720
+ onMarkAllRead?: () => void;
721
+ markAllLabel?: string;
722
+ /** A "see all" footer — without it the inbox is the end of the road */
723
+ onViewAll?: () => void;
724
+ viewAllLabel?: string;
725
+ /** The mute button in the header — pass it from `useNotificationSound` */
726
+ sound?: {
727
+ muted: boolean;
728
+ setMuted: (m: boolean) => void;
729
+ ready: boolean;
730
+ };
731
+ }
732
+ /**
733
+ * The dropdown panel — the notification inbox beside the bell.
734
+ *
735
+ * The list has a bounded height and **scrolls inside itself**: a panel that
736
+ * grows with its list reaches the bottom of the screen and cuts the last
737
+ * notification off with nothing to show it was cut.
738
+ *
739
+ * And "mark all read" disappears once nothing is unread — a button that does
740
+ * nothing, always present, teaches the user that buttons here do not respond.
741
+ */
742
+ declare function NotificationPanel({ items, title, unread, needsAction, onMarkAllRead, markAllLabel, onViewAll, viewAllLabel, sound, onOpen, onDismiss, emptyTitle, emptyDescription, className, }: NotificationPanelProps): react.JSX.Element;
743
+ interface SoundToggleProps {
744
+ muted: boolean;
745
+ setMuted: (m: boolean) => void;
746
+ /** Audio is unlocked — before that the button says the browser blocked it, not the user */
747
+ ready: boolean;
748
+ className?: string;
749
+ }
750
+ /**
751
+ * Muting notification sound.
752
+ *
753
+ * The third state matters: **not muted, but the browser has not unlocked
754
+ * audio yet**. The button says so instead of pretending sound is on — someone
755
+ * waiting for a chime that never comes deserves to know why.
756
+ */
757
+ declare function SoundToggle({ muted, setMuted, ready, className }: SoundToggleProps): react.JSX.Element;
758
+ interface NotificationMenuProps extends Omit<NotificationPanelProps, 'unread'> {
759
+ unread: number;
760
+ bellLabel?: string;
761
+ /**
762
+ * Which edge the panel aligns to against the bell — `end` by default.
763
+ *
764
+ * The bell sits at the `end` of the top bar, so the panel extends toward
765
+ * `start` and stays on screen. Move the bell and this has to flip.
766
+ */
767
+ align?: 'start' | 'end';
768
+ /**
769
+ * Above the bell or below it — `bottom` by default.
770
+ *
771
+ * A bottom bar on mobile needs `top`: a panel dropping from a bell at the
772
+ * bottom of the screen lands entirely outside it.
773
+ */
774
+ side?: 'top' | 'bottom';
775
+ className?: string;
776
+ }
777
+ /**
778
+ * The bell and the panel as one unit — this is what a top bar mounts.
779
+ *
780
+ * It closes on an outside click and on Escape. Both are needed: the outside
781
+ * click is what a mouse expects, and Escape is the only way out for anyone on
782
+ * a keyboard.
783
+ */
784
+ declare function NotificationMenu({ unread, bellLabel, align, side, className, onViewAll, onOpen, ...panel }: NotificationMenuProps): react.JSX.Element;
785
+ interface NotificationDrawerProps extends NotificationListProps {
786
+ open: boolean;
787
+ onClose: () => void;
788
+ title?: ReactNode;
789
+ unread?: number;
790
+ /** Same as `NotificationPanel` — the explanation has to show on mobile too */
791
+ needsAction?: number;
792
+ onMarkAllRead?: () => void;
793
+ markAllLabel?: string;
794
+ closeLabel?: string;
795
+ }
796
+ /**
797
+ * The side drawer — the same inbox, from the edge of the screen.
798
+ *
799
+ * Why two and not one: the dropdown panel is anchored to the bell, and on a
800
+ * 360px screen that means a panel covering the whole screen with one edge
801
+ * hanging off it. The drawer opens from `inline-end` — the left in Arabic and
802
+ * the right in English, by itself.
803
+ *
804
+ * And the list inside is **identical**, down to the character: someone who
805
+ * saw a notification on desktop and looks for it on mobile has to see the
806
+ * same row, or they will think there are two.
807
+ */
808
+ declare function NotificationDrawer({ open, onClose, items, title, unread, needsAction, onMarkAllRead, markAllLabel, closeLabel, onOpen, onDismiss, emptyTitle, emptyDescription, }: NotificationDrawerProps): react.JSX.Element;
809
+ /**
810
+ * The screen corner a toast drops into.
811
+ *
812
+ * `start`/`end` are logical, not left and right: `end` means the left in
813
+ * Arabic and the right in English. The bell moves with the language, and the
814
+ * notification has to move with it — otherwise it arrives from one side and
815
+ * is filed on the other.
816
+ */
817
+ type NotificationPlacement = 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
818
+ interface NotificationToastHostProps {
819
+ items: Notification[];
820
+ onClose: (id: string) => void;
821
+ onOpen?: (n: Notification) => void;
822
+ /**
823
+ * The corner — `top-end` by default, so it lands beside the bell.
824
+ *
825
+ * Keep it away from `ToastHost`'s corner (`bottom-start` by default): a
826
+ * toast says "the thing you did happened" and a notification says
827
+ * "something arrived from outside", and if both come from the same place
828
+ * they read as one queue.
829
+ */
830
+ placement?: NotificationPlacement;
831
+ /** Column width above `sm` — below it takes the full width */
832
+ width?: string;
833
+ closeLabel?: string;
834
+ regionLabel?: string;
835
+ className?: string;
836
+ }
837
+ /**
838
+ * The toast notification — the card that drops in when something arrives.
839
+ *
840
+ * `aria-live="polite"`, not `assertive`: the latter cuts a screen reader off
841
+ * mid-sentence. A notification is already an interruption; it does not earn a
842
+ * second one — whoever is listening will hear it once the line finishes.
843
+ */
844
+ declare function NotificationToastHost({ items, onClose, onOpen, placement, width, closeLabel, regionLabel, className, }: NotificationToastHostProps): react.JSX.Element | null;
845
+
846
+ export { NOTIFICATION_PRIORITIES, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_SOUNDS, type Notification, type NotificationAction, type NotificationBatch, NotificationBell, type NotificationBellProps, type NotificationCenter, NotificationDrawer, type NotificationDrawerProps, type NotificationJSON, type NotificationKind, type NotificationKindRegistry, NotificationList, type NotificationListProps, NotificationMenu, type NotificationMenuProps, NotificationPanel, type NotificationPanelProps, type NotificationParseResult, type NotificationPlacement, type NotificationPriority, NotificationProvider, type NotificationProviderProps, NotificationRow, type NotificationRowProps, type NotificationSoundApi, type NotificationSoundName, NotificationToastHost, type NotificationToastHostProps, type NotificationTranslate, type NotificationsApi, type ParseOptions, QUMRA_KINDS, QUMRA_NOTIFICATIONS, type ResolvedKind, SoundToggle, type SoundToggleProps, type UseNotificationCenterOptions, type UseNotificationSoundOptions, type UseNotificationsOptions, createKindRegistry, formatRelativeTime, isBulkReadable, optimistic, parseNotification, parseNotifications, relativeTickMs, serializeNotification, useNotificationCenter, useNotificationContext, useNotificationSound, useNotifications };