@wasto/cms-components 1.5.18 → 1.5.19

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,133 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { Platform } from "react-native";
3
+ import { useAnnouncements } from "../../contexts/AnnouncementsContext";
4
+ import { useWasto } from "../../contexts/WastoContext";
5
+ import { useOptionalAppNavigation } from "../../navigation/NavigationContext";
6
+
7
+ type NotificationsModule = typeof import("expo-notifications");
8
+
9
+ let notificationsModule: NotificationsModule | null | undefined;
10
+
11
+ function loadNotifications(): NotificationsModule | null {
12
+ if (notificationsModule !== undefined) return notificationsModule;
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ notificationsModule = require("expo-notifications") as NotificationsModule;
16
+ } catch {
17
+ notificationsModule = null;
18
+ }
19
+ return notificationsModule;
20
+ }
21
+
22
+ type PushData = {
23
+ announcementId?: unknown;
24
+ showAsModal?: unknown;
25
+ href?: unknown;
26
+ };
27
+
28
+ function asPushData(data: unknown): PushData {
29
+ if (!data || typeof data !== "object") return {};
30
+ return data as PushData;
31
+ }
32
+
33
+ function extractHref(data: PushData): string | null {
34
+ if (typeof data.href !== "string") return null;
35
+ const trimmed = data.href.trim();
36
+ return trimmed || null;
37
+ }
38
+
39
+ function isShowAsModal(data: PushData): boolean {
40
+ return data.showAsModal === true || data.showAsModal === "true";
41
+ }
42
+
43
+ function extractAnnouncementId(data: PushData): string | null {
44
+ if (typeof data.announcementId !== "string") return null;
45
+ const trimmed = data.announcementId.trim();
46
+ return trimmed || null;
47
+ }
48
+
49
+ /**
50
+ * Handles Expo notification taps (warm + cold start):
51
+ * - showAsModal + announcementId → open AnnouncementModal
52
+ * - otherwise href → NavigationApi.push
53
+ * Mount inside AnnouncementsProvider (under NavigationProvider).
54
+ */
55
+ export function PushNotificationNavigator(): ReactNode {
56
+ const { previewMode, reportError } = useWasto();
57
+ const navigation = useOptionalAppNavigation();
58
+ const { openFromPushPayload } = useAnnouncements();
59
+ const handledResponseId = useRef<string | null>(null);
60
+
61
+ useEffect(() => {
62
+ if (previewMode || Platform.OS === "web") return;
63
+
64
+ const Notifications = loadNotifications();
65
+ if (!Notifications) return;
66
+
67
+ const openFromResponse = (response: {
68
+ notification: {
69
+ request: {
70
+ identifier?: string;
71
+ content: {
72
+ title?: string | null;
73
+ body?: string | null;
74
+ data?: unknown;
75
+ };
76
+ };
77
+ };
78
+ }) => {
79
+ const responseId = response.notification.request.identifier ?? "";
80
+ if (responseId && handledResponseId.current === responseId) return;
81
+
82
+ const content = response.notification.request.content;
83
+ const data = asPushData(content.data);
84
+ const announcementId = extractAnnouncementId(data);
85
+ const href = extractHref(data);
86
+
87
+ const markHandled = () => {
88
+ if (responseId) handledResponseId.current = responseId;
89
+ };
90
+
91
+ void (async () => {
92
+ try {
93
+ if (isShowAsModal(data) && announcementId) {
94
+ markHandled();
95
+ await openFromPushPayload({
96
+ announcementId,
97
+ title: content.title,
98
+ body: content.body,
99
+ redirectHref: href,
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (href && navigation) {
105
+ markHandled();
106
+ navigation.push(href);
107
+ }
108
+ } catch (error) {
109
+ reportError(error, {
110
+ source: "push",
111
+ path: href ?? announcementId ?? undefined,
112
+ });
113
+ }
114
+ })();
115
+ };
116
+
117
+ const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
118
+
119
+ void Notifications.getLastNotificationResponseAsync()
120
+ .then((response) => {
121
+ if (response) openFromResponse(response);
122
+ })
123
+ .catch((error) => {
124
+ reportError(error, { source: "push" });
125
+ });
126
+
127
+ return () => {
128
+ subscription.remove();
129
+ };
130
+ }, [navigation, openFromPushPayload, previewMode, reportError]);
131
+
132
+ return null;
133
+ }
@@ -8,12 +8,12 @@ import { registerExpoPushToken } from "../../push/registerExpoPushToken";
8
8
  /**
9
9
  * Registers the Expo push token once a deviceId exists.
10
10
  * Retries when the app comes back to the foreground (permission granted in OS settings).
11
- * Associates the token with the selected collection place (→ city → city group).
11
+ * Associates the token with cmsAppId + selected collection place (→ city → city group).
12
12
  * Renders nothing — mount inside LocalProvider.
13
13
  */
14
14
  export function PushTokenRegistrar(): ReactNode {
15
15
  const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
16
- const { previewMode, reportError } = useWasto();
16
+ const { previewMode, reportError, appId } = useWasto();
17
17
  const client = useWastoClient();
18
18
  const lastRegistered = useRef<string | null>(null);
19
19
  const inFlight = useRef(false);
@@ -25,7 +25,7 @@ export function PushTokenRegistrar(): ReactNode {
25
25
 
26
26
  const tryRegister = async () => {
27
27
  if (cancelled || inFlight.current) return;
28
- const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
28
+ const registrationKey = `${deviceId}:${appId ?? ""}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
29
29
  if (lastRegistered.current === registrationKey) return;
30
30
 
31
31
  inFlight.current = true;
@@ -35,6 +35,7 @@ export function PushTokenRegistrar(): ReactNode {
35
35
  registerPushToken: (id: string, pushToken: string) =>
36
36
  client.public.devices.registerPushToken(id, {
37
37
  token: pushToken,
38
+ cmsAppId: appId || null,
38
39
  cityId: selectedCityId,
39
40
  wasteCollectionPlaceId: selectedPlaceId,
40
41
  }),
@@ -73,6 +74,7 @@ export function PushTokenRegistrar(): ReactNode {
73
74
  sub.remove();
74
75
  };
75
76
  }, [
77
+ appId,
76
78
  client.public.devices,
77
79
  deviceId,
78
80
  previewMode,
@@ -6,6 +6,7 @@ import {
6
6
  useContext,
7
7
  useEffect,
8
8
  useMemo,
9
+ useRef,
9
10
  useState,
10
11
  type ReactNode,
11
12
  } from "react";
@@ -18,6 +19,15 @@ export type PublicAnnouncement = {
18
19
  body: string;
19
20
  imageUrl?: string | null;
20
21
  sentAt?: string | null;
22
+ redirectHref?: string | null;
23
+ };
24
+
25
+ export type OpenFromPushPayload = {
26
+ announcementId: string;
27
+ title?: string | null;
28
+ body?: string | null;
29
+ redirectHref?: string | null;
30
+ imageUrl?: string | null;
21
31
  };
22
32
 
23
33
  type AnnouncementsContextType = {
@@ -28,6 +38,8 @@ type AnnouncementsContextType = {
28
38
  markAsRead: (id: string) => Promise<void>;
29
39
  /** Open the shared AnnouncementModal for a specific item (inbox). */
30
40
  openAnnouncement: (item: PublicAnnouncement) => void;
41
+ /** Open modal from a push notification tap (skips if already dismissed). */
42
+ openFromPushPayload: (payload: OpenFromPushPayload) => Promise<boolean>;
31
43
  openFromBell: () => Promise<void>;
32
44
  forceVisible: boolean;
33
45
  };
@@ -63,25 +75,33 @@ function resolveImageUrl(apiUrl: string, url?: string | null): string | null {
63
75
 
64
76
  export type AnnouncementsProviderProps = PropsWithChildren & {
65
77
  apiUrl: string;
78
+ appId: string;
66
79
  };
67
80
 
68
- export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProviderProps): ReactNode {
81
+ export function AnnouncementsProvider({ children, apiUrl, appId }: AnnouncementsProviderProps): ReactNode {
69
82
  const { selectedCityId, hasPlaceSelected } = useLocal();
70
83
  const client = useWastoClient();
71
84
  const [announcement, setAnnouncement] = useState<PublicAnnouncement | null>(null);
72
85
  const [hasUnread, setHasUnread] = useState(false);
73
86
  const [forceVisible, setForceVisible] = useState(false);
74
87
  const [queue, setQueue] = useState<PublicAnnouncement[]>([]);
88
+ /** When true, load() must not replace the currently shown announcement. */
89
+ const holdingPushModalRef = useRef(false);
90
+ const queueRef = useRef<PublicAnnouncement[]>([]);
91
+ queueRef.current = queue;
75
92
 
76
93
  const load = useCallback(async (): Promise<PublicAnnouncement[]> => {
77
- if (!selectedCityId || !hasPlaceSelected) {
78
- setAnnouncement(null);
94
+ if (!selectedCityId || !hasPlaceSelected || !appId) {
95
+ if (!holdingPushModalRef.current) {
96
+ setAnnouncement(null);
97
+ setForceVisible(false);
98
+ }
79
99
  setHasUnread(false);
80
100
  setQueue([]);
81
101
  return [];
82
102
  }
83
103
  try {
84
- const list = await client.public.announcements.listForCity(selectedCityId);
104
+ const list = await client.public.announcements.listForCity(selectedCityId, appId);
85
105
  const dismissed = await getDismissedIds();
86
106
  const pending = (list ?? [])
87
107
  .filter((a) => a?.id && !dismissed.has(a.id))
@@ -91,18 +111,24 @@ export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProvide
91
111
  }));
92
112
  setQueue(pending);
93
113
  setHasUnread(pending.length > 0);
94
- setAnnouncement(pending[0] ?? null);
114
+ if (!holdingPushModalRef.current) {
115
+ setAnnouncement(pending[0] ?? null);
116
+ }
95
117
  return pending;
96
118
  } catch {
97
- setAnnouncement(null);
119
+ if (!holdingPushModalRef.current) {
120
+ setAnnouncement(null);
121
+ setForceVisible(false);
122
+ }
98
123
  setHasUnread(false);
99
124
  setQueue([]);
100
125
  return [];
101
126
  }
102
- }, [apiUrl, client.public.announcements, hasPlaceSelected, selectedCityId]);
127
+ }, [apiUrl, appId, client.public.announcements, hasPlaceSelected, selectedCityId]);
103
128
 
104
129
  useEffect(() => {
105
130
  void load().then((pending) => {
131
+ if (holdingPushModalRef.current) return;
106
132
  if (pending.length > 0) setForceVisible(true);
107
133
  });
108
134
  }, [load]);
@@ -118,11 +144,13 @@ export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProvide
118
144
 
119
145
  const dismiss = useCallback(async () => {
120
146
  if (!announcement) {
147
+ holdingPushModalRef.current = false;
121
148
  setForceVisible(false);
122
149
  return;
123
150
  }
124
151
  const id = announcement.id;
125
152
  await markDismissed(id);
153
+ holdingPushModalRef.current = false;
126
154
  const nextQueue = queue.filter((a) => a.id !== id);
127
155
  setQueue(nextQueue);
128
156
  setHasUnread(nextQueue.length > 0);
@@ -135,6 +163,32 @@ export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProvide
135
163
  setForceVisible(true);
136
164
  }, []);
137
165
 
166
+ const openFromPushPayload = useCallback(async (payload: OpenFromPushPayload) => {
167
+ const id = payload.announcementId?.trim();
168
+ if (!id) return false;
169
+ const dismissed = await getDismissedIds();
170
+ if (dismissed.has(id)) return false;
171
+
172
+ const fromQueue = queueRef.current.find((a) => a.id === id);
173
+ const item: PublicAnnouncement = {
174
+ id,
175
+ title: (payload.title?.trim() || fromQueue?.title || "").trim() || "Annonce",
176
+ body: (payload.body?.trim() || fromQueue?.body || "").trim(),
177
+ imageUrl: payload.imageUrl
178
+ ? resolveImageUrl(apiUrl, payload.imageUrl)
179
+ : fromQueue?.imageUrl ?? null,
180
+ sentAt: fromQueue?.sentAt ?? null,
181
+ redirectHref: payload.redirectHref?.trim() || fromQueue?.redirectHref || null,
182
+ };
183
+
184
+ holdingPushModalRef.current = true;
185
+ setQueue((prev) => prev.filter((a) => a.id !== id));
186
+ setAnnouncement(item);
187
+ setForceVisible(true);
188
+ setHasUnread(true);
189
+ return true;
190
+ }, [apiUrl]);
191
+
138
192
  const openFromBell = useCallback(async () => {
139
193
  const pending = queue.length > 0 ? queue : await load();
140
194
  if (pending.length === 0) return;
@@ -149,10 +203,20 @@ export function AnnouncementsProvider({ children, apiUrl }: AnnouncementsProvide
149
203
  dismiss,
150
204
  markAsRead,
151
205
  openAnnouncement,
206
+ openFromPushPayload,
152
207
  openFromBell,
153
208
  forceVisible,
154
209
  }),
155
- [announcement, dismiss, forceVisible, hasUnread, markAsRead, openAnnouncement, openFromBell],
210
+ [
211
+ announcement,
212
+ dismiss,
213
+ forceVisible,
214
+ hasUnread,
215
+ markAsRead,
216
+ openAnnouncement,
217
+ openFromBell,
218
+ openFromPushPayload,
219
+ ],
156
220
  );
157
221
 
158
222
  return (
@@ -50,17 +50,20 @@ export type LocalContextType = {
50
50
 
51
51
  export const LocalContext = createContext<LocalContextType | undefined>(undefined);
52
52
 
53
- export type LocalContextProps = PropsWithChildren & {
54
- previewMode?: boolean;
55
- };
56
-
57
53
  function storageKey(base: string, previewMode: boolean) {
58
54
  return previewMode ? `${PREVIEW_PREFIX}${base}` : base;
59
55
  }
60
56
 
57
+ export type LocalContextProps = PropsWithChildren & {
58
+ previewMode?: boolean;
59
+ /** CmsApp id — stored on anonymous devices for push/announcement scoping */
60
+ appId?: string | null;
61
+ };
62
+
61
63
  export function LocalProvider({
62
64
  children,
63
65
  previewMode = false,
66
+ appId = null,
64
67
  }: LocalContextProps): ReactNode {
65
68
  const keys = useMemo(
66
69
  () => ({
@@ -103,6 +106,7 @@ export function LocalProvider({
103
106
  osVersion: null,
104
107
  appVersion: null,
105
108
  deviceModel: null,
109
+ cmsAppId: appId || null,
106
110
  });
107
111
  if (!existingDeviceId) {
108
112
  await AsyncStorage.setItem(keys.device, nextDeviceId);
@@ -125,7 +129,7 @@ export function LocalProvider({
125
129
  setIsLoading(false);
126
130
  return resolvedDeviceId;
127
131
  },
128
- [client.public.analytics, keys, previewMode],
132
+ [appId, client.public.analytics, keys, previewMode],
129
133
  );
130
134
 
131
135
  const clearPlaceSelection = useCallback(async () => {
@@ -16,6 +16,7 @@ import { AnnouncementsProvider } from "./AnnouncementsContext";
16
16
  import SelectStreetModal from "../components/select-street-modal/SelectStreetModal";
17
17
  import { AnnouncementModal } from "../components/announcement-modal/AnnouncementModal";
18
18
  import { PushTokenRegistrar } from "../components/push/PushTokenRegistrar";
19
+ import { PushNotificationNavigator } from "../components/push/PushNotificationNavigator";
19
20
  import { OptionalSafeAreaProvider } from "../components/safe-area/OptionalSafeAreaProvider";
20
21
  import {
21
22
  initWastoSentry,
@@ -104,10 +105,11 @@ export function WastoProvider(props: WastoProviderProps): ReactNode {
104
105
  return (
105
106
  <WastoContext value={value}>
106
107
  <OptionalSafeAreaProvider>
107
- <LocalProvider previewMode={previewMode}>
108
+ <LocalProvider previewMode={previewMode} appId={appId}>
108
109
  <PushTokenRegistrar />
109
110
  <ChallengesProvider>
110
- <AnnouncementsProvider apiUrl={apiUrl}>
111
+ <AnnouncementsProvider apiUrl={apiUrl} appId={appId}>
112
+ <PushNotificationNavigator />
111
113
  <ThemeProvider theme={props.theme}>
112
114
  <AppDataProvider apiUrl={apiUrl}>
113
115
  {props.children}
@@ -33,7 +33,7 @@ function resolveImageUrl(apiUrl: string, url?: string | null): string | null {
33
33
  }
34
34
 
35
35
  export function useCityAnnouncements() {
36
- const { apiUrl } = useWasto();
36
+ const { apiUrl, appId } = useWasto();
37
37
  const { selectedCityId, hasPlaceSelected } = useLocal();
38
38
  const client = useWastoClient();
39
39
  const { markAsRead: contextMarkAsRead, openAnnouncement } = useAnnouncements();
@@ -44,7 +44,7 @@ export function useCityAnnouncements() {
44
44
  const [error, setError] = useState<string | null>(null);
45
45
 
46
46
  const reload = useCallback(async () => {
47
- if (!selectedCityId || !hasPlaceSelected) {
47
+ if (!selectedCityId || !hasPlaceSelected || !appId) {
48
48
  setAll([]);
49
49
  setLoading(false);
50
50
  setError(null);
@@ -54,7 +54,7 @@ export function useCityAnnouncements() {
54
54
  setError(null);
55
55
  try {
56
56
  const [list, dismissed] = await Promise.all([
57
- client.public.announcements.listForCity(selectedCityId),
57
+ client.public.announcements.listForCity(selectedCityId, appId),
58
58
  readDismissedIds(),
59
59
  ]);
60
60
  const items: CityAnnouncementItem[] = (list ?? [])
@@ -77,7 +77,7 @@ export function useCityAnnouncements() {
77
77
  } finally {
78
78
  setLoading(false);
79
79
  }
80
- }, [apiUrl, client.public.announcements, hasPlaceSelected, selectedCityId]);
80
+ }, [apiUrl, appId, client.public.announcements, hasPlaceSelected, selectedCityId]);
81
81
 
82
82
  useEffect(() => {
83
83
  void reload();
@@ -94,6 +94,7 @@ export type UseCollectionCalendarResult = {
94
94
  weeks: CalendarDayCell[][];
95
95
 
96
96
  selectedPlaceId: string | null;
97
+ wasteTypes: WasteType[];
97
98
 
98
99
  /** Collectes pour une date précise (utile pour les cases en débordement). */
99
100
  getWasteTypesForDate: (year: number, month: number, day: number) => WasteTypeInfoForDay[];
@@ -413,6 +414,7 @@ export function useCollectionCalendar(options: UseCollectionCalendarOptions = {}
413
414
  weeks,
414
415
 
415
416
  selectedPlaceId: resolvedPlaceId,
417
+ wasteTypes,
416
418
 
417
419
  getWasteTypesForDate,
418
420
  getWasteTypesForDay,