@seedgrid/fe-components 2026.11.2 → 2026.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/seedgrid-components.manifest.json +350 -65
- package/dist/buttons/SgButton.d.ts +6 -2
- package/dist/buttons/SgButton.d.ts.map +1 -1
- package/dist/buttons/SgButton.js +2 -2
- package/dist/buttons/SgButton.meta.js +3 -3
- package/dist/buttons/SgFloatActionButton.d.ts +2 -2
- package/dist/buttons/SgFloatActionButton.d.ts.map +1 -1
- package/dist/buttons/SgFloatActionButton.js +7 -7
- package/dist/buttons/SgFloatActionButton.meta.js +2 -2
- package/dist/buttons/SgSplitButton.d.ts +2 -2
- package/dist/buttons/SgSplitButton.d.ts.map +1 -1
- package/dist/buttons/SgSplitButton.js +4 -4
- package/dist/buttons/SgSplitButton.meta.js +3 -3
- package/dist/commons/SgAvatar.d.ts +2 -2
- package/dist/commons/SgAvatar.d.ts.map +1 -1
- package/dist/commons/SgAvatar.js +3 -3
- package/dist/commons/SgBadge.d.ts +1 -1
- package/dist/commons/SgBadge.d.ts.map +1 -1
- package/dist/commons/SgBadge.js +3 -3
- package/dist/gadgets/calendar/SgCalendar.d.ts +6 -0
- package/dist/gadgets/calendar/SgCalendar.d.ts.map +1 -1
- package/dist/gadgets/calendar/SgCalendar.js +9 -5
- package/dist/gadgets/calendar/SgCalendar.meta.d.ts.map +1 -1
- package/dist/gadgets/calendar/SgCalendar.meta.js +1 -0
- package/dist/generated/compiledStyles.d.ts +1 -1
- package/dist/generated/compiledStyles.d.ts.map +1 -1
- package/dist/generated/compiledStyles.js +1 -1
- package/dist/i18n/en-US.d.ts.map +1 -1
- package/dist/i18n/en-US.js +26 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +26 -0
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +26 -0
- package/dist/i18n/pt-BR.d.ts.map +1 -1
- package/dist/i18n/pt-BR.js +26 -0
- package/dist/i18n/pt-PT.d.ts.map +1 -1
- package/dist/i18n/pt-PT.js +26 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/inputs/SgAutocomplete.d.ts +1 -1
- package/dist/inputs/SgAutocomplete.d.ts.map +1 -1
- package/dist/inputs/SgAutocomplete.js +3 -3
- package/dist/inputs/SgCameraCapture.d.ts +1 -1
- package/dist/inputs/SgCameraCapture.d.ts.map +1 -1
- package/dist/inputs/SgCameraCapture.js +4 -4
- package/dist/inputs/SgCameraCapture.meta.js +1 -1
- package/dist/inputs/SgCheckboxGroup.d.ts +2 -2
- package/dist/inputs/SgCheckboxGroup.d.ts.map +1 -1
- package/dist/inputs/SgCheckboxGroup.js +12 -12
- package/dist/inputs/SgCombobox.js +5 -5
- package/dist/inputs/SgDatatable.d.ts +7 -0
- package/dist/inputs/SgDatatable.d.ts.map +1 -1
- package/dist/inputs/SgDatatable.js +33 -21
- package/dist/inputs/SgDatatable.meta.d.ts.map +1 -1
- package/dist/inputs/SgDatatable.meta.js +5 -1
- package/dist/inputs/SgFileUpload.d.ts +1 -1
- package/dist/inputs/SgFileUpload.d.ts.map +1 -1
- package/dist/inputs/SgFileUpload.js +2 -2
- package/dist/inputs/SgFileUpload.meta.js +1 -1
- package/dist/inputs/SgImageUpload.meta.js +1 -1
- package/dist/inputs/SgInputCurrency.d.ts.map +1 -1
- package/dist/inputs/SgInputCurrency.js +6 -4
- package/dist/inputs/SgInputDate.d.ts.map +1 -1
- package/dist/inputs/SgInputDate.js +3 -4
- package/dist/inputs/SgInputDateTime.d.ts.map +1 -1
- package/dist/inputs/SgInputDateTime.js +11 -0
- package/dist/inputs/SgInputNumber.d.ts.map +1 -1
- package/dist/inputs/SgInputNumber.js +6 -4
- package/dist/inputs/SgInputText.d.ts.map +1 -1
- package/dist/inputs/SgInputText.js +6 -4
- package/dist/inputs/SgInputTextArea.d.ts.map +1 -1
- package/dist/inputs/SgInputTextArea.js +4 -2
- package/dist/inputs/SgMultiSelect.meta.js +1 -1
- package/dist/inputs/SgMultiSelectChips.meta.js +1 -1
- package/dist/inputs/SgMultiSelectDropdown.js +1 -1
- package/dist/inputs/SgOrderList.d.ts +3 -3
- package/dist/inputs/SgOrderList.d.ts.map +1 -1
- package/dist/inputs/SgOrderList.js +13 -13
- package/dist/inputs/SgOrderList.meta.js +1 -1
- package/dist/inputs/SgPeriodSelector.d.ts +1 -1
- package/dist/inputs/SgPeriodSelector.d.ts.map +1 -1
- package/dist/inputs/SgPeriodSelector.js +3 -3
- package/dist/inputs/SgPickList.d.ts +3 -3
- package/dist/inputs/SgPickList.d.ts.map +1 -1
- package/dist/inputs/SgPickList.js +28 -28
- package/dist/inputs/SgPickList.meta.js +1 -1
- package/dist/inputs/SgRadioGroup.d.ts +2 -2
- package/dist/inputs/SgRadioGroup.d.ts.map +1 -1
- package/dist/inputs/SgRadioGroup.js +5 -5
- package/dist/inputs/SgRating.d.ts +1 -1
- package/dist/inputs/SgRating.d.ts.map +1 -1
- package/dist/inputs/SgRating.js +7 -7
- package/dist/inputs/SgRating.meta.js +1 -1
- package/dist/inputs/SgSlider.d.ts +1 -1
- package/dist/inputs/SgSlider.d.ts.map +1 -1
- package/dist/inputs/SgSlider.js +3 -3
- package/dist/inputs/SgSlider.meta.js +1 -1
- package/dist/inputs/SgStepperInput.d.ts +2 -2
- package/dist/inputs/SgStepperInput.d.ts.map +1 -1
- package/dist/inputs/SgStepperInput.js +4 -4
- package/dist/inputs/SgStepperInput.meta.js +1 -1
- package/dist/inputs/SgTextEditor.d.ts +1 -1
- package/dist/inputs/SgTextEditor.d.ts.map +1 -1
- package/dist/inputs/SgTextEditor.js +14 -14
- package/dist/inputs/SgToggleSwitch.meta.js +1 -1
- package/dist/inputs/SgYearPartSelector.d.ts +1 -1
- package/dist/inputs/SgYearPartSelector.d.ts.map +1 -1
- package/dist/inputs/SgYearPartSelector.js +1 -2
- package/dist/inputs/SgYearPartSelector.meta.js +2 -2
- package/dist/inputs/dateTimeLogic.d.ts +7 -1
- package/dist/inputs/dateTimeLogic.d.ts.map +1 -1
- package/dist/inputs/dateTimeLogic.js +17 -4
- package/dist/inputs/useSgMultiSelect.d.ts +1 -1
- package/dist/inputs/useSgMultiSelect.d.ts.map +1 -1
- package/dist/inputs/useSgMultiSelect.js +1 -1
- package/dist/layout/SgAccordion.d.ts +1 -1
- package/dist/layout/SgAccordion.d.ts.map +1 -1
- package/dist/layout/SgAccordion.js +3 -3
- package/dist/layout/SgBreadcrumb.d.ts +1 -1
- package/dist/layout/SgBreadcrumb.d.ts.map +1 -1
- package/dist/layout/SgBreadcrumb.js +1 -1
- package/dist/layout/SgCard.d.ts +1 -1
- package/dist/layout/SgCard.d.ts.map +1 -1
- package/dist/layout/SgCard.js +14 -14
- package/dist/layout/SgMenu.d.ts +1 -1
- package/dist/layout/SgMenu.d.ts.map +1 -1
- package/dist/layout/SgMenu.js +15 -15
- package/dist/layout/SgPageControl.d.ts +1 -1
- package/dist/layout/SgPageControl.d.ts.map +1 -1
- package/dist/layout/SgPageControl.js +4 -4
- package/dist/layout/SgPageControlPage.meta.js +2 -2
- package/dist/layout/SgToolBar.d.ts +1 -1
- package/dist/layout/SgToolBar.d.ts.map +1 -1
- package/dist/layout/SgToolBar.js +4 -4
- package/dist/layout/SgToolbarIconButton.meta.js +2 -2
- package/dist/layout/SgTreeView.d.ts +4 -4
- package/dist/layout/SgTreeView.d.ts.map +1 -1
- package/dist/layout/SgTreeView.js +6 -6
- package/dist/menus/SgDockMenu.d.ts +1 -1
- package/dist/menus/SgDockMenu.d.ts.map +1 -1
- package/dist/menus/SgDockMenu.js +4 -4
- package/dist/notifications/SgNotificationBell.d.ts +51 -0
- package/dist/notifications/SgNotificationBell.d.ts.map +1 -0
- package/dist/notifications/SgNotificationBell.js +348 -0
- package/dist/notifications/SgNotificationBell.meta.d.ts +4 -0
- package/dist/notifications/SgNotificationBell.meta.d.ts.map +1 -0
- package/dist/notifications/SgNotificationBell.meta.js +75 -0
- package/dist/notifications/notificationBellLogic.d.ts +148 -0
- package/dist/notifications/notificationBellLogic.d.ts.map +1 -0
- package/dist/notifications/notificationBellLogic.js +244 -0
- package/dist/overlay/SgConfirmationDialog.d.ts +1 -1
- package/dist/overlay/SgConfirmationDialog.d.ts.map +1 -1
- package/dist/overlay/SgConfirmationDialog.js +1 -1
- package/dist/overlay/SgPopup.d.ts +1 -1
- package/dist/overlay/SgPopup.d.ts.map +1 -1
- package/dist/overlay/SgPopup.js +1 -1
- package/dist/realtime/SgRealtimeProvider.d.ts +5 -0
- package/dist/realtime/SgRealtimeProvider.d.ts.map +1 -1
- package/dist/realtime/SgRealtimeProvider.js +31 -8
- package/dist/sandbox.cjs +64 -64
- package/dist/style.css +1 -1
- package/dist/wizard/SgWizard.d.ts +7 -0
- package/dist/wizard/SgWizard.d.ts.map +1 -1
- package/dist/wizard/SgWizard.js +11 -2
- package/dist/wizard/SgWizard.meta.d.ts.map +1 -1
- package/dist/wizard/SgWizard.meta.js +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type SgNotification, type SgNotificationPlacement, type SgPollingStats } from "./notificationBellLogic";
|
|
3
|
+
export type { SgNotification, SgNotificationPlacement, SgPollingStats } from "./notificationBellLogic";
|
|
4
|
+
/** Como um tipo de notificacao aparece (icone) e o que faz ao abrir. */
|
|
5
|
+
export type SgNotificationTypeConfig = {
|
|
6
|
+
icon?: React.ReactNode;
|
|
7
|
+
onOpen?: (notification: SgNotification) => void;
|
|
8
|
+
};
|
|
9
|
+
export type SgNotificationBellProps = {
|
|
10
|
+
/** Base das rotas da extension `user-notifications`. Default: `/api/user-notifications` (BFF, mesma origem). */
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
/** Rota que entrega o token do Pulse (usada se nao houver `SgRealtimeProvider` acima). Default: `/api/realtime/connect`. */
|
|
13
|
+
tokenEndpoint?: string;
|
|
14
|
+
/** Opcoes do `fetch` (ex.: `headers: { Authorization }` quando nao ha cookie). */
|
|
15
|
+
requestInit?: RequestInit;
|
|
16
|
+
/** Substitui o `fetch` (apps sem BFF, testes, demonstracao). Default: `fetch` global. */
|
|
17
|
+
fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
/** Tipo -> icone e acao ao abrir. O sino nao conhece o negocio: relatorio e so um tipo. */
|
|
19
|
+
types?: Record<string, SgNotificationTypeConfig>;
|
|
20
|
+
/** Evento do Pulse que dispara a leitura. Default: `user-notification.*`. */
|
|
21
|
+
eventPattern?: string;
|
|
22
|
+
/** Intervalo do polling de reserva (so com o Pulse desligado ou fora do ar), preso entre 30 e 60 s. Default: 45000. */
|
|
23
|
+
pollIntervalMs?: number;
|
|
24
|
+
/** Maximo de notificacoes na lista. Default e maximo: 100 (uma pagina da rota). */
|
|
25
|
+
maxVisible?: number;
|
|
26
|
+
/** De que lado do sino o painel abre (como o `placement` do SgPopup). `auto` escolhe o lado em que cabe. Default: `auto`. */
|
|
27
|
+
placement?: SgNotificationPlacement;
|
|
28
|
+
/** Mostra o interruptor "Nao lidas" no topo do painel. `false` esconde so o interruptor (o sino fica) e o painel lista tudo. Default: true. */
|
|
29
|
+
showUnreadToggle?: boolean;
|
|
30
|
+
/** O painel abre em "Nao lidas" (ainda nao clicadas). Ignorada com `showUnreadToggle=false`. Nao persiste entre recargas. Default: true. */
|
|
31
|
+
defaultUnreadOnly?: boolean;
|
|
32
|
+
/** Chegou uma notificacao nova (aviso do Pulse). */
|
|
33
|
+
onNotification?: (notification: SgNotification) => void;
|
|
34
|
+
/** O usuario excluiu uma notificacao. */
|
|
35
|
+
onNotificationDelete?: (notification: SgNotification) => void;
|
|
36
|
+
/** O usuario abriu uma notificacao (depois do `onOpen` do tipo). */
|
|
37
|
+
onOpen?: (notification: SgNotification) => void;
|
|
38
|
+
/** O polling consultou a API: contador, instante e motivo. Para medir o custo. */
|
|
39
|
+
onPollingTick?: (stats: SgPollingStats) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Sino de notificacoes do usuario logado (P129). Autonomo: se nao houver `SgRealtimeProvider` acima, abre a propria
|
|
44
|
+
* conexao com o Pulse (token por `tokenEndpoint`, nunca a apikey no front); com o Pulse desligado ou fora do ar, faz
|
|
45
|
+
* polling de reserva. Sem regra de negocio: o que cada `type` mostra e faz vem de `types`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function SgNotificationBell(props: Readonly<SgNotificationBellProps>): import("react/jsx-runtime").JSX.Element;
|
|
48
|
+
export declare namespace SgNotificationBell {
|
|
49
|
+
var displayName: string;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=SgNotificationBell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SgNotificationBell.d.ts","sourceRoot":"","sources":["../../src/notifications/SgNotificationBell.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAC;AAY1B,OAAO,EA2BL,KAAK,cAAc,EACnB,KAAK,uBAAuB,EAG5B,KAAK,cAAc,EACpB,MAAM,yBAAyB,CAAC;AAEjC,YAAY,EAAE,cAAc,EAAE,uBAAuB,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAEvG,wEAAwE;AACxE,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,cAAc,KAAK,IAAI,CAAC;CACjD,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,gHAAgH;IAChH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4HAA4H;IAC5H,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,kFAAkF;IAClF,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,yFAAyF;IACzF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnE,2FAA2F;IAC3F,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;IACjD,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uHAAuH;IACvH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mFAAmF;IACnF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6HAA6H;IAC7H,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,+IAA+I;IAC/I,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,4IAA4I;IAC5I,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,oDAAoD;IACpD,cAAc,CAAC,EAAE,CAAC,YAAY,EAAE,cAAc,KAAK,IAAI,CAAC;IACxD,yCAAyC;IACzC,oBAAoB,CAAC,EAAE,CAAC,YAAY,EAAE,cAAc,KAAK,IAAI,CAAC;IAC9D,oEAAoE;IACpE,MAAM,CAAC,EAAE,CAAC,YAAY,EAAE,cAAc,KAAK,IAAI,CAAC;IAChD,kFAAkF;IAClF,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA8dF;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,uBAAuB,CAAC,2CAoB1E;yBApBe,kBAAkB"}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Bell, Trash2 } from "lucide-react";
|
|
5
|
+
import { t, useComponentsI18n } from "../i18n";
|
|
6
|
+
import { SgBadge } from "../commons/SgBadge";
|
|
7
|
+
import { SgToggleSwitch } from "../inputs/SgToggleSwitch";
|
|
8
|
+
import { SgRealtimeProvider, useHasSgRealtimeProvider, useSgRealtimeEvent, useSgRealtimeStatus } from "../realtime/SgRealtimeProvider";
|
|
9
|
+
import { DEFAULT_EVENT_PATTERN, EMPTY_POLLING_STATS, NOTIFICATIONS_CHANNEL, badgeText, clampMaxVisible, afterOpen, displayUnread, initialUnreadOnly, listQuery, visibleItems, isValidInstant, markAllRead, newUnread, nextPollDelay, normalizeList, PANEL_MAX_HEIGHT, PANEL_WIDTH, panelPosition, planEvent, pollingReason, recordPollingTick, relativeTime, removeNotification, shouldRefreshOnStatus, viewState } from "./notificationBellLogic";
|
|
10
|
+
const DEFAULT_API_URL = "/api/user-notifications";
|
|
11
|
+
const PAGE_SIZE = 100;
|
|
12
|
+
function reducedMotion() {
|
|
13
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
14
|
+
}
|
|
15
|
+
function SgNotificationBellInner(props) {
|
|
16
|
+
const { apiUrl = DEFAULT_API_URL, requestInit, types, eventPattern = DEFAULT_EVENT_PATTERN, pollIntervalMs, placement = "auto", showUnreadToggle = true, className } = props;
|
|
17
|
+
const maxVisible = clampMaxVisible(props.maxVisible);
|
|
18
|
+
const i18n = useComponentsI18n();
|
|
19
|
+
const { status } = useSgRealtimeStatus();
|
|
20
|
+
const [items, setItems] = React.useState([]);
|
|
21
|
+
const [total, setTotal] = React.useState(0);
|
|
22
|
+
/** Contador do servidor (GET /unread-count); a lista local pode estar limitada pelo teto. */
|
|
23
|
+
const [serverUnread, setServerUnread] = React.useState(null);
|
|
24
|
+
const [loaded, setLoaded] = React.useState(false);
|
|
25
|
+
const [failed, setFailed] = React.useState(false);
|
|
26
|
+
const [actionFailed, setActionFailed] = React.useState(false);
|
|
27
|
+
const [open, setOpen] = React.useState(false);
|
|
28
|
+
const [unreadOnly, setUnreadOnly] = React.useState(() => initialUnreadOnly(props.showUnreadToggle, props.defaultUnreadOnly));
|
|
29
|
+
const unreadOnlyRef = React.useRef(unreadOnly);
|
|
30
|
+
unreadOnlyRef.current = unreadOnly;
|
|
31
|
+
const panelRef = React.useRef(null);
|
|
32
|
+
const [position, setPosition] = React.useState(null);
|
|
33
|
+
const [announce, setAnnounce] = React.useState("");
|
|
34
|
+
const loadedRef = React.useRef(false);
|
|
35
|
+
const notifiedRef = React.useRef(new Set());
|
|
36
|
+
const itemsRef = React.useRef([]);
|
|
37
|
+
itemsRef.current = items;
|
|
38
|
+
const latest = React.useRef(props);
|
|
39
|
+
latest.current = props;
|
|
40
|
+
const bellRef = React.useRef(null);
|
|
41
|
+
const rootRef = React.useRef(null);
|
|
42
|
+
const base = apiUrl.replace(/\/+$/, "");
|
|
43
|
+
/** ETag fraca + corpo de cada pagina da lista (chave: query). Em 304 vale o corpo guardado. */
|
|
44
|
+
const listCache = React.useRef(new Map());
|
|
45
|
+
const request = React.useCallback(async (path, method, ifNoneMatch) => {
|
|
46
|
+
const init = { credentials: "same-origin", ...latest.current.requestInit, method };
|
|
47
|
+
if (ifNoneMatch) {
|
|
48
|
+
const headers = new Headers(init.headers);
|
|
49
|
+
headers.set("If-None-Match", ifNoneMatch);
|
|
50
|
+
init.headers = headers;
|
|
51
|
+
}
|
|
52
|
+
const response = await (latest.current.fetcher ?? fetch)(base + path, init);
|
|
53
|
+
if (response.status === 304 && ifNoneMatch)
|
|
54
|
+
return response;
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status });
|
|
57
|
+
return response;
|
|
58
|
+
}, [base]);
|
|
59
|
+
const ring = React.useCallback(() => {
|
|
60
|
+
const el = bellRef.current;
|
|
61
|
+
if (!el || reducedMotion() || typeof el.animate !== "function")
|
|
62
|
+
return;
|
|
63
|
+
el.animate([{ transform: "rotate(0)" }, { transform: "rotate(14deg)" }, { transform: "rotate(-12deg)" }, { transform: "rotate(8deg)" }, { transform: "rotate(0)" }], { duration: 700, easing: "ease-in-out" });
|
|
64
|
+
}, []);
|
|
65
|
+
/** Le a lista (ate maxVisible, em paginas de 100). `announceNew`: chegou por aviso, entao anima e avisa. */
|
|
66
|
+
const load = React.useCallback(async (opts) => {
|
|
67
|
+
try {
|
|
68
|
+
const only = opts?.unreadOnly ?? unreadOnlyRef.current;
|
|
69
|
+
const wanted = Math.max(1, maxVisible);
|
|
70
|
+
const collected = [];
|
|
71
|
+
let reported = 0;
|
|
72
|
+
for (let page = 0; collected.length < wanted; page += 1) {
|
|
73
|
+
const size = Math.min(PAGE_SIZE, wanted - collected.length);
|
|
74
|
+
const query = listQuery(page, size, only);
|
|
75
|
+
const cached = listCache.current.get(query);
|
|
76
|
+
const response = await request(query, "GET", cached?.etag);
|
|
77
|
+
let res;
|
|
78
|
+
if (response.status === 304 && cached) {
|
|
79
|
+
res = cached.body;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
res = (await response.json());
|
|
83
|
+
const etag = response.headers?.get?.("ETag");
|
|
84
|
+
if (etag)
|
|
85
|
+
listCache.current.set(query, { etag, body: res });
|
|
86
|
+
else
|
|
87
|
+
listCache.current.delete(query);
|
|
88
|
+
}
|
|
89
|
+
collected.push(...res.items);
|
|
90
|
+
reported = res.total;
|
|
91
|
+
if (res.items.length < size || collected.length >= reported)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
let unreadFromServer = null;
|
|
95
|
+
try {
|
|
96
|
+
const body = (await (await request("/unread-count", "GET")).json());
|
|
97
|
+
if (typeof body.count === "number")
|
|
98
|
+
unreadFromServer = body.count;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// sem o contador do servidor, vale a lista local
|
|
102
|
+
}
|
|
103
|
+
const previous = itemsRef.current;
|
|
104
|
+
const next = normalizeList(collected, maxVisible, new Date().toISOString());
|
|
105
|
+
const fresh = newUnread(previous, next);
|
|
106
|
+
const wasLoaded = loadedRef.current;
|
|
107
|
+
loadedRef.current = true;
|
|
108
|
+
setServerUnread(unreadFromServer);
|
|
109
|
+
setItems(next);
|
|
110
|
+
setTotal(reported);
|
|
111
|
+
setFailed(false);
|
|
112
|
+
setLoaded(true);
|
|
113
|
+
if (opts?.announceNew && wasLoaded && fresh.length > 0) {
|
|
114
|
+
ring();
|
|
115
|
+
setAnnounce(t(i18n, "components.notifications.bell.announce", { title: fresh[0]?.title ?? "" }));
|
|
116
|
+
}
|
|
117
|
+
if (opts?.notifyId) {
|
|
118
|
+
const arrived = next.find((n) => n.publicId === opts.notifyId);
|
|
119
|
+
if (arrived && !notifiedRef.current.has(arrived.publicId) && !previous.some((n) => n.publicId === arrived.publicId)) {
|
|
120
|
+
notifiedRef.current.add(arrived.publicId);
|
|
121
|
+
latest.current.onNotification?.(arrived);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
setFailed(true);
|
|
128
|
+
setLoaded(true);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}, [i18n, maxVisible, request, ring]);
|
|
132
|
+
// Leitura inicial.
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
void load();
|
|
135
|
+
}, [load]);
|
|
136
|
+
// Aviso do Pulse.
|
|
137
|
+
useSgRealtimeEvent(NOTIFICATIONS_CHANNEL, eventPattern, (event) => {
|
|
138
|
+
const plan = planEvent(eventPattern, event.channel, event.event, event.data);
|
|
139
|
+
if (plan.action === "fetch")
|
|
140
|
+
void load({ notifyId: plan.publicId, announceNew: true });
|
|
141
|
+
else if (plan.action === "refresh")
|
|
142
|
+
void load();
|
|
143
|
+
});
|
|
144
|
+
// Ao (re)conectar, le uma vez para recuperar o que passou.
|
|
145
|
+
const previousStatus = React.useRef(status);
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
if (shouldRefreshOnStatus(previousStatus.current, status))
|
|
148
|
+
void load();
|
|
149
|
+
previousStatus.current = status;
|
|
150
|
+
}, [status, load]);
|
|
151
|
+
// Polling de reserva: so com o Pulse desligado ou fora do ar; para com a aba oculta.
|
|
152
|
+
const reason = pollingReason(status);
|
|
153
|
+
const stats = React.useRef(EMPTY_POLLING_STATS);
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (!reason || typeof document === "undefined")
|
|
156
|
+
return undefined;
|
|
157
|
+
let timer;
|
|
158
|
+
let failures = 0;
|
|
159
|
+
let stopped = false;
|
|
160
|
+
let inFlight = false;
|
|
161
|
+
const schedule = () => {
|
|
162
|
+
if (timer)
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
timer = undefined;
|
|
165
|
+
if (stopped || document.hidden)
|
|
166
|
+
return;
|
|
167
|
+
timer = setTimeout(() => void tick(), nextPollDelay(pollIntervalMs, failures, Math.random()));
|
|
168
|
+
};
|
|
169
|
+
// Uma consulta por vez: trocar de aba durante uma consulta nao abre um segundo laco.
|
|
170
|
+
const tick = async () => {
|
|
171
|
+
if (inFlight || stopped)
|
|
172
|
+
return;
|
|
173
|
+
inFlight = true;
|
|
174
|
+
if (timer)
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
timer = undefined;
|
|
177
|
+
try {
|
|
178
|
+
stats.current = recordPollingTick(stats.current, new Date().toISOString(), reason);
|
|
179
|
+
latest.current.onPollingTick?.(stats.current);
|
|
180
|
+
failures = (await load({ announceNew: true })) ? 0 : failures + 1;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
inFlight = false;
|
|
184
|
+
}
|
|
185
|
+
schedule();
|
|
186
|
+
};
|
|
187
|
+
const onVisibility = () => {
|
|
188
|
+
if (document.hidden) {
|
|
189
|
+
if (timer)
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
timer = undefined;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
void tick();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
198
|
+
schedule();
|
|
199
|
+
return () => {
|
|
200
|
+
stopped = true;
|
|
201
|
+
if (timer)
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
204
|
+
};
|
|
205
|
+
}, [reason, pollIntervalMs, request, load]);
|
|
206
|
+
// Fecha com Esc e com clique fora.
|
|
207
|
+
React.useEffect(() => {
|
|
208
|
+
if (!open)
|
|
209
|
+
return undefined;
|
|
210
|
+
const onKey = (e) => {
|
|
211
|
+
if (e.key === "Escape") {
|
|
212
|
+
setOpen(false);
|
|
213
|
+
bellRef.current?.focus();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const onDown = (e) => {
|
|
217
|
+
if (rootRef.current && !rootRef.current.contains(e.target))
|
|
218
|
+
setOpen(false);
|
|
219
|
+
};
|
|
220
|
+
document.addEventListener("keydown", onKey);
|
|
221
|
+
document.addEventListener("mousedown", onDown);
|
|
222
|
+
return () => {
|
|
223
|
+
document.removeEventListener("keydown", onKey);
|
|
224
|
+
document.removeEventListener("mousedown", onDown);
|
|
225
|
+
};
|
|
226
|
+
}, [open]);
|
|
227
|
+
/** 404 em ler/excluir: a notificacao ja nao existe no servidor (outra aba, retencao); sai da lista local. */
|
|
228
|
+
const act = async (fn, target) => {
|
|
229
|
+
try {
|
|
230
|
+
await fn();
|
|
231
|
+
setActionFailed(false);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (target && error.status === 404) {
|
|
235
|
+
setItems((list) => removeNotification(list, target.publicId));
|
|
236
|
+
setTotal((v) => Math.max(0, v - 1));
|
|
237
|
+
if (!target.read)
|
|
238
|
+
setServerUnread((v) => (v === null ? v : Math.max(0, v - 1)));
|
|
239
|
+
setActionFailed(false);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
setActionFailed(true);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const openItem = (n) => act(async () => {
|
|
247
|
+
if (!n.read) {
|
|
248
|
+
await request(`/${encodeURIComponent(n.publicId)}/read`, "POST");
|
|
249
|
+
setItems((list) => afterOpen(list, n.publicId, unreadOnlyRef.current, new Date().toISOString()));
|
|
250
|
+
setServerUnread((v) => (v === null ? v : Math.max(0, v - 1)));
|
|
251
|
+
}
|
|
252
|
+
setOpen(false);
|
|
253
|
+
types?.[n.type]?.onOpen?.(n);
|
|
254
|
+
latest.current.onOpen?.(n);
|
|
255
|
+
}, n);
|
|
256
|
+
const deleteItem = (n) => act(async () => {
|
|
257
|
+
await request(`/${encodeURIComponent(n.publicId)}`, "DELETE");
|
|
258
|
+
setItems((list) => removeNotification(list, n.publicId));
|
|
259
|
+
setTotal((v) => Math.max(0, v - 1));
|
|
260
|
+
if (!n.read)
|
|
261
|
+
setServerUnread((v) => (v === null ? v : Math.max(0, v - 1)));
|
|
262
|
+
latest.current.onNotificationDelete?.(n);
|
|
263
|
+
}, n);
|
|
264
|
+
const readAll = () => act(async () => {
|
|
265
|
+
await request("/read-all", "POST");
|
|
266
|
+
setItems((list) => markAllRead(list, new Date().toISOString()));
|
|
267
|
+
setServerUnread(0);
|
|
268
|
+
});
|
|
269
|
+
const clearAll = () => act(async () => {
|
|
270
|
+
await request("", "DELETE");
|
|
271
|
+
setItems([]);
|
|
272
|
+
setTotal(0);
|
|
273
|
+
setServerUnread(0);
|
|
274
|
+
});
|
|
275
|
+
const shown = visibleItems(items, unreadOnly);
|
|
276
|
+
const count = displayUnread(serverUnread, items);
|
|
277
|
+
const view = viewState({ loaded, failed, count: shown.length, unreadOnly });
|
|
278
|
+
const changeUnreadOnly = (checked) => {
|
|
279
|
+
setUnreadOnly(checked);
|
|
280
|
+
unreadOnlyRef.current = checked;
|
|
281
|
+
void load({ unreadOnly: checked });
|
|
282
|
+
};
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
const ageText = (n) => {
|
|
285
|
+
const r = relativeTime(n.createdAt, now);
|
|
286
|
+
if (r.unit === "now")
|
|
287
|
+
return t(i18n, "components.notifications.bell.now");
|
|
288
|
+
if (r.unit === "minute")
|
|
289
|
+
return t(i18n, "components.notifications.bell.minutes", { n: r.value });
|
|
290
|
+
if (r.unit === "hour")
|
|
291
|
+
return t(i18n, "components.notifications.bell.hours", { n: r.value });
|
|
292
|
+
if (r.unit === "day")
|
|
293
|
+
return t(i18n, "components.notifications.bell.days", { n: r.value });
|
|
294
|
+
return isValidInstant(n.createdAt) ? new Date(n.createdAt).toLocaleDateString(i18n.locale) : "";
|
|
295
|
+
};
|
|
296
|
+
const bellLabel = count > 0
|
|
297
|
+
? t(i18n, "components.notifications.bell.unread", { count })
|
|
298
|
+
: t(i18n, "components.notifications.bell.none");
|
|
299
|
+
// Posicao do painel na janela: o lado vem de `placement`; `auto` escolhe onde cabe. Recalcula ao abrir, ao mudar a
|
|
300
|
+
// lista, ao redimensionar e ao rolar.
|
|
301
|
+
React.useLayoutEffect(() => {
|
|
302
|
+
if (!open) {
|
|
303
|
+
setPosition(null);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const place = () => {
|
|
307
|
+
const anchor = bellRef.current?.getBoundingClientRect();
|
|
308
|
+
const panel = panelRef.current;
|
|
309
|
+
if (!anchor || !panel)
|
|
310
|
+
return;
|
|
311
|
+
const height = Math.min(panel.scrollHeight, PANEL_MAX_HEIGHT);
|
|
312
|
+
setPosition(panelPosition(anchor, { width: Math.min(PANEL_WIDTH, panel.offsetWidth || PANEL_WIDTH), height }, { width: window.innerWidth, height: window.innerHeight }, placement));
|
|
313
|
+
};
|
|
314
|
+
place();
|
|
315
|
+
window.addEventListener("resize", place);
|
|
316
|
+
window.addEventListener("scroll", place, true);
|
|
317
|
+
return () => {
|
|
318
|
+
window.removeEventListener("resize", place);
|
|
319
|
+
window.removeEventListener("scroll", place, true);
|
|
320
|
+
};
|
|
321
|
+
}, [open, placement, items.length, loaded, failed]);
|
|
322
|
+
return (_jsxs("div", { ref: rootRef, className: ["relative inline-block", className].filter(Boolean).join(" "), children: [_jsxs("button", { ref: bellRef, type: "button", "aria-label": bellLabel, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => {
|
|
323
|
+
if (!open)
|
|
324
|
+
void load();
|
|
325
|
+
setOpen((v) => !v);
|
|
326
|
+
}, className: "relative inline-flex size-11 items-center justify-center rounded-full text-foreground hover:bg-muted/60 focus-visible:outline focus-visible:outline-2", children: [_jsx(Bell, { className: "size-5", "aria-hidden": "true" }), count > 0 ? (_jsx("span", { className: "pointer-events-none absolute right-1 top-1", children: _jsx(SgBadge, { value: badgeText(count), severity: "danger", size: "xs", rounded: true }) })) : null] }), _jsx("span", { className: "sr-only", role: "status", "aria-live": "polite", children: announce }), open ? (_jsxs("div", { ref: panelRef, role: "dialog", "aria-label": t(i18n, "components.notifications.bell.label"), "data-side": position?.side, style: { top: position?.top ?? 0, left: position?.left ?? 0, maxHeight: position?.maxHeight ?? PANEL_MAX_HEIGHT, visibility: position ? "visible" : "hidden" }, className: "fixed z-50 flex w-[min(26rem,calc(100vw-1rem))] flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg", children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border px-4 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-base font-semibold", children: t(i18n, "components.notifications.bell.label") }), count > 0 ? (_jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-xs font-semibold", children: t(i18n, "components.notifications.bell.news", { count }) })) : null] }), count > 0 ? (_jsx("button", { type: "button", onClick: () => void readAll(), className: "min-h-11 px-1 text-sm font-medium text-primary", children: t(i18n, "components.notifications.bell.markAll") })) : null] }), showUnreadToggle ? (_jsx("div", { className: "flex items-center border-b border-border px-4 py-1", children: _jsx(SgToggleSwitch, { id: "sg-notification-unread-only", label: t(i18n, "components.notifications.bell.unreadOnly"), checked: unreadOnly, onChange: changeUnreadOnly }) })) : null, actionFailed ? (_jsx("div", { role: "alert", className: "border-b border-border px-4 py-2 text-sm text-destructive", children: t(i18n, "components.notifications.bell.actionFailed") })) : null, _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [view === "loading" ? (_jsx("div", { role: "status", className: "px-4 py-6 text-center text-sm text-muted-foreground", children: t(i18n, "components.notifications.bell.loading") })) : null, view === "error" ? (_jsxs("div", { role: "alert", className: "flex flex-col items-center gap-2 px-4 py-6 text-center text-sm", children: [_jsx("span", { children: t(i18n, "components.notifications.bell.error") }), _jsx("button", { type: "button", onClick: () => void load(), className: "min-h-11 px-3 font-medium text-primary", children: t(i18n, "components.notifications.bell.retry") })] })) : null, view === "empty" ? (_jsxs("div", { className: "px-4 py-8 text-center", children: [_jsx("div", { className: "text-sm font-medium", children: t(i18n, "components.notifications.bell.empty") }), _jsx("div", { className: "mt-1 text-xs text-muted-foreground", children: t(i18n, "components.notifications.bell.emptyHint") })] })) : null, view === "caughtUp" ? (_jsxs("div", { className: "px-4 py-8 text-center", children: [_jsx("div", { className: "text-sm font-medium", children: t(i18n, "components.notifications.bell.caughtUp") }), _jsx("button", { type: "button", onClick: () => changeUnreadOnly(false), className: "mt-1 min-h-11 px-3 text-sm font-medium text-primary", children: t(i18n, "components.notifications.bell.seeAll") })] })) : null, view === "list" ? (_jsx("ul", { className: "m-0 list-none p-0", children: shown.map((n) => (_jsxs("li", { "data-unread": n.read ? "false" : "true", className: ["flex items-start gap-3 border-b border-border px-4 py-3", n.read ? "" : "bg-muted/40"].join(" "), children: [_jsx("span", { className: "mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-full border border-border bg-background", children: types?.[n.type]?.icon ?? _jsx(Bell, { className: "size-4", "aria-hidden": "true" }) }), _jsxs("button", { type: "button", onClick: () => void openItem(n), className: "min-w-0 flex-1 text-left", children: [_jsx("span", { className: ["block truncate text-sm", n.read ? "font-medium" : "font-semibold"].join(" "), children: n.title }), n.message ? _jsx("span", { className: "block text-sm text-muted-foreground", children: n.message }) : null, _jsx("span", { className: "mt-1 block text-xs text-muted-foreground", children: ageText(n) })] }), _jsx("button", { type: "button", "aria-label": t(i18n, "components.notifications.bell.delete", { title: n.title }), onClick: () => void deleteItem(n), className: "-mr-2 inline-flex size-11 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(Trash2, { className: "size-4", "aria-hidden": "true" }) })] }, n.publicId))) })) : null] }), view === "list" ? (_jsxs("div", { className: "flex items-center justify-between border-t border-border px-4", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: t(i18n, "components.notifications.bell.footer", { shown: shown.length, total: Math.max(total, shown.length) }) }), _jsx("button", { type: "button", onClick: () => void clearAll(), className: "min-h-11 px-1 text-sm font-medium text-destructive", children: t(i18n, "components.notifications.bell.clear") })] })) : null] })) : null] }));
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Sino de notificacoes do usuario logado (P129). Autonomo: se nao houver `SgRealtimeProvider` acima, abre a propria
|
|
330
|
+
* conexao com o Pulse (token por `tokenEndpoint`, nunca a apikey no front); com o Pulse desligado ou fora do ar, faz
|
|
331
|
+
* polling de reserva. Sem regra de negocio: o que cada `type` mostra e faz vem de `types`.
|
|
332
|
+
*/
|
|
333
|
+
export function SgNotificationBell(props) {
|
|
334
|
+
const hasProvider = useHasSgRealtimeProvider(NOTIFICATIONS_CHANNEL);
|
|
335
|
+
const { fetcher, tokenEndpoint = "/api/realtime/connect", requestInit } = props;
|
|
336
|
+
const fetchToken = React.useMemo(() => fetcher
|
|
337
|
+
? async () => {
|
|
338
|
+
const response = await fetcher(tokenEndpoint, { credentials: "same-origin", ...requestInit, method: "POST" });
|
|
339
|
+
if (!response.ok)
|
|
340
|
+
throw new Error(`HTTP ${response.status}`);
|
|
341
|
+
return (await response.json());
|
|
342
|
+
}
|
|
343
|
+
: undefined, [fetcher, tokenEndpoint, requestInit]);
|
|
344
|
+
if (hasProvider)
|
|
345
|
+
return _jsx(SgNotificationBellInner, { ...props });
|
|
346
|
+
return (_jsx(SgRealtimeProvider, { channels: [NOTIFICATIONS_CHANNEL], tokenEndpoint: tokenEndpoint, tokenRequestInit: requestInit, fetchToken: fetchToken, children: _jsx(SgNotificationBellInner, { ...props }) }));
|
|
347
|
+
}
|
|
348
|
+
SgNotificationBell.displayName = "SgNotificationBell";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SgNotificationBell.meta.d.ts","sourceRoot":"","sources":["../../src/notifications/SgNotificationBell.meta.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE9D,eAAO,MAAM,MAAM,EAAE,QAsDpB,CAAC;AAEF,eAAO,MAAM,OAAO,EAAE,WAoBrB,CAAC"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const sgMeta = {
|
|
2
|
+
version: "0.1",
|
|
3
|
+
componentId: "feedback.notification-bell",
|
|
4
|
+
package: "@seedgrid/fe-components",
|
|
5
|
+
exportName: "SgNotificationBell",
|
|
6
|
+
slug: "sg-notification-bell",
|
|
7
|
+
displayName: "SgNotificationBell",
|
|
8
|
+
category: "feedback",
|
|
9
|
+
subcategory: "notification",
|
|
10
|
+
description: "Sino de notificacoes do usuario logado: contador de nao lidas, lista de ate 100 notificacoes com titulo, texto, data e estado lida/nao lida, marcar como lida, excluir, marcar todas e limpar. Autonomo: conecta-se sozinho ao Pulse (token vindo do backend, sem apikey no front) e reaproveita o SgRealtimeProvider se houver; com o Pulse desligado faz polling de reserva. Sem regra de negocio: cada tipo de notificacao tem icone e acao ao abrir.",
|
|
11
|
+
tags: ["notification", "bell", "inbox", "alert", "realtime", "pulse", "badge", "feedback"],
|
|
12
|
+
capabilities: ["realtime", "polling-fallback", "unread-count", "mark-as-read", "delete", "reduced-motion", "aria-live"],
|
|
13
|
+
fieldSemantics: ["notification", "inbox", "userNotice"],
|
|
14
|
+
props: [
|
|
15
|
+
{ name: "apiUrl", type: "string", default: "/api/user-notifications", description: "Base das rotas da extension user-notifications (BFF, mesma origem).", semanticRole: "data", bindable: true },
|
|
16
|
+
{ name: "tokenEndpoint", type: "string", default: "/api/realtime/connect", description: "Rota que entrega o token do Pulse; usada quando nao ha SgRealtimeProvider acima.", semanticRole: "behavior", bindable: true },
|
|
17
|
+
{ name: "requestInit", type: "RequestInit", description: "Opcoes do fetch (ex.: headers com Authorization quando nao ha cookie).", semanticRole: "behavior", bindable: false },
|
|
18
|
+
{ name: "fetcher", type: "(input: string, init?: RequestInit) => Promise<Response>", description: "Substitui o fetch (apps sem BFF, testes, demonstracao).", semanticRole: "behavior", bindable: false },
|
|
19
|
+
{ name: "types", type: "Record<string, SgNotificationTypeConfig>", description: "Tipo da notificacao -> icone e acao ao abrir (onOpen). O sino nao conhece o negocio.", semanticRole: "data", bindable: true },
|
|
20
|
+
{ name: "eventPattern", type: "string", default: "user-notification.*", description: "Evento do Pulse que dispara a leitura.", semanticRole: "behavior", bindable: true },
|
|
21
|
+
{ name: "pollIntervalMs", type: "number", default: 45000, description: "Intervalo do polling de reserva (so com o Pulse desligado ou fora do ar), preso entre 30 e 60 s, com jitter e backoff em erro.", semanticRole: "behavior", bindable: true },
|
|
22
|
+
{ name: "maxVisible", type: "number", default: 100, description: "Maximo de notificacoes na lista; as mais antigas saem.", semanticRole: "behavior", bindable: true },
|
|
23
|
+
{ name: "placement", type: "\"auto\" | \"top\" | \"right\" | \"bottom\" | \"left\"", default: "auto", description: "De que lado do sino o painel abre (como o placement do SgPopup); auto escolhe o lado em que cabe.", semanticRole: "behavior", bindable: true },
|
|
24
|
+
{ name: "showUnreadToggle", type: "boolean", default: "true", description: "Mostra o interruptor Não lidas no topo do painel; false esconde só o interruptor (o sino fica) e o painel lista tudo.", semanticRole: "behavior", bindable: true },
|
|
25
|
+
{ name: "defaultUnreadOnly", type: "boolean", default: "true", description: "O painel abre em Não lidas (ainda não clicadas); ignorada com showUnreadToggle=false e não persiste entre recargas.", semanticRole: "behavior", bindable: true },
|
|
26
|
+
{ name: "onNotification", type: "(notification: SgNotification) => void", description: "Chegou uma notificacao nova (aviso do Pulse).", semanticRole: "event", bindable: false },
|
|
27
|
+
{ name: "onNotificationDelete", type: "(notification: SgNotification) => void", description: "O usuario excluiu uma notificacao.", semanticRole: "event", bindable: false },
|
|
28
|
+
{ name: "onOpen", type: "(notification: SgNotification) => void", description: "O usuario abriu uma notificacao (depois do onOpen do tipo).", semanticRole: "event", bindable: false },
|
|
29
|
+
{ name: "onPollingTick", type: "(stats: SgPollingStats) => void", description: "O polling consultou a API: contador, instante da ultima consulta e motivo (disabled ou unavailable).", semanticRole: "event", bindable: false }
|
|
30
|
+
],
|
|
31
|
+
states: ["loading", "empty", "list", "error"],
|
|
32
|
+
examples: [
|
|
33
|
+
{
|
|
34
|
+
id: "basic",
|
|
35
|
+
title: "Basico",
|
|
36
|
+
file: "apps/showcase/src/app/components/sg-notification-bell/samples/basico.tsx.sample",
|
|
37
|
+
kind: "sample"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "types",
|
|
41
|
+
title: "Tipos com icone e acao",
|
|
42
|
+
file: "apps/showcase/src/app/components/sg-notification-bell/samples/tipos.tsx.sample",
|
|
43
|
+
kind: "sample"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "playground",
|
|
47
|
+
title: "Playground",
|
|
48
|
+
file: "apps/showcase/src/app/components/sg-notification-bell/sg-notification-bell.tsx.playground",
|
|
49
|
+
kind: "playground"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
showcase: { route: "/components/sg-notification-bell", hasPlayground: true, hasPropsTable: true },
|
|
53
|
+
sdui: { rendererType: "feedback.notification-bell", acceptsDataBinding: true, defaultProps: {} }
|
|
54
|
+
};
|
|
55
|
+
export const aiHints = {
|
|
56
|
+
version: "0.1",
|
|
57
|
+
preferredUseCases: [
|
|
58
|
+
"Avisar o usuario logado de algo que terminou ou aconteceu (relatorio pronto, backup, importacao, convite) com historico e contador de nao lidas.",
|
|
59
|
+
"Barra superior de um app com caixa de notificacoes que atualiza sozinha.",
|
|
60
|
+
"Qualquer aviso persistente ao usuario que nao deve sumir sozinho."
|
|
61
|
+
],
|
|
62
|
+
avoidUseCases: [
|
|
63
|
+
"Mensagem rapida que some sozinha (toast): use o feedback de toast.",
|
|
64
|
+
"Chat ou mensagens entre usuarios.",
|
|
65
|
+
"Mostrar notificacoes sem a extension user-notifications no backend."
|
|
66
|
+
],
|
|
67
|
+
synonyms: ["sino", "notificacoes", "notification bell", "inbox", "avisos do usuario", "caixa de notificacoes", "alerts"],
|
|
68
|
+
relatedEntityFields: ["notification", "alert", "unread", "report", "backup"],
|
|
69
|
+
compositionHints: [
|
|
70
|
+
"Pôr no cabecalho do layout autenticado; sem SgRealtimeProvider acima ele abre a propria conexao.",
|
|
71
|
+
"Use types para mapear cada tipo de notificacao a um icone e a acao de abrir (ex.: baixar o relatorio).",
|
|
72
|
+
"O payload da notificacao so tem identificadores: a acao de abrir busca o arquivo ou registro pela API do dono."
|
|
73
|
+
],
|
|
74
|
+
rankingSignals: { freeText: 0, structuredChoice: 0, date: 0, number: 0.2, denseLayout: 0.2 }
|
|
75
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Item da caixa do usuario, como a API devolve. `payload` so tem identificadores. */
|
|
2
|
+
export type SgNotification = {
|
|
3
|
+
publicId: string;
|
|
4
|
+
/** Livre (ex.: `report.ready`); o sino nao interpreta, so mapeia para icone e acao. */
|
|
5
|
+
type: string;
|
|
6
|
+
title: string;
|
|
7
|
+
message?: string | null;
|
|
8
|
+
payload?: Record<string, unknown> | null;
|
|
9
|
+
read: boolean;
|
|
10
|
+
readAt?: string | null;
|
|
11
|
+
/** ISO-8601 com offset. */
|
|
12
|
+
createdAt: string;
|
|
13
|
+
expiresAt?: string | null;
|
|
14
|
+
};
|
|
15
|
+
export declare const NOTIFICATIONS_CHANNEL = "user-notifications";
|
|
16
|
+
export declare const DEFAULT_EVENT_PATTERN = "user-notification.*";
|
|
17
|
+
export declare const DEFAULT_MAX_VISIBLE = 100;
|
|
18
|
+
export declare const BADGE_MAX = 99;
|
|
19
|
+
export declare const DEFAULT_POLL_INTERVAL_MS = 45000;
|
|
20
|
+
export declare const MIN_POLL_INTERVAL_MS = 30000;
|
|
21
|
+
export declare const MAX_POLL_INTERVAL_MS = 60000;
|
|
22
|
+
export declare const MAX_POLL_BACKOFF_MS = 300000;
|
|
23
|
+
/** Mais nova primeiro; empate pelo `publicId` decrescente (como o servidor: ORDER BY created_at DESC, id DESC). */
|
|
24
|
+
export declare function sortNotifications(items: readonly SgNotification[]): SgNotification[];
|
|
25
|
+
/** Teto da lista: de 1 a 100 (uma pagina da rota); sem valor ou invalido, 100. */
|
|
26
|
+
export declare function clampMaxVisible(value?: number | null): number;
|
|
27
|
+
/** Vencida (`expiresAt` ate agora): o servidor nao as lista, o sino tambem nao as mostra. */
|
|
28
|
+
export declare function isExpired(item: SgNotification, now: string): boolean;
|
|
29
|
+
/** Lista que o sino mostra: sem repetidos (vale o ultimo), sem vencidas (se `now` vier), ordenada, no maximo `max` itens. */
|
|
30
|
+
export declare function normalizeList(items: readonly SgNotification[], max?: number, now?: string): SgNotification[];
|
|
31
|
+
/** Contador mostrado: o do servidor (`GET /unread-count`) quando se sabe, senao as nao lidas da lista local. */
|
|
32
|
+
export declare function displayUnread(serverCount: number | null | undefined, items: readonly SgNotification[]): number;
|
|
33
|
+
/** So conta as nao lidas. */
|
|
34
|
+
export declare function unreadCount(items: readonly SgNotification[]): number;
|
|
35
|
+
/** Texto do contador: vazio com zero, `99+` acima de 99. */
|
|
36
|
+
export declare function badgeText(count: number): string;
|
|
37
|
+
export declare function markRead(items: readonly SgNotification[], publicId: string, readAt: string): SgNotification[];
|
|
38
|
+
export declare function markAllRead(items: readonly SgNotification[], readAt: string): SgNotification[];
|
|
39
|
+
export declare function removeNotification(items: readonly SgNotification[], publicId: string): SgNotification[];
|
|
40
|
+
/**
|
|
41
|
+
* Itens nao lidos que chegaram de verdade: desconhecidos e mais novos que o mais novo da lista anterior (uma nao lida
|
|
42
|
+
* antiga que so apareceu agora, por teto ou por reler, nao e noticia). Sao eles que fazem o sino animar.
|
|
43
|
+
*/
|
|
44
|
+
export declare function newUnread(previous: readonly SgNotification[], next: readonly SgNotification[]): SgNotification[];
|
|
45
|
+
/** `user-notification.*` casa `user-notification.created` e `user-notification.changed`. */
|
|
46
|
+
export declare function matchesEventPattern(pattern: string, event: string): boolean;
|
|
47
|
+
/** O que fazer com um aviso do Pulse. */
|
|
48
|
+
export type SgNotificationEventPlan = {
|
|
49
|
+
action: "fetch";
|
|
50
|
+
publicId: string;
|
|
51
|
+
} | {
|
|
52
|
+
action: "refresh";
|
|
53
|
+
} | {
|
|
54
|
+
action: "ignore";
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* `created` com o identificador: busca so a nova. `created` sem identificador e `changed` (ler, excluir, limpar em
|
|
58
|
+
* outra aba): le a lista de novo. Outro canal ou evento fora do padrao: ignora.
|
|
59
|
+
*/
|
|
60
|
+
export declare function planEvent(pattern: string, channel: string, event: string, data: unknown): SgNotificationEventPlan;
|
|
61
|
+
/** Estado da conexao em tempo real (o mesmo do `SgRealtimeProvider`). */
|
|
62
|
+
export type SgNotificationStatus = "idle" | "disabled" | "connecting" | "connected" | "reconnecting" | "unavailable" | "auth-error";
|
|
63
|
+
/** Por que o polling foi acionado: realtime desligado na configuracao, ou Pulse fora do ar. */
|
|
64
|
+
export type SgPollingReason = "disabled" | "unavailable";
|
|
65
|
+
/**
|
|
66
|
+
* Polling so quando nao ha tempo real nem esperanca de ter logo: `disabled` (enabled:false) e `unavailable` /
|
|
67
|
+
* `auth-error` (Pulse fora do ar). Conectando ou reconectando, o SDK resolve sozinho.
|
|
68
|
+
*/
|
|
69
|
+
export declare function pollingReason(status: SgNotificationStatus): SgPollingReason | null;
|
|
70
|
+
/** Ao (re)conectar, le a lista uma vez para recuperar o que passou enquanto estava fora. */
|
|
71
|
+
export declare function shouldRefreshOnStatus(previous: SgNotificationStatus, next: SgNotificationStatus): boolean;
|
|
72
|
+
/** Intervalo pedido, preso entre 30 e 60 s (padrao 45 s). */
|
|
73
|
+
export declare function clampPollInterval(ms?: number | null): number;
|
|
74
|
+
/**
|
|
75
|
+
* Espera ate a proxima consulta: intervalo, dobrado a cada falha seguida (ate 5 min), com jitter de -10% a +10%, e nunca acima de 5 min (o teto vale depois do jitter).
|
|
76
|
+
* `random` entra de fora (0 a 1) para o resultado ser testavel.
|
|
77
|
+
*/
|
|
78
|
+
export declare function nextPollDelay(baseMs: number | null | undefined, failures: number, random: number): number;
|
|
79
|
+
/** Metrica do polling, exposta por `onPollingTick`. */
|
|
80
|
+
export type SgPollingStats = {
|
|
81
|
+
count: number;
|
|
82
|
+
lastAt: string | null;
|
|
83
|
+
reason: SgPollingReason | null;
|
|
84
|
+
};
|
|
85
|
+
export declare const EMPTY_POLLING_STATS: SgPollingStats;
|
|
86
|
+
export declare function recordPollingTick(stats: SgPollingStats, at: string, reason: SgPollingReason): SgPollingStats;
|
|
87
|
+
export type SgNotificationViewState = "loading" | "error" | "empty" | "caughtUp" | "list";
|
|
88
|
+
/**
|
|
89
|
+
* Qual das telas mostrar. Com erro e lista ja carregada, a lista continua (a falha vira aviso); sem nada para
|
|
90
|
+
* mostrar, erro vence vazio.
|
|
91
|
+
*/
|
|
92
|
+
export declare function viewState(input: {
|
|
93
|
+
loaded: boolean;
|
|
94
|
+
failed: boolean;
|
|
95
|
+
count: number;
|
|
96
|
+
unreadOnly?: boolean;
|
|
97
|
+
}): SgNotificationViewState;
|
|
98
|
+
/** O que a lista mostra: em "Nao lidas" so as ainda nao clicadas; em "Todas", tudo. */
|
|
99
|
+
export declare function visibleItems(items: readonly SgNotification[], unreadOnly: boolean): SgNotification[];
|
|
100
|
+
/** Query da lista: em "Nao lidas" pede so as nao lidas ao servidor (`unreadOnly=true`, que ja entra na ETag). */
|
|
101
|
+
export declare function listQuery(page: number, size: number, unreadOnly: boolean): string;
|
|
102
|
+
/** Clicou numa notificacao: em "Nao lidas" ela sai da lista; em "Todas" so perde o destaque. */
|
|
103
|
+
export declare function afterOpen(items: readonly SgNotification[], publicId: string, unreadOnly: boolean, readAt: string): SgNotification[];
|
|
104
|
+
/** Valor inicial do interruptor: sem interruptor, o painel mostra tudo; com ele, vale `defaultUnreadOnly` (padrao true). */
|
|
105
|
+
export declare function initialUnreadOnly(showUnreadToggle?: boolean | null, defaultUnreadOnly?: boolean | null): boolean;
|
|
106
|
+
export type SgRelativeTime = {
|
|
107
|
+
unit: "now";
|
|
108
|
+
} | {
|
|
109
|
+
unit: "minute" | "hour" | "day";
|
|
110
|
+
value: number;
|
|
111
|
+
} | {
|
|
112
|
+
unit: "date";
|
|
113
|
+
};
|
|
114
|
+
/** Idade da notificacao em partes; o texto ("ha 5 min") e do i18n de cada plataforma. */
|
|
115
|
+
/** Data que da para mostrar (um `createdAt` invalido nao vira 1970 na tela). */
|
|
116
|
+
export declare function isValidInstant(iso: string): boolean;
|
|
117
|
+
export declare function relativeTime(createdAt: string, now: string): SgRelativeTime;
|
|
118
|
+
/** De que lado do sino o painel abre (os mesmos valores do SgPopup). `auto` escolhe o lado em que cabe. */
|
|
119
|
+
export type SgNotificationPlacement = "auto" | "top" | "right" | "bottom" | "left";
|
|
120
|
+
export type SgPanelSide = Exclude<SgNotificationPlacement, "auto">;
|
|
121
|
+
export type SgPanelPosition = {
|
|
122
|
+
top: number;
|
|
123
|
+
left: number;
|
|
124
|
+
side: SgPanelSide;
|
|
125
|
+
maxHeight: number;
|
|
126
|
+
};
|
|
127
|
+
/** Largura do painel (26rem) e altura maxima (32rem), em px. */
|
|
128
|
+
export declare const PANEL_WIDTH = 416;
|
|
129
|
+
export declare const PANEL_MAX_HEIGHT = 512;
|
|
130
|
+
/**
|
|
131
|
+
* Onde o painel fica, em coordenadas da janela, sem sair dela. `auto` tenta embaixo, em cima, a direita e a esquerda
|
|
132
|
+
* (nessa ordem) e fica com o primeiro lado em que o painel cabe inteiro; se nenhum couber, usa embaixo ou em cima,
|
|
133
|
+
* o que tiver mais espaco. O lado forcado (`top`, `right`...) e respeitado. O painel se alinha ao fim do sino e e
|
|
134
|
+
* empurrado para dentro da janela. `panelHeight` e a altura real do painel (ate PANEL_MAX_HEIGHT).
|
|
135
|
+
*/
|
|
136
|
+
export declare function panelPosition(anchor: {
|
|
137
|
+
left: number;
|
|
138
|
+
top: number;
|
|
139
|
+
right: number;
|
|
140
|
+
bottom: number;
|
|
141
|
+
}, panel: {
|
|
142
|
+
width: number;
|
|
143
|
+
height: number;
|
|
144
|
+
}, viewport: {
|
|
145
|
+
width: number;
|
|
146
|
+
height: number;
|
|
147
|
+
}, placement?: SgNotificationPlacement, offset?: number, padding?: number): SgPanelPosition;
|
|
148
|
+
//# sourceMappingURL=notificationBellLogic.d.ts.map
|