@wasto/cms-components 1.5.11 → 1.5.14
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/index.d.mts +82 -52
- package/dist/index.d.ts +82 -52
- package/dist/index.js +1149 -901
- package/dist/index.mjs +490 -239
- package/package.json +18 -3
- package/src/components/header/Header.tsx +68 -22
- package/src/components/locations-map/LocationsMapShell.tsx +24 -5
- package/src/components/page/Page.tsx +13 -3
- package/src/components/push/PushTokenRegistrar.tsx +77 -0
- package/src/contexts/HeaderConfigContext.tsx +29 -0
- package/src/contexts/LocalContext.tsx +32 -5
- package/src/contexts/WastoContext.tsx +2 -0
- package/src/helpers/nextCollection.ts +6 -3
- package/src/hooks/useCollectionCalendar.ts +8 -9
- package/src/index.ts +12 -0
- package/src/push/registerExpoPushToken.ts +133 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wasto/cms-components",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.14",
|
|
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": "*",
|
|
@@ -42,21 +44,31 @@
|
|
|
42
44
|
"react-native-gesture-handler": "*",
|
|
43
45
|
"react-native-markdown-display": "*",
|
|
44
46
|
"react-native-reanimated": "*",
|
|
45
|
-
"react-native-safe-area-context": "*"
|
|
47
|
+
"react-native-safe-area-context": "*",
|
|
48
|
+
"@react-navigation/elements": "*"
|
|
46
49
|
},
|
|
47
50
|
"peerDependenciesMeta": {
|
|
48
51
|
"@maplibre/maplibre-react-native": {
|
|
49
52
|
"optional": true
|
|
50
53
|
},
|
|
54
|
+
"@react-navigation/elements": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
51
57
|
"@sentry/react-native": {
|
|
52
58
|
"optional": true
|
|
53
59
|
},
|
|
60
|
+
"expo-constants": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
54
63
|
"expo-image-picker": {
|
|
55
64
|
"optional": true
|
|
56
65
|
},
|
|
57
66
|
"expo-linear-gradient": {
|
|
58
67
|
"optional": true
|
|
59
68
|
},
|
|
69
|
+
"expo-notifications": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
60
72
|
"expo-router": {
|
|
61
73
|
"optional": true
|
|
62
74
|
},
|
|
@@ -67,17 +79,20 @@
|
|
|
67
79
|
"devDependencies": {
|
|
68
80
|
"@maplibre/maplibre-react-native": "11.3.6",
|
|
69
81
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
|
82
|
+
"@react-navigation/elements": "^2.9.10",
|
|
70
83
|
"@sentry/react-native": "~6.22.0",
|
|
71
84
|
"@types/react": "~19.2.2",
|
|
85
|
+
"expo-constants": "~18.0.9",
|
|
72
86
|
"expo-haptics": "~15.0.7",
|
|
73
87
|
"expo-linear-gradient": "~15.0.8",
|
|
88
|
+
"expo-notifications": "~0.32.17",
|
|
74
89
|
"maplibre-gl": "^4.7.0",
|
|
75
90
|
"react-native-gesture-handler": "~2.28.0",
|
|
76
91
|
"react-native-markdown-display": "^7.0.2",
|
|
77
92
|
"react-native-reanimated": "~4.1.1",
|
|
78
93
|
"tsup": "^8.5.1",
|
|
79
94
|
"typescript": "^6.0.2",
|
|
80
|
-
"@wasto/sdk": "1.0.
|
|
95
|
+
"@wasto/sdk": "1.0.5"
|
|
81
96
|
},
|
|
82
97
|
"keywords": [],
|
|
83
98
|
"author": "",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "react-native";
|
|
9
9
|
import { useTheme } from "../../contexts/ThemeContext";
|
|
10
10
|
import { useWasto } from "../../contexts/WastoContext";
|
|
11
|
+
import { useOptionalAppData } from "../../contexts/AppDataContext";
|
|
11
12
|
import { useSafeAreaPadding } from "../../helpers/useSafeAreaPadding";
|
|
12
13
|
import { Title } from "../title/Title";
|
|
13
14
|
import { Theme } from "../../types/Theme";
|
|
@@ -58,6 +59,26 @@ export type AppHeaderConfig = {
|
|
|
58
59
|
elements: AppHeaderElement[];
|
|
59
60
|
};
|
|
60
61
|
|
|
62
|
+
/** Actions standard (notifications + profil) pour navbar sans titre. */
|
|
63
|
+
export const DEFAULT_HEADER_ACTIONS: readonly AppHeaderActionElement[] = [
|
|
64
|
+
{
|
|
65
|
+
id: "notifications",
|
|
66
|
+
type: "action",
|
|
67
|
+
slot: "right",
|
|
68
|
+
icon: "bell",
|
|
69
|
+
action: "navigate",
|
|
70
|
+
href: SYSTEM_ROUTES.notifications,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "profile",
|
|
74
|
+
type: "action",
|
|
75
|
+
slot: "right",
|
|
76
|
+
icon: "person",
|
|
77
|
+
action: "navigate",
|
|
78
|
+
href: SYSTEM_ROUTES.profile,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
61
82
|
export type HeaderProps = {
|
|
62
83
|
readonly options: { title?: string; headerBackVisible?: boolean };
|
|
63
84
|
readonly route: { name: string };
|
|
@@ -67,7 +88,10 @@ export type HeaderProps = {
|
|
|
67
88
|
};
|
|
68
89
|
/** Flotte au-dessus du contenu (ex. carte) : fond transparent. */
|
|
69
90
|
readonly overlay?: boolean;
|
|
70
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Config layout (`header.elements`) — logo / actions.
|
|
93
|
+
* Si fournie (même vide), le fallback titre d’écran ne s’applique pas.
|
|
94
|
+
*/
|
|
71
95
|
readonly headerConfig?: AppHeaderConfig | null;
|
|
72
96
|
};
|
|
73
97
|
|
|
@@ -75,21 +99,16 @@ function resolveLogoUri(
|
|
|
75
99
|
apiUrl: string,
|
|
76
100
|
el: AppHeaderLogoElement,
|
|
77
101
|
): string | null {
|
|
78
|
-
|
|
102
|
+
const url = (el.url || "").trim();
|
|
103
|
+
if (url) return url;
|
|
104
|
+
// Fallback média (export parfois laisse mediaId si rewrite manqué)
|
|
105
|
+
if (el.sourceType === "media" || el.mediaId) {
|
|
79
106
|
return publicMediaUrl(apiUrl, el.mediaId);
|
|
80
107
|
}
|
|
81
|
-
|
|
82
|
-
return url || null;
|
|
108
|
+
return null;
|
|
83
109
|
}
|
|
84
110
|
|
|
85
|
-
function
|
|
86
|
-
element,
|
|
87
|
-
}: {
|
|
88
|
-
readonly element: AppHeaderLogoElement;
|
|
89
|
-
}): ReactNode {
|
|
90
|
-
const { apiUrl } = useWasto();
|
|
91
|
-
const uri = resolveLogoUri(apiUrl, element);
|
|
92
|
-
if (!uri) return null;
|
|
111
|
+
function HeaderLogoImage({ uri }: { readonly uri: string }): ReactNode {
|
|
93
112
|
return (
|
|
94
113
|
<Image
|
|
95
114
|
source={{ uri }}
|
|
@@ -100,6 +119,29 @@ function HeaderLogo({
|
|
|
100
119
|
);
|
|
101
120
|
}
|
|
102
121
|
|
|
122
|
+
function HeaderLogo({
|
|
123
|
+
element,
|
|
124
|
+
}: {
|
|
125
|
+
readonly element: AppHeaderLogoElement;
|
|
126
|
+
}): ReactNode {
|
|
127
|
+
const { apiUrl } = useWasto();
|
|
128
|
+
const uri = resolveLogoUri(apiUrl, element);
|
|
129
|
+
if (!uri) return null;
|
|
130
|
+
return <HeaderLogoImage uri={uri} />;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function CityLogoFallback(): ReactNode {
|
|
134
|
+
const { apiUrl } = useWasto();
|
|
135
|
+
const appData = useOptionalAppData();
|
|
136
|
+
const logoMediaId =
|
|
137
|
+
typeof appData?.city?.logoMediaId === "string"
|
|
138
|
+
? appData.city.logoMediaId
|
|
139
|
+
: null;
|
|
140
|
+
const uri = publicMediaUrl(apiUrl, logoMediaId);
|
|
141
|
+
if (!uri) return null;
|
|
142
|
+
return <HeaderLogoImage uri={uri} />;
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
function HeaderActionButton({
|
|
104
146
|
onPress,
|
|
105
147
|
accessibilityLabel,
|
|
@@ -143,9 +185,15 @@ export function Header({
|
|
|
143
185
|
[options.title, route.name],
|
|
144
186
|
);
|
|
145
187
|
|
|
146
|
-
|
|
147
|
-
const
|
|
188
|
+
/** Config explicite → navbar (logo/actions), jamais le fallback titre d’écran. */
|
|
189
|
+
const hasExplicitConfig = headerConfig != null;
|
|
190
|
+
const elements = useMemo(() => {
|
|
191
|
+
const raw = headerConfig?.elements ?? [];
|
|
192
|
+
// `type: "title"` est déprécié (navbar uniquement).
|
|
193
|
+
return raw.filter((el) => el.type !== "title");
|
|
194
|
+
}, [headerConfig]);
|
|
148
195
|
const hasCustomActions = elements.some((el) => el.type === "action");
|
|
196
|
+
const hasLogo = elements.some((el) => el.type === "logo");
|
|
149
197
|
|
|
150
198
|
const bySlot = useMemo(() => {
|
|
151
199
|
const left: AppHeaderElement[] = [];
|
|
@@ -174,11 +222,7 @@ export function Header({
|
|
|
174
222
|
return <HeaderLogo key={el.id} element={el} />;
|
|
175
223
|
}
|
|
176
224
|
if (el.type === "title") {
|
|
177
|
-
|
|
178
|
-
el.useScreenTitle !== false
|
|
179
|
-
? screenTitle
|
|
180
|
-
: (el.text || "").trim() || screenTitle;
|
|
181
|
-
return <Title key={el.id} text={text} level="1" />;
|
|
225
|
+
return null;
|
|
182
226
|
}
|
|
183
227
|
// action
|
|
184
228
|
const onPress = async () => {
|
|
@@ -259,7 +303,7 @@ export function Header({
|
|
|
259
303
|
pointerEvents="box-none"
|
|
260
304
|
>
|
|
261
305
|
<View style={styles.header} pointerEvents="box-none">
|
|
262
|
-
{
|
|
306
|
+
{hasExplicitConfig ? (
|
|
263
307
|
<>
|
|
264
308
|
<View style={styles.slotLeft} pointerEvents="box-none">
|
|
265
309
|
{showBack ? (
|
|
@@ -276,6 +320,7 @@ export function Header({
|
|
|
276
320
|
</HeaderActionButton>
|
|
277
321
|
) : null}
|
|
278
322
|
{bySlot.left.map(renderElement)}
|
|
323
|
+
{!hasLogo ? <CityLogoFallback /> : null}
|
|
279
324
|
</View>
|
|
280
325
|
<View
|
|
281
326
|
style={styles.slotCenter}
|
|
@@ -304,6 +349,7 @@ export function Header({
|
|
|
304
349
|
/>
|
|
305
350
|
</HeaderActionButton>
|
|
306
351
|
) : null}
|
|
352
|
+
<CityLogoFallback />
|
|
307
353
|
<Title text={screenTitle} level="1" />
|
|
308
354
|
</View>
|
|
309
355
|
<View style={styles.actions}>{defaultActions}</View>
|
|
@@ -333,9 +379,9 @@ const styles = StyleSheet.create({
|
|
|
333
379
|
alignItems: "center",
|
|
334
380
|
justifyContent: "space-between",
|
|
335
381
|
paddingHorizontal: SPACE.pageX,
|
|
336
|
-
paddingVertical:
|
|
382
|
+
paddingVertical: 6,
|
|
337
383
|
gap: 10,
|
|
338
|
-
minHeight:
|
|
384
|
+
minHeight: 44,
|
|
339
385
|
},
|
|
340
386
|
left: {
|
|
341
387
|
flexDirection: "row",
|
|
@@ -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 (
|
|
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
|
-
|
|
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={{
|
|
256
|
-
route={{ name:
|
|
273
|
+
options={{ headerBackVisible: false }}
|
|
274
|
+
route={{ name: "map" }}
|
|
257
275
|
navigation={NOOP_NAV}
|
|
276
|
+
headerConfig={overlayHeaderConfig}
|
|
258
277
|
/>
|
|
259
278
|
) : null}
|
|
260
279
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
PropsWithChildren,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
2
8
|
import { RefreshControl, ScrollView, StyleSheet, View } from "react-native";
|
|
9
|
+
import { HeaderHeightContext } from "@react-navigation/elements";
|
|
3
10
|
import { useTheme } from "../../contexts/ThemeContext";
|
|
4
11
|
import { useOptionalAppData } from "../../contexts/AppDataContext";
|
|
5
12
|
import { useSafeAreaPadding } from "../../helpers/useSafeAreaPadding";
|
|
@@ -18,7 +25,8 @@ export type PageProps = PropsWithChildren & {
|
|
|
18
25
|
};
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
* Page shell:
|
|
28
|
+
* Page shell: optional vertical scroll.
|
|
29
|
+
* Top safe area only when no navigation header (avoids a double inset band).
|
|
22
30
|
* No global horizontal padding — each block uses ContentInset or is full-bleed
|
|
23
31
|
* (map, calendar grid). Bottom inset is handled by BottomTab when using tabs.
|
|
24
32
|
*/
|
|
@@ -30,7 +38,9 @@ export function Page({
|
|
|
30
38
|
const { theme } = useTheme();
|
|
31
39
|
const t = resolveTheme(theme);
|
|
32
40
|
const themedStyles = createThemedStyles(theme);
|
|
33
|
-
const
|
|
41
|
+
const headerHeight = useContext(HeaderHeightContext);
|
|
42
|
+
const hasNavHeader = typeof headerHeight === "number" && headerHeight > 0;
|
|
43
|
+
const topInset = useSafeAreaPadding(hasNavHeader ? [] : ["top"]);
|
|
34
44
|
const appData = useOptionalAppData();
|
|
35
45
|
const [refreshing, setRefreshing] = useState(false);
|
|
36
46
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { AppState, 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
|
+
* 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).
|
|
12
|
+
* Renders nothing — mount inside LocalProvider.
|
|
13
|
+
*/
|
|
14
|
+
export function PushTokenRegistrar(): ReactNode {
|
|
15
|
+
const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
|
|
16
|
+
const { previewMode, reportError } = useWasto();
|
|
17
|
+
const client = useWastoClient();
|
|
18
|
+
const lastRegistered = useRef<string | null>(null);
|
|
19
|
+
const inFlight = useRef(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (previewMode || Platform.OS === "web" || !deviceId) return;
|
|
23
|
+
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
|
|
26
|
+
const tryRegister = async () => {
|
|
27
|
+
if (cancelled || inFlight.current) return;
|
|
28
|
+
const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
|
|
29
|
+
if (lastRegistered.current === registrationKey) return;
|
|
30
|
+
|
|
31
|
+
inFlight.current = true;
|
|
32
|
+
try {
|
|
33
|
+
const token = await registerExpoPushToken({
|
|
34
|
+
deviceId,
|
|
35
|
+
registerPushToken: (id: string, pushToken: string) =>
|
|
36
|
+
client.public.devices.registerPushToken(id, {
|
|
37
|
+
token: pushToken,
|
|
38
|
+
cityId: selectedCityId,
|
|
39
|
+
wasteCollectionPlaceId: selectedPlaceId,
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
if (!cancelled && token) {
|
|
43
|
+
lastRegistered.current = registrationKey;
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!cancelled) {
|
|
47
|
+
reportError(error, { source: "push", deviceId });
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
inFlight.current = false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
void tryRegister();
|
|
55
|
+
|
|
56
|
+
const sub = AppState.addEventListener("change", (state) => {
|
|
57
|
+
if (state === "active") {
|
|
58
|
+
lastRegistered.current = null;
|
|
59
|
+
void tryRegister();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
sub.remove();
|
|
66
|
+
};
|
|
67
|
+
}, [
|
|
68
|
+
client.public.devices,
|
|
69
|
+
deviceId,
|
|
70
|
+
previewMode,
|
|
71
|
+
reportError,
|
|
72
|
+
selectedCityId,
|
|
73
|
+
selectedPlaceId,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -93,18 +93,22 @@ export function LocalProvider({
|
|
|
93
93
|
|
|
94
94
|
let resolvedDeviceId: string | null = null;
|
|
95
95
|
if (!previewMode) {
|
|
96
|
-
const
|
|
96
|
+
const existingDeviceId = await AsyncStorage.getItem(keys.device);
|
|
97
|
+
const nextDeviceId = existingDeviceId || generateDeviceId();
|
|
97
98
|
await client.public.analytics.track({
|
|
98
|
-
id:
|
|
99
|
+
id: nextDeviceId,
|
|
99
100
|
cityId: cityId,
|
|
101
|
+
wasteCollectionPlaceId: placeId,
|
|
100
102
|
platform: Platform.OS,
|
|
101
103
|
osVersion: null,
|
|
102
104
|
appVersion: null,
|
|
103
105
|
deviceModel: null,
|
|
104
106
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
if (!existingDeviceId) {
|
|
108
|
+
await AsyncStorage.setItem(keys.device, nextDeviceId);
|
|
109
|
+
}
|
|
110
|
+
setDeviceId(nextDeviceId);
|
|
111
|
+
resolvedDeviceId = nextDeviceId;
|
|
108
112
|
} else {
|
|
109
113
|
// Studio preview: never create analytics devices / persist device id.
|
|
110
114
|
setDeviceId(null);
|
|
@@ -190,6 +194,29 @@ export function LocalProvider({
|
|
|
190
194
|
void loadLocalData();
|
|
191
195
|
}, [keys, previewMode]);
|
|
192
196
|
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (previewMode || !deviceId || !selectedCityId) return;
|
|
199
|
+
void client.public.analytics
|
|
200
|
+
.track({
|
|
201
|
+
id: deviceId,
|
|
202
|
+
cityId: selectedCityId,
|
|
203
|
+
wasteCollectionPlaceId: selectedPlaceId,
|
|
204
|
+
platform: Platform.OS,
|
|
205
|
+
osVersion: null,
|
|
206
|
+
appVersion: null,
|
|
207
|
+
deviceModel: null,
|
|
208
|
+
})
|
|
209
|
+
.catch(() => {
|
|
210
|
+
// Heartbeat best-effort: the sector is already stored locally.
|
|
211
|
+
});
|
|
212
|
+
}, [
|
|
213
|
+
client.public.analytics,
|
|
214
|
+
deviceId,
|
|
215
|
+
previewMode,
|
|
216
|
+
selectedCityId,
|
|
217
|
+
selectedPlaceId,
|
|
218
|
+
]);
|
|
219
|
+
|
|
193
220
|
return (
|
|
194
221
|
<LocalContext
|
|
195
222
|
value={{
|
|
@@ -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}>
|
|
@@ -23,7 +23,7 @@ export type WasteCollectionPlace = {
|
|
|
23
23
|
/** Ex: "Lundi", "Mardi", ... (mapping lundi=0 … dimanche=6) */
|
|
24
24
|
name: string;
|
|
25
25
|
wasteTypeId: string;
|
|
26
|
-
|
|
26
|
+
everyWeeks?: number;
|
|
27
27
|
startDate?: string;
|
|
28
28
|
}>;
|
|
29
29
|
/** Périodes d’exception (pas de collecte) */
|
|
@@ -90,13 +90,16 @@ export function getCollectionsForDay(
|
|
|
90
90
|
});
|
|
91
91
|
if (hasException) continue;
|
|
92
92
|
|
|
93
|
-
if (day.
|
|
93
|
+
if (day.startDate) {
|
|
94
94
|
const start = new Date(day.startDate);
|
|
95
|
+
start.setHours(0, 0, 0, 0);
|
|
96
|
+
const everyWeeks = Math.max(1, Number(day.everyWeeks) || 1);
|
|
95
97
|
const diffDays = Math.floor(
|
|
96
98
|
(targetDate.getTime() - start.getTime()) / (1000 * 60 * 60 * 24),
|
|
97
99
|
);
|
|
98
100
|
const weeks = Math.floor(diffDays / 7);
|
|
99
|
-
if (weeks < 0
|
|
101
|
+
if (weeks < 0) continue;
|
|
102
|
+
if (everyWeeks > 1 && weeks % everyWeeks !== 0) continue;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
const wt = wasteTypes.find((w) => w.id === day.wasteTypeId);
|
|
@@ -31,7 +31,7 @@ export type CollectionScheduleEntry = {
|
|
|
31
31
|
dayOfWeek: number; // lundi=0 ... dimanche=6
|
|
32
32
|
wasteTypeId: string;
|
|
33
33
|
color: string;
|
|
34
|
-
|
|
34
|
+
everyWeeks?: number;
|
|
35
35
|
startDate?: string;
|
|
36
36
|
};
|
|
37
37
|
|
|
@@ -255,7 +255,7 @@ function buildSchedule(
|
|
|
255
255
|
const rawDays = (selected.collectionDays || []) as Array<{
|
|
256
256
|
name: string;
|
|
257
257
|
wasteTypeId: string;
|
|
258
|
-
|
|
258
|
+
everyWeeks?: number;
|
|
259
259
|
startDate?: string;
|
|
260
260
|
}>;
|
|
261
261
|
|
|
@@ -268,7 +268,7 @@ function buildSchedule(
|
|
|
268
268
|
dayOfWeek: dayIndex,
|
|
269
269
|
wasteTypeId: day.wasteTypeId,
|
|
270
270
|
color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
|
|
271
|
-
|
|
271
|
+
everyWeeks: Math.max(1, Number(day.everyWeeks) || 1),
|
|
272
272
|
startDate: day.startDate,
|
|
273
273
|
});
|
|
274
274
|
}
|
|
@@ -293,14 +293,13 @@ function computeWasteTypesForDay(
|
|
|
293
293
|
|
|
294
294
|
const wasteTypes: WasteTypeInfoForDay[] = [];
|
|
295
295
|
for (const s of daySchedules) {
|
|
296
|
-
|
|
296
|
+
const everyWeeks = Math.max(1, Number(s.everyWeeks) || 1);
|
|
297
|
+
if (s.startDate) {
|
|
297
298
|
const weeksSinceStart = getWeeksSinceStart(s.startDate, date);
|
|
298
|
-
if (weeksSinceStart
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
} else if (!s.biweekly) {
|
|
302
|
-
wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
|
|
299
|
+
if (weeksSinceStart < 0) continue;
|
|
300
|
+
if (everyWeeks > 1 && weeksSinceStart % everyWeeks !== 0) continue;
|
|
303
301
|
}
|
|
302
|
+
wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
|
|
304
303
|
}
|
|
305
304
|
|
|
306
305
|
// Filtre des exceptions: si l’exception cible un wasteTypeId précis, on ne garde pas ce type pendant la période.
|
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 {
|