@wasto/cms-components 1.5.11 → 1.5.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wasto/cms-components",
3
- "version": "1.5.11",
3
+ "version": "1.5.12",
4
4
  "description": "Wast'O CMS React Native components",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -30,10 +30,12 @@
30
30
  "@react-navigation/bottom-tabs": "*",
31
31
  "@sentry/react-native": "*",
32
32
  "@wasto/sdk": "*",
33
+ "expo-constants": "*",
33
34
  "expo-haptics": "*",
34
35
  "expo-image": "*",
35
36
  "expo-image-picker": "*",
36
37
  "expo-linear-gradient": "*",
38
+ "expo-notifications": "*",
37
39
  "expo-router": "*",
38
40
  "lucide-react-native": "*",
39
41
  "maplibre-gl": "*",
@@ -51,12 +53,18 @@
51
53
  "@sentry/react-native": {
52
54
  "optional": true
53
55
  },
56
+ "expo-constants": {
57
+ "optional": true
58
+ },
54
59
  "expo-image-picker": {
55
60
  "optional": true
56
61
  },
57
62
  "expo-linear-gradient": {
58
63
  "optional": true
59
64
  },
65
+ "expo-notifications": {
66
+ "optional": true
67
+ },
60
68
  "expo-router": {
61
69
  "optional": true
62
70
  },
@@ -69,8 +77,10 @@
69
77
  "@react-navigation/bottom-tabs": "^7.4.0",
70
78
  "@sentry/react-native": "~6.22.0",
71
79
  "@types/react": "~19.2.2",
80
+ "expo-constants": "~18.0.9",
72
81
  "expo-haptics": "~15.0.7",
73
82
  "expo-linear-gradient": "~15.0.8",
83
+ "expo-notifications": "~0.32.17",
74
84
  "maplibre-gl": "^4.7.0",
75
85
  "react-native-gesture-handler": "~2.28.0",
76
86
  "react-native-markdown-display": "^7.0.2",
@@ -58,6 +58,26 @@ export type AppHeaderConfig = {
58
58
  elements: AppHeaderElement[];
59
59
  };
60
60
 
61
+ /** Actions standard (notifications + profil) pour navbar sans titre. */
62
+ export const DEFAULT_HEADER_ACTIONS: readonly AppHeaderActionElement[] = [
63
+ {
64
+ id: "notifications",
65
+ type: "action",
66
+ slot: "right",
67
+ icon: "bell",
68
+ action: "navigate",
69
+ href: SYSTEM_ROUTES.notifications,
70
+ },
71
+ {
72
+ id: "profile",
73
+ type: "action",
74
+ slot: "right",
75
+ icon: "person",
76
+ action: "navigate",
77
+ href: SYSTEM_ROUTES.profile,
78
+ },
79
+ ];
80
+
61
81
  export type HeaderProps = {
62
82
  readonly options: { title?: string; headerBackVisible?: boolean };
63
83
  readonly route: { name: string };
@@ -67,7 +87,10 @@ export type HeaderProps = {
67
87
  };
68
88
  /** Flotte au-dessus du contenu (ex. carte) : fond transparent. */
69
89
  readonly overlay?: boolean;
70
- /** Config layout (`header.elements`) — logo / titre / actions. */
90
+ /**
91
+ * Config layout (`header.elements`) — logo / actions.
92
+ * Si fournie (même vide), le fallback titre d’écran ne s’applique pas.
93
+ */
71
94
  readonly headerConfig?: AppHeaderConfig | null;
72
95
  };
73
96
 
@@ -143,8 +166,13 @@ export function Header({
143
166
  [options.title, route.name],
144
167
  );
145
168
 
146
- const elements = headerConfig?.elements ?? [];
147
- const hasCustom = elements.length > 0;
169
+ /** Config explicite → navbar (logo/actions), jamais le fallback titre d’écran. */
170
+ const hasExplicitConfig = headerConfig != null;
171
+ const elements = useMemo(() => {
172
+ const raw = headerConfig?.elements ?? [];
173
+ // `type: "title"` est déprécié (navbar uniquement).
174
+ return raw.filter((el) => el.type !== "title");
175
+ }, [headerConfig]);
148
176
  const hasCustomActions = elements.some((el) => el.type === "action");
149
177
 
150
178
  const bySlot = useMemo(() => {
@@ -174,11 +202,7 @@ export function Header({
174
202
  return <HeaderLogo key={el.id} element={el} />;
175
203
  }
176
204
  if (el.type === "title") {
177
- const text =
178
- el.useScreenTitle !== false
179
- ? screenTitle
180
- : (el.text || "").trim() || screenTitle;
181
- return <Title key={el.id} text={text} level="1" />;
205
+ return null;
182
206
  }
183
207
  // action
184
208
  const onPress = async () => {
@@ -259,7 +283,7 @@ export function Header({
259
283
  pointerEvents="box-none"
260
284
  >
261
285
  <View style={styles.header} pointerEvents="box-none">
262
- {hasCustom ? (
286
+ {hasExplicitConfig ? (
263
287
  <>
264
288
  <View style={styles.slotLeft} pointerEvents="box-none">
265
289
  {showBack ? (
@@ -27,15 +27,22 @@ import { LocationSheet } from "./LocationSheet";
27
27
  import type { LocationsMapViewProps } from "./LocationsMapView";
28
28
  import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
29
29
  import { getContrastColor } from "../../helpers/getContrastColor";
30
- import { Header } from "../header/Header";
30
+ import { Header, DEFAULT_HEADER_ACTIONS, type AppHeaderConfig } from "../header/Header";
31
+ import { useHeaderConfig } from "../../contexts/HeaderConfigContext";
31
32
  import { SPACE } from "../../helpers/layout";
32
33
 
33
34
  export type LocationsMapProps = {
34
35
  fullScreen?: boolean;
35
36
  showFilters?: boolean;
36
- /** Header flottant au-dessus de la carte (titre + cloche + profil). */
37
+ /** Header flottant au-dessus de la carte (même navbar que le layout, sans titre). */
37
38
  overlayHeader?: boolean;
39
+ /**
40
+ * @deprecated Ignoré — la navbar n’affiche plus de titre d’écran.
41
+ * Conservé pour compatibilité des configs CMS existantes.
42
+ */
38
43
  overlayTitle?: string;
44
+ /** Override optionnel ; sinon config du layout via `HeaderConfigProvider`. */
45
+ headerConfig?: AppHeaderConfig | null;
39
46
  };
40
47
 
41
48
  const CATEGORIES = Object.keys(LOCATION_CATEGORY_META) as LocationCategory[];
@@ -168,17 +175,28 @@ export function LocationsMapShell({
168
175
  fullScreen = false,
169
176
  showFilters = true,
170
177
  overlayHeader = false,
171
- overlayTitle = "Carte",
178
+ headerConfig: headerConfigProp,
172
179
  MapCanvas,
173
180
  }: LocationsMapShellProps): ReactNode {
174
181
  const { theme } = useTheme();
175
182
  const t = resolveTheme(theme);
176
183
  const themedStyles = createThemedStyles(theme);
184
+ const layoutHeaderConfig = useHeaderConfig();
177
185
  const { locations, isLoading, error, center } = useLocations();
178
186
  const [filters, setFilters] = useState<Set<LocationCategory>>(new Set());
179
187
  const [selected, setSelected] = useState<MapLocation | null>(null);
180
188
  const [filtersOpen, setFiltersOpen] = useState(false);
181
189
 
190
+ const overlayHeaderConfig: AppHeaderConfig = useMemo(() => {
191
+ const raw = headerConfigProp ?? layoutHeaderConfig;
192
+ if (raw?.elements?.length) {
193
+ return {
194
+ elements: raw.elements.filter((el) => el.type !== "title"),
195
+ };
196
+ }
197
+ return { elements: DEFAULT_HEADER_ACTIONS.map((a) => ({ ...a })) };
198
+ }, [headerConfigProp, layoutHeaderConfig]);
199
+
182
200
  const toggle = (category: LocationCategory) => {
183
201
  setFilters((prev) => {
184
202
  const next = new Set(prev);
@@ -252,9 +270,10 @@ export function LocationsMapShell({
252
270
  {overlayHeader ? (
253
271
  <Header
254
272
  overlay
255
- options={{ title: overlayTitle, headerBackVisible: false }}
256
- route={{ name: overlayTitle }}
273
+ options={{ headerBackVisible: false }}
274
+ route={{ name: "map" }}
257
275
  navigation={NOOP_NAV}
276
+ headerConfig={overlayHeaderConfig}
258
277
  />
259
278
  ) : null}
260
279
 
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef, type ReactNode } from "react";
2
+ import { Platform } from "react-native";
3
+ import { useLocal } from "../../contexts/LocalContext";
4
+ import { useWasto } from "../../contexts/WastoContext";
5
+ import { useWastoClient } from "../../hooks/useWastoClient";
6
+ import { registerExpoPushToken } from "../../push/registerExpoPushToken";
7
+
8
+ /**
9
+ * Registers the Expo push token once a deviceId exists.
10
+ * Renders nothing — mount inside LocalProvider.
11
+ */
12
+ export function PushTokenRegistrar(): ReactNode {
13
+ const { deviceId } = useLocal();
14
+ const { previewMode, reportError } = useWasto();
15
+ const client = useWastoClient();
16
+ const lastRegistered = useRef<string | null>(null);
17
+
18
+ useEffect(() => {
19
+ if (previewMode || Platform.OS === "web" || !deviceId) return;
20
+ if (lastRegistered.current === deviceId) return;
21
+
22
+ let cancelled = false;
23
+
24
+ void (async () => {
25
+ try {
26
+ const token = await registerExpoPushToken({
27
+ deviceId,
28
+ registerPushToken: (id: string, pushToken: string) =>
29
+ client.public.devices.registerPushToken(id, { token: pushToken }),
30
+ });
31
+ if (!cancelled && token) {
32
+ lastRegistered.current = deviceId;
33
+ }
34
+ } catch (error) {
35
+ if (!cancelled) {
36
+ reportError(error, { source: "push", deviceId });
37
+ }
38
+ }
39
+ })();
40
+
41
+ return () => {
42
+ cancelled = true;
43
+ };
44
+ }, [client.public.devices, deviceId, previewMode, reportError]);
45
+
46
+ return null;
47
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ReactNode,
5
+ } from "react";
6
+ import type { AppHeaderConfig } from "../components/header/Header";
7
+
8
+ const HeaderConfigContext = createContext<AppHeaderConfig | null>(null);
9
+
10
+ export type HeaderConfigProviderProps = {
11
+ readonly value: AppHeaderConfig | null;
12
+ readonly children: ReactNode;
13
+ };
14
+
15
+ export function HeaderConfigProvider({
16
+ value,
17
+ children,
18
+ }: HeaderConfigProviderProps): ReactNode {
19
+ return (
20
+ <HeaderConfigContext.Provider value={value}>
21
+ {children}
22
+ </HeaderConfigContext.Provider>
23
+ );
24
+ }
25
+
26
+ /** Config header du layout parent (`null` si hors provider / non défini). */
27
+ export function useHeaderConfig(): AppHeaderConfig | null {
28
+ return useContext(HeaderConfigContext);
29
+ }
@@ -15,6 +15,7 @@ import { ChallengesProvider } from "./ChallengesContext";
15
15
  import { AnnouncementsProvider } from "./AnnouncementsContext";
16
16
  import SelectStreetModal from "../components/select-street-modal/SelectStreetModal";
17
17
  import { AnnouncementModal } from "../components/announcement-modal/AnnouncementModal";
18
+ import { PushTokenRegistrar } from "../components/push/PushTokenRegistrar";
18
19
  import { OptionalSafeAreaProvider } from "../components/safe-area/OptionalSafeAreaProvider";
19
20
  import {
20
21
  initWastoSentry,
@@ -104,6 +105,7 @@ export function WastoProvider(props: WastoProviderProps): ReactNode {
104
105
  <WastoContext value={value}>
105
106
  <OptionalSafeAreaProvider>
106
107
  <LocalProvider previewMode={previewMode}>
108
+ <PushTokenRegistrar />
107
109
  <ChallengesProvider>
108
110
  <AnnouncementsProvider apiUrl={apiUrl}>
109
111
  <ThemeProvider theme={props.theme}>
package/src/index.ts CHANGED
@@ -63,6 +63,7 @@ export {
63
63
  } from "./components/title/Title"
64
64
  export {
65
65
  Header,
66
+ DEFAULT_HEADER_ACTIONS,
66
67
  type HeaderProps,
67
68
  type AppHeaderConfig,
68
69
  type AppHeaderElement,
@@ -98,6 +99,12 @@ export {
98
99
  type WastoContextType,
99
100
  } from "./contexts/WastoContext"
100
101
 
102
+ export {
103
+ HeaderConfigProvider,
104
+ useHeaderConfig,
105
+ type HeaderConfigProviderProps,
106
+ } from "./contexts/HeaderConfigContext"
107
+
101
108
  export {
102
109
  useAppData,
103
110
  useOptionalAppData,
@@ -114,6 +121,11 @@ export {
114
121
  type WastoErrorContext,
115
122
  } from "./sentry/initSentry"
116
123
 
124
+ export {
125
+ registerExpoPushToken,
126
+ type RegisterExpoPushTokenOptions,
127
+ } from "./push/registerExpoPushToken"
128
+
117
129
  export { useWastoClient } from "./hooks/useWastoClient"
118
130
 
119
131
  export type {
@@ -0,0 +1,133 @@
1
+ import { Platform } from "react-native";
2
+
3
+ type NotificationsModule = typeof import("expo-notifications");
4
+
5
+ type ExpoConstantsLike = {
6
+ expoConfig?: {
7
+ extra?: {
8
+ eas?: {
9
+ projectId?: string;
10
+ };
11
+ };
12
+ } | null;
13
+ easConfig?: {
14
+ projectId?: string;
15
+ };
16
+ };
17
+
18
+ let notificationsModule: NotificationsModule | null | undefined;
19
+ let constantsModule: ExpoConstantsLike | null | undefined;
20
+ let handlerConfigured = false;
21
+
22
+ function loadNotifications(): NotificationsModule | null {
23
+ if (notificationsModule !== undefined) return notificationsModule;
24
+ try {
25
+ // Optional peer — apps without the package still load cms-components.
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ notificationsModule = require("expo-notifications") as NotificationsModule;
28
+ } catch {
29
+ notificationsModule = null;
30
+ }
31
+ return notificationsModule;
32
+ }
33
+
34
+ function loadConstants(): ExpoConstantsLike | null {
35
+ if (constantsModule !== undefined) return constantsModule;
36
+ try {
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const mod = require("expo-constants") as
39
+ | ExpoConstantsLike
40
+ | { default: ExpoConstantsLike };
41
+ constantsModule = "default" in mod && mod.default ? mod.default : (mod as ExpoConstantsLike);
42
+ } catch {
43
+ constantsModule = null;
44
+ }
45
+ return constantsModule;
46
+ }
47
+
48
+ function resolveEasProjectId(): string | null {
49
+ const Constants = loadConstants();
50
+ const fromExtra = Constants?.expoConfig?.extra?.eas?.projectId;
51
+ const fromEasConfig = Constants?.easConfig?.projectId;
52
+ const fromEnv =
53
+ typeof process !== "undefined"
54
+ ? String(process.env.EXPO_PUBLIC_EAS_PROJECT_ID ?? "").trim()
55
+ : "";
56
+ const projectId = String(fromExtra || fromEasConfig || fromEnv || "").trim();
57
+ return projectId || null;
58
+ }
59
+
60
+ export type RegisterExpoPushTokenOptions = {
61
+ deviceId: string;
62
+ registerPushToken: (deviceId: string, token: string) => Promise<unknown>;
63
+ };
64
+
65
+ /**
66
+ * Request OS permission, fetch Expo push token, register it on the API.
67
+ * No-op on web, preview, missing peer, missing EAS projectId, or denied permission.
68
+ */
69
+ export async function registerExpoPushToken(
70
+ options: RegisterExpoPushTokenOptions,
71
+ ): Promise<string | null> {
72
+ if (Platform.OS === "web") return null;
73
+
74
+ const deviceId = options.deviceId?.trim();
75
+ if (!deviceId) return null;
76
+
77
+ const Notifications = loadNotifications();
78
+ if (!Notifications) {
79
+ if (__DEV__) {
80
+ console.warn(
81
+ "[wasto] expo-notifications is not installed; push registration skipped",
82
+ );
83
+ }
84
+ return null;
85
+ }
86
+
87
+ const projectId = resolveEasProjectId();
88
+ if (!projectId) {
89
+ if (__DEV__) {
90
+ console.warn(
91
+ "[wasto] EAS projectId missing (extra.eas.projectId or EXPO_PUBLIC_EAS_PROJECT_ID); push registration skipped",
92
+ );
93
+ }
94
+ return null;
95
+ }
96
+
97
+ if (!handlerConfigured) {
98
+ Notifications.setNotificationHandler({
99
+ handleNotification: async () => ({
100
+ shouldShowAlert: true,
101
+ shouldPlaySound: true,
102
+ shouldSetBadge: false,
103
+ shouldShowBanner: true,
104
+ shouldShowList: true,
105
+ }),
106
+ });
107
+ handlerConfigured = true;
108
+ }
109
+
110
+ if (Platform.OS === "android") {
111
+ await Notifications.setNotificationChannelAsync("default", {
112
+ name: "default",
113
+ importance: Notifications.AndroidImportance.MAX,
114
+ vibrationPattern: [0, 250, 250, 250],
115
+ lightColor: "#FF231F7C",
116
+ });
117
+ }
118
+
119
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
120
+ let finalStatus = existingStatus;
121
+ if (existingStatus !== "granted") {
122
+ const { status } = await Notifications.requestPermissionsAsync();
123
+ finalStatus = status;
124
+ }
125
+ if (finalStatus !== "granted") return null;
126
+
127
+ const push = await Notifications.getExpoPushTokenAsync({ projectId });
128
+ const token = push?.data?.trim();
129
+ if (!token) return null;
130
+
131
+ await options.registerPushToken(deviceId, token);
132
+ return token;
133
+ }