@wasto/cms-components 1.5.12 → 1.5.15

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 CHANGED
@@ -185,7 +185,8 @@ type PageProps = PropsWithChildren & {
185
185
  pullToRefresh?: boolean;
186
186
  };
187
187
  /**
188
- * Page shell: top safe area + optional vertical scroll.
188
+ * Page shell: optional vertical scroll.
189
+ * Top safe area only when no navigation header (avoids a double inset band).
189
190
  * No global horizontal padding — each block uses ContentInset or is full-bleed
190
191
  * (map, calendar grid). Bottom inset is handled by BottomTab when using tabs.
191
192
  */
@@ -430,7 +431,7 @@ type WasteCollectionPlace = {
430
431
  /** Ex: "Lundi", "Mardi", ... (mapping lundi=0 … dimanche=6) */
431
432
  name: string;
432
433
  wasteTypeId: string;
433
- biweekly?: boolean;
434
+ everyWeeks?: number;
434
435
  startDate?: string;
435
436
  }>;
436
437
  /** Périodes d’exception (pas de collecte) */
@@ -483,6 +484,8 @@ declare function useOptionalAppData(): AppDataContextType | null;
483
484
  type RegisterExpoPushTokenOptions = {
484
485
  deviceId: string;
485
486
  registerPushToken: (deviceId: string, token: string) => Promise<unknown>;
487
+ /** Called when registration is skipped or fails (not only thrown errors). */
488
+ onSkip?: (reason: string, detail?: Record<string, unknown>) => void;
486
489
  };
487
490
  /**
488
491
  * Request OS permission, fetch Expo push token, register it on the API.
package/dist/index.d.ts CHANGED
@@ -185,7 +185,8 @@ type PageProps = PropsWithChildren & {
185
185
  pullToRefresh?: boolean;
186
186
  };
187
187
  /**
188
- * Page shell: top safe area + optional vertical scroll.
188
+ * Page shell: optional vertical scroll.
189
+ * Top safe area only when no navigation header (avoids a double inset band).
189
190
  * No global horizontal padding — each block uses ContentInset or is full-bleed
190
191
  * (map, calendar grid). Bottom inset is handled by BottomTab when using tabs.
191
192
  */
@@ -430,7 +431,7 @@ type WasteCollectionPlace = {
430
431
  /** Ex: "Lundi", "Mardi", ... (mapping lundi=0 … dimanche=6) */
431
432
  name: string;
432
433
  wasteTypeId: string;
433
- biweekly?: boolean;
434
+ everyWeeks?: number;
434
435
  startDate?: string;
435
436
  }>;
436
437
  /** Périodes d’exception (pas de collecte) */
@@ -483,6 +484,8 @@ declare function useOptionalAppData(): AppDataContextType | null;
483
484
  type RegisterExpoPushTokenOptions = {
484
485
  deviceId: string;
485
486
  registerPushToken: (deviceId: string, token: string) => Promise<unknown>;
487
+ /** Called when registration is skipped or fails (not only thrown errors). */
488
+ onSkip?: (reason: string, detail?: Record<string, unknown>) => void;
486
489
  };
487
490
  /**
488
491
  * Request OS permission, fetch Expo push token, register it on the API.
package/dist/index.js CHANGED
@@ -271,18 +271,22 @@ function LocalProvider({
271
271
  };
272
272
  let resolvedDeviceId = null;
273
273
  if (!previewMode) {
274
- const newDeviceId = generateDeviceId();
274
+ const existingDeviceId = await import_async_storage.default.getItem(keys.device);
275
+ const nextDeviceId = existingDeviceId || generateDeviceId();
275
276
  await client.public.analytics.track({
276
- id: newDeviceId,
277
+ id: nextDeviceId,
277
278
  cityId,
279
+ wasteCollectionPlaceId: placeId,
278
280
  platform: import_react_native.Platform.OS,
279
281
  osVersion: null,
280
282
  appVersion: null,
281
283
  deviceModel: null
282
284
  });
283
- await import_async_storage.default.setItem(keys.device, newDeviceId);
284
- setDeviceId(newDeviceId);
285
- resolvedDeviceId = newDeviceId;
285
+ if (!existingDeviceId) {
286
+ await import_async_storage.default.setItem(keys.device, nextDeviceId);
287
+ }
288
+ setDeviceId(nextDeviceId);
289
+ resolvedDeviceId = nextDeviceId;
286
290
  } else {
287
291
  setDeviceId(null);
288
292
  }
@@ -356,6 +360,25 @@ function LocalProvider({
356
360
  };
357
361
  void loadLocalData();
358
362
  }, [keys, previewMode]);
363
+ (0, import_react3.useEffect)(() => {
364
+ if (previewMode || !deviceId || !selectedCityId) return;
365
+ void client.public.analytics.track({
366
+ id: deviceId,
367
+ cityId: selectedCityId,
368
+ wasteCollectionPlaceId: selectedPlaceId,
369
+ platform: import_react_native.Platform.OS,
370
+ osVersion: null,
371
+ appVersion: null,
372
+ deviceModel: null
373
+ }).catch(() => {
374
+ });
375
+ }, [
376
+ client.public.analytics,
377
+ deviceId,
378
+ previewMode,
379
+ selectedCityId,
380
+ selectedPlaceId
381
+ ]);
359
382
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
360
383
  LocalContext,
361
384
  {
@@ -746,13 +769,16 @@ function getCollectionsForDay(wasteTypes, wasteCollectionPlaces, dateOffset, sel
746
769
  return targetDate.getTime() >= from.getTime() && targetDate.getTime() < to.getTime();
747
770
  });
748
771
  if (hasException) continue;
749
- if (day.biweekly && day.startDate) {
772
+ if (day.startDate) {
750
773
  const start = new Date(day.startDate);
774
+ start.setHours(0, 0, 0, 0);
775
+ const everyWeeks = Math.max(1, Number(day.everyWeeks) || 1);
751
776
  const diffDays = Math.floor(
752
777
  (targetDate.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24)
753
778
  );
754
779
  const weeks = Math.floor(diffDays / 7);
755
- if (weeks < 0 || weeks % 2 !== 0) continue;
780
+ if (weeks < 0) continue;
781
+ if (everyWeeks > 1 && weeks % everyWeeks !== 0) continue;
756
782
  }
757
783
  const wt = wasteTypes.find((w) => w.id === day.wasteTypeId);
758
784
  if (!wt) continue;
@@ -2153,25 +2179,31 @@ function resolveEasProjectId() {
2153
2179
  return projectId || null;
2154
2180
  }
2155
2181
  async function registerExpoPushToken(options) {
2156
- if (import_react_native7.Platform.OS === "web") return null;
2182
+ const skip = (reason, detail) => {
2183
+ options.onSkip?.(reason, detail);
2184
+ if (__DEV__) {
2185
+ console.warn(`[wasto] push registration skipped: ${reason}`, detail ?? "");
2186
+ }
2187
+ };
2188
+ if (import_react_native7.Platform.OS === "web") {
2189
+ skip("web_platform");
2190
+ return null;
2191
+ }
2157
2192
  const deviceId = options.deviceId?.trim();
2158
- if (!deviceId) return null;
2193
+ if (!deviceId) {
2194
+ skip("missing_device_id");
2195
+ return null;
2196
+ }
2159
2197
  const Notifications = loadNotifications();
2160
2198
  if (!Notifications) {
2161
- if (__DEV__) {
2162
- console.warn(
2163
- "[wasto] expo-notifications is not installed; push registration skipped"
2164
- );
2165
- }
2199
+ skip("expo_notifications_missing");
2166
2200
  return null;
2167
2201
  }
2168
2202
  const projectId = resolveEasProjectId();
2169
2203
  if (!projectId) {
2170
- if (__DEV__) {
2171
- console.warn(
2172
- "[wasto] EAS projectId missing (extra.eas.projectId or EXPO_PUBLIC_EAS_PROJECT_ID); push registration skipped"
2173
- );
2174
- }
2204
+ skip("eas_project_id_missing", {
2205
+ hint: "Set EXPO_PUBLIC_EAS_PROJECT_ID or extra.eas.projectId before EAS build"
2206
+ });
2175
2207
  return null;
2176
2208
  }
2177
2209
  if (!handlerConfigured) {
@@ -2200,43 +2232,90 @@ async function registerExpoPushToken(options) {
2200
2232
  const { status } = await Notifications.requestPermissionsAsync();
2201
2233
  finalStatus = status;
2202
2234
  }
2203
- if (finalStatus !== "granted") return null;
2204
- const push = await Notifications.getExpoPushTokenAsync({ projectId });
2205
- const token = push?.data?.trim();
2206
- if (!token) return null;
2207
- await options.registerPushToken(deviceId, token);
2208
- return token;
2235
+ if (finalStatus !== "granted") {
2236
+ skip("permission_denied", { status: finalStatus });
2237
+ return null;
2238
+ }
2239
+ try {
2240
+ const push = await Notifications.getExpoPushTokenAsync({ projectId });
2241
+ const token = push?.data?.trim();
2242
+ if (!token) {
2243
+ skip("empty_expo_push_token", { projectId });
2244
+ return null;
2245
+ }
2246
+ await options.registerPushToken(deviceId, token);
2247
+ return token;
2248
+ } catch (error) {
2249
+ skip("get_expo_push_token_failed", {
2250
+ projectId,
2251
+ message: error instanceof Error ? error.message : String(error)
2252
+ });
2253
+ throw error;
2254
+ }
2209
2255
  }
2210
2256
 
2211
2257
  // src/components/push/PushTokenRegistrar.tsx
2212
2258
  function PushTokenRegistrar() {
2213
- const { deviceId } = useLocal();
2259
+ const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
2214
2260
  const { previewMode, reportError: reportError2 } = useWasto();
2215
2261
  const client = useWastoClient();
2216
2262
  const lastRegistered = (0, import_react8.useRef)(null);
2263
+ const inFlight = (0, import_react8.useRef)(false);
2217
2264
  (0, import_react8.useEffect)(() => {
2218
2265
  if (previewMode || import_react_native8.Platform.OS === "web" || !deviceId) return;
2219
- if (lastRegistered.current === deviceId) return;
2220
2266
  let cancelled = false;
2221
- void (async () => {
2267
+ const tryRegister = async () => {
2268
+ if (cancelled || inFlight.current) return;
2269
+ const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
2270
+ if (lastRegistered.current === registrationKey) return;
2271
+ inFlight.current = true;
2222
2272
  try {
2223
2273
  const token = await registerExpoPushToken({
2224
2274
  deviceId,
2225
- registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, { token: pushToken })
2275
+ registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, {
2276
+ token: pushToken,
2277
+ cityId: selectedCityId,
2278
+ wasteCollectionPlaceId: selectedPlaceId
2279
+ }),
2280
+ onSkip: (reason, detail) => {
2281
+ reportError2(new Error(`push_skip:${reason}`), {
2282
+ source: "push",
2283
+ deviceId,
2284
+ reason,
2285
+ ...detail
2286
+ });
2287
+ }
2226
2288
  });
2227
2289
  if (!cancelled && token) {
2228
- lastRegistered.current = deviceId;
2290
+ lastRegistered.current = registrationKey;
2229
2291
  }
2230
2292
  } catch (error) {
2231
2293
  if (!cancelled) {
2232
2294
  reportError2(error, { source: "push", deviceId });
2233
2295
  }
2296
+ } finally {
2297
+ inFlight.current = false;
2234
2298
  }
2235
- })();
2299
+ };
2300
+ void tryRegister();
2301
+ const sub = import_react_native8.AppState.addEventListener("change", (state) => {
2302
+ if (state === "active") {
2303
+ lastRegistered.current = null;
2304
+ void tryRegister();
2305
+ }
2306
+ });
2236
2307
  return () => {
2237
2308
  cancelled = true;
2309
+ sub.remove();
2238
2310
  };
2239
- }, [client.public.devices, deviceId, previewMode, reportError2]);
2311
+ }, [
2312
+ client.public.devices,
2313
+ deviceId,
2314
+ previewMode,
2315
+ reportError2,
2316
+ selectedCityId,
2317
+ selectedPlaceId
2318
+ ]);
2240
2319
  return null;
2241
2320
  }
2242
2321
 
@@ -6369,7 +6448,7 @@ function buildSchedule(wasteTypes, places, selectedPlaceId) {
6369
6448
  dayOfWeek: dayIndex,
6370
6449
  wasteTypeId: day.wasteTypeId,
6371
6450
  color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
6372
- biweekly: day.biweekly,
6451
+ everyWeeks: Math.max(1, Number(day.everyWeeks) || 1),
6373
6452
  startDate: day.startDate
6374
6453
  });
6375
6454
  }
@@ -6383,14 +6462,13 @@ function computeWasteTypesForDay(day, year, month, schedule, exceptions) {
6383
6462
  const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
6384
6463
  const wasteTypes = [];
6385
6464
  for (const s of daySchedules) {
6386
- if (s.biweekly && s.startDate) {
6465
+ const everyWeeks = Math.max(1, Number(s.everyWeeks) || 1);
6466
+ if (s.startDate) {
6387
6467
  const weeksSinceStart = getWeeksSinceStart(s.startDate, date);
6388
- if (weeksSinceStart >= 0 && weeksSinceStart % 2 === 0) {
6389
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6390
- }
6391
- } else if (!s.biweekly) {
6392
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6468
+ if (weeksSinceStart < 0) continue;
6469
+ if (everyWeeks > 1 && weeksSinceStart % everyWeeks !== 0) continue;
6393
6470
  }
6471
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6394
6472
  }
6395
6473
  const filtered = [];
6396
6474
  for (const wt of wasteTypes) {
@@ -8186,18 +8264,14 @@ var DEFAULT_HEADER_ACTIONS = [
8186
8264
  }
8187
8265
  ];
8188
8266
  function resolveLogoUri(apiUrl, el) {
8189
- if (el.sourceType === "media") {
8267
+ const url = (el.url || "").trim();
8268
+ if (url) return url;
8269
+ if (el.sourceType === "media" || el.mediaId) {
8190
8270
  return publicMediaUrl(apiUrl, el.mediaId);
8191
8271
  }
8192
- const url = (el.url || "").trim();
8193
- return url || null;
8272
+ return null;
8194
8273
  }
8195
- function HeaderLogo({
8196
- element
8197
- }) {
8198
- const { apiUrl } = useWasto();
8199
- const uri = resolveLogoUri(apiUrl, element);
8200
- if (!uri) return null;
8274
+ function HeaderLogoImage({ uri }) {
8201
8275
  return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
8202
8276
  import_react_native34.Image,
8203
8277
  {
@@ -8208,6 +8282,22 @@ function HeaderLogo({
8208
8282
  }
8209
8283
  );
8210
8284
  }
8285
+ function HeaderLogo({
8286
+ element
8287
+ }) {
8288
+ const { apiUrl } = useWasto();
8289
+ const uri = resolveLogoUri(apiUrl, element);
8290
+ if (!uri) return null;
8291
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(HeaderLogoImage, { uri });
8292
+ }
8293
+ function CityLogoFallback() {
8294
+ const { apiUrl } = useWasto();
8295
+ const appData = useOptionalAppData();
8296
+ const logoMediaId = typeof appData?.city?.logoMediaId === "string" ? appData.city.logoMediaId : null;
8297
+ const uri = publicMediaUrl(apiUrl, logoMediaId);
8298
+ if (!uri) return null;
8299
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(HeaderLogoImage, { uri });
8300
+ }
8211
8301
  function HeaderActionButton({
8212
8302
  onPress,
8213
8303
  accessibilityLabel,
@@ -8248,6 +8338,7 @@ function Header({
8248
8338
  return raw.filter((el) => el.type !== "title");
8249
8339
  }, [headerConfig]);
8250
8340
  const hasCustomActions = elements.some((el) => el.type === "action");
8341
+ const hasLogo = elements.some((el) => el.type === "logo");
8251
8342
  const bySlot = (0, import_react35.useMemo)(() => {
8252
8343
  const left = [];
8253
8344
  const center = [];
@@ -8369,7 +8460,8 @@ function Header({
8369
8460
  )
8370
8461
  }
8371
8462
  ) : null,
8372
- bySlot.left.map(renderElement)
8463
+ bySlot.left.map(renderElement),
8464
+ !hasLogo ? /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(CityLogoFallback, {}) : null
8373
8465
  ] }),
8374
8466
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
8375
8467
  import_react_native34.View,
@@ -8401,6 +8493,7 @@ function Header({
8401
8493
  )
8402
8494
  }
8403
8495
  ) : null,
8496
+ /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(CityLogoFallback, {}),
8404
8497
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(Title, { text: screenTitle, level: "1" })
8405
8498
  ] }),
8406
8499
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(import_react_native34.View, { style: styles29.actions, children: defaultActions })
@@ -8425,9 +8518,9 @@ var styles29 = import_react_native34.StyleSheet.create({
8425
8518
  alignItems: "center",
8426
8519
  justifyContent: "space-between",
8427
8520
  paddingHorizontal: SPACE.pageX,
8428
- paddingVertical: 12,
8521
+ paddingVertical: 6,
8429
8522
  gap: 10,
8430
- minHeight: 56
8523
+ minHeight: 44
8431
8524
  },
8432
8525
  left: {
8433
8526
  flexDirection: "row",
@@ -8952,6 +9045,7 @@ function LocationsMap(props) {
8952
9045
  // src/components/page/Page.tsx
8953
9046
  var import_react38 = require("react");
8954
9047
  var import_react_native36 = require("react-native");
9048
+ var import_elements = require("@react-navigation/elements");
8955
9049
  var import_jsx_runtime45 = require("react/jsx-runtime");
8956
9050
  function Page({
8957
9051
  children,
@@ -8961,7 +9055,9 @@ function Page({
8961
9055
  const { theme } = useTheme();
8962
9056
  const t = resolveTheme(theme);
8963
9057
  const themedStyles = createThemedStyles16(theme);
8964
- const topInset = useSafeAreaPadding(["top"]);
9058
+ const headerHeight = (0, import_react38.useContext)(import_elements.HeaderHeightContext);
9059
+ const hasNavHeader = typeof headerHeight === "number" && headerHeight > 0;
9060
+ const topInset = useSafeAreaPadding(hasNavHeader ? [] : ["top"]);
8965
9061
  const appData = useOptionalAppData();
8966
9062
  const [refreshing, setRefreshing] = (0, import_react38.useState)(false);
8967
9063
  const enableRefresh = pullToRefresh ?? scroll;
package/dist/index.mjs CHANGED
@@ -212,18 +212,22 @@ function LocalProvider({
212
212
  };
213
213
  let resolvedDeviceId = null;
214
214
  if (!previewMode) {
215
- const newDeviceId = generateDeviceId();
215
+ const existingDeviceId = await AsyncStorage.getItem(keys.device);
216
+ const nextDeviceId = existingDeviceId || generateDeviceId();
216
217
  await client.public.analytics.track({
217
- id: newDeviceId,
218
+ id: nextDeviceId,
218
219
  cityId,
220
+ wasteCollectionPlaceId: placeId,
219
221
  platform: Platform.OS,
220
222
  osVersion: null,
221
223
  appVersion: null,
222
224
  deviceModel: null
223
225
  });
224
- await AsyncStorage.setItem(keys.device, newDeviceId);
225
- setDeviceId(newDeviceId);
226
- resolvedDeviceId = newDeviceId;
226
+ if (!existingDeviceId) {
227
+ await AsyncStorage.setItem(keys.device, nextDeviceId);
228
+ }
229
+ setDeviceId(nextDeviceId);
230
+ resolvedDeviceId = nextDeviceId;
227
231
  } else {
228
232
  setDeviceId(null);
229
233
  }
@@ -297,6 +301,25 @@ function LocalProvider({
297
301
  };
298
302
  void loadLocalData();
299
303
  }, [keys, previewMode]);
304
+ useEffect(() => {
305
+ if (previewMode || !deviceId || !selectedCityId) return;
306
+ void client.public.analytics.track({
307
+ id: deviceId,
308
+ cityId: selectedCityId,
309
+ wasteCollectionPlaceId: selectedPlaceId,
310
+ platform: Platform.OS,
311
+ osVersion: null,
312
+ appVersion: null,
313
+ deviceModel: null
314
+ }).catch(() => {
315
+ });
316
+ }, [
317
+ client.public.analytics,
318
+ deviceId,
319
+ previewMode,
320
+ selectedCityId,
321
+ selectedPlaceId
322
+ ]);
300
323
  return /* @__PURE__ */ jsx2(
301
324
  LocalContext,
302
325
  {
@@ -705,13 +728,16 @@ function getCollectionsForDay(wasteTypes, wasteCollectionPlaces, dateOffset, sel
705
728
  return targetDate.getTime() >= from.getTime() && targetDate.getTime() < to.getTime();
706
729
  });
707
730
  if (hasException) continue;
708
- if (day.biweekly && day.startDate) {
731
+ if (day.startDate) {
709
732
  const start = new Date(day.startDate);
733
+ start.setHours(0, 0, 0, 0);
734
+ const everyWeeks = Math.max(1, Number(day.everyWeeks) || 1);
710
735
  const diffDays = Math.floor(
711
736
  (targetDate.getTime() - start.getTime()) / (1e3 * 60 * 60 * 24)
712
737
  );
713
738
  const weeks = Math.floor(diffDays / 7);
714
- if (weeks < 0 || weeks % 2 !== 0) continue;
739
+ if (weeks < 0) continue;
740
+ if (everyWeeks > 1 && weeks % everyWeeks !== 0) continue;
715
741
  }
716
742
  const wt = wasteTypes.find((w) => w.id === day.wasteTypeId);
717
743
  if (!wt) continue;
@@ -2108,7 +2134,7 @@ var styles4 = StyleSheet4.create({
2108
2134
 
2109
2135
  // src/components/push/PushTokenRegistrar.tsx
2110
2136
  import { useEffect as useEffect6, useRef as useRef3 } from "react";
2111
- import { Platform as Platform5 } from "react-native";
2137
+ import { AppState, Platform as Platform5 } from "react-native";
2112
2138
 
2113
2139
  // src/push/registerExpoPushToken.ts
2114
2140
  import { Platform as Platform4 } from "react-native";
@@ -2143,25 +2169,31 @@ function resolveEasProjectId() {
2143
2169
  return projectId || null;
2144
2170
  }
2145
2171
  async function registerExpoPushToken(options) {
2146
- if (Platform4.OS === "web") return null;
2172
+ const skip = (reason, detail) => {
2173
+ options.onSkip?.(reason, detail);
2174
+ if (__DEV__) {
2175
+ console.warn(`[wasto] push registration skipped: ${reason}`, detail ?? "");
2176
+ }
2177
+ };
2178
+ if (Platform4.OS === "web") {
2179
+ skip("web_platform");
2180
+ return null;
2181
+ }
2147
2182
  const deviceId = options.deviceId?.trim();
2148
- if (!deviceId) return null;
2183
+ if (!deviceId) {
2184
+ skip("missing_device_id");
2185
+ return null;
2186
+ }
2149
2187
  const Notifications = loadNotifications();
2150
2188
  if (!Notifications) {
2151
- if (__DEV__) {
2152
- console.warn(
2153
- "[wasto] expo-notifications is not installed; push registration skipped"
2154
- );
2155
- }
2189
+ skip("expo_notifications_missing");
2156
2190
  return null;
2157
2191
  }
2158
2192
  const projectId = resolveEasProjectId();
2159
2193
  if (!projectId) {
2160
- if (__DEV__) {
2161
- console.warn(
2162
- "[wasto] EAS projectId missing (extra.eas.projectId or EXPO_PUBLIC_EAS_PROJECT_ID); push registration skipped"
2163
- );
2164
- }
2194
+ skip("eas_project_id_missing", {
2195
+ hint: "Set EXPO_PUBLIC_EAS_PROJECT_ID or extra.eas.projectId before EAS build"
2196
+ });
2165
2197
  return null;
2166
2198
  }
2167
2199
  if (!handlerConfigured) {
@@ -2190,43 +2222,90 @@ async function registerExpoPushToken(options) {
2190
2222
  const { status } = await Notifications.requestPermissionsAsync();
2191
2223
  finalStatus = status;
2192
2224
  }
2193
- if (finalStatus !== "granted") return null;
2194
- const push = await Notifications.getExpoPushTokenAsync({ projectId });
2195
- const token = push?.data?.trim();
2196
- if (!token) return null;
2197
- await options.registerPushToken(deviceId, token);
2198
- return token;
2225
+ if (finalStatus !== "granted") {
2226
+ skip("permission_denied", { status: finalStatus });
2227
+ return null;
2228
+ }
2229
+ try {
2230
+ const push = await Notifications.getExpoPushTokenAsync({ projectId });
2231
+ const token = push?.data?.trim();
2232
+ if (!token) {
2233
+ skip("empty_expo_push_token", { projectId });
2234
+ return null;
2235
+ }
2236
+ await options.registerPushToken(deviceId, token);
2237
+ return token;
2238
+ } catch (error) {
2239
+ skip("get_expo_push_token_failed", {
2240
+ projectId,
2241
+ message: error instanceof Error ? error.message : String(error)
2242
+ });
2243
+ throw error;
2244
+ }
2199
2245
  }
2200
2246
 
2201
2247
  // src/components/push/PushTokenRegistrar.tsx
2202
2248
  function PushTokenRegistrar() {
2203
- const { deviceId } = useLocal();
2249
+ const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
2204
2250
  const { previewMode, reportError: reportError2 } = useWasto();
2205
2251
  const client = useWastoClient();
2206
2252
  const lastRegistered = useRef3(null);
2253
+ const inFlight = useRef3(false);
2207
2254
  useEffect6(() => {
2208
2255
  if (previewMode || Platform5.OS === "web" || !deviceId) return;
2209
- if (lastRegistered.current === deviceId) return;
2210
2256
  let cancelled = false;
2211
- void (async () => {
2257
+ const tryRegister = async () => {
2258
+ if (cancelled || inFlight.current) return;
2259
+ const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
2260
+ if (lastRegistered.current === registrationKey) return;
2261
+ inFlight.current = true;
2212
2262
  try {
2213
2263
  const token = await registerExpoPushToken({
2214
2264
  deviceId,
2215
- registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, { token: pushToken })
2265
+ registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, {
2266
+ token: pushToken,
2267
+ cityId: selectedCityId,
2268
+ wasteCollectionPlaceId: selectedPlaceId
2269
+ }),
2270
+ onSkip: (reason, detail) => {
2271
+ reportError2(new Error(`push_skip:${reason}`), {
2272
+ source: "push",
2273
+ deviceId,
2274
+ reason,
2275
+ ...detail
2276
+ });
2277
+ }
2216
2278
  });
2217
2279
  if (!cancelled && token) {
2218
- lastRegistered.current = deviceId;
2280
+ lastRegistered.current = registrationKey;
2219
2281
  }
2220
2282
  } catch (error) {
2221
2283
  if (!cancelled) {
2222
2284
  reportError2(error, { source: "push", deviceId });
2223
2285
  }
2286
+ } finally {
2287
+ inFlight.current = false;
2224
2288
  }
2225
- })();
2289
+ };
2290
+ void tryRegister();
2291
+ const sub = AppState.addEventListener("change", (state) => {
2292
+ if (state === "active") {
2293
+ lastRegistered.current = null;
2294
+ void tryRegister();
2295
+ }
2296
+ });
2226
2297
  return () => {
2227
2298
  cancelled = true;
2299
+ sub.remove();
2228
2300
  };
2229
- }, [client.public.devices, deviceId, previewMode, reportError2]);
2301
+ }, [
2302
+ client.public.devices,
2303
+ deviceId,
2304
+ previewMode,
2305
+ reportError2,
2306
+ selectedCityId,
2307
+ selectedPlaceId
2308
+ ]);
2230
2309
  return null;
2231
2310
  }
2232
2311
 
@@ -6387,7 +6466,7 @@ function buildSchedule(wasteTypes, places, selectedPlaceId) {
6387
6466
  dayOfWeek: dayIndex,
6388
6467
  wasteTypeId: day.wasteTypeId,
6389
6468
  color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
6390
- biweekly: day.biweekly,
6469
+ everyWeeks: Math.max(1, Number(day.everyWeeks) || 1),
6391
6470
  startDate: day.startDate
6392
6471
  });
6393
6472
  }
@@ -6401,14 +6480,13 @@ function computeWasteTypesForDay(day, year, month, schedule, exceptions) {
6401
6480
  const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
6402
6481
  const wasteTypes = [];
6403
6482
  for (const s of daySchedules) {
6404
- if (s.biweekly && s.startDate) {
6483
+ const everyWeeks = Math.max(1, Number(s.everyWeeks) || 1);
6484
+ if (s.startDate) {
6405
6485
  const weeksSinceStart = getWeeksSinceStart(s.startDate, date);
6406
- if (weeksSinceStart >= 0 && weeksSinceStart % 2 === 0) {
6407
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6408
- }
6409
- } else if (!s.biweekly) {
6410
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6486
+ if (weeksSinceStart < 0) continue;
6487
+ if (everyWeeks > 1 && weeksSinceStart % everyWeeks !== 0) continue;
6411
6488
  }
6489
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6412
6490
  }
6413
6491
  const filtered = [];
6414
6492
  for (const wt of wasteTypes) {
@@ -8264,18 +8342,14 @@ var DEFAULT_HEADER_ACTIONS = [
8264
8342
  }
8265
8343
  ];
8266
8344
  function resolveLogoUri(apiUrl, el) {
8267
- if (el.sourceType === "media") {
8345
+ const url = (el.url || "").trim();
8346
+ if (url) return url;
8347
+ if (el.sourceType === "media" || el.mediaId) {
8268
8348
  return publicMediaUrl(apiUrl, el.mediaId);
8269
8349
  }
8270
- const url = (el.url || "").trim();
8271
- return url || null;
8350
+ return null;
8272
8351
  }
8273
- function HeaderLogo({
8274
- element
8275
- }) {
8276
- const { apiUrl } = useWasto();
8277
- const uri = resolveLogoUri(apiUrl, element);
8278
- if (!uri) return null;
8352
+ function HeaderLogoImage({ uri }) {
8279
8353
  return /* @__PURE__ */ jsx41(
8280
8354
  Image11,
8281
8355
  {
@@ -8286,6 +8360,22 @@ function HeaderLogo({
8286
8360
  }
8287
8361
  );
8288
8362
  }
8363
+ function HeaderLogo({
8364
+ element
8365
+ }) {
8366
+ const { apiUrl } = useWasto();
8367
+ const uri = resolveLogoUri(apiUrl, element);
8368
+ if (!uri) return null;
8369
+ return /* @__PURE__ */ jsx41(HeaderLogoImage, { uri });
8370
+ }
8371
+ function CityLogoFallback() {
8372
+ const { apiUrl } = useWasto();
8373
+ const appData = useOptionalAppData();
8374
+ const logoMediaId = typeof appData?.city?.logoMediaId === "string" ? appData.city.logoMediaId : null;
8375
+ const uri = publicMediaUrl(apiUrl, logoMediaId);
8376
+ if (!uri) return null;
8377
+ return /* @__PURE__ */ jsx41(HeaderLogoImage, { uri });
8378
+ }
8289
8379
  function HeaderActionButton({
8290
8380
  onPress,
8291
8381
  accessibilityLabel,
@@ -8326,6 +8416,7 @@ function Header({
8326
8416
  return raw.filter((el) => el.type !== "title");
8327
8417
  }, [headerConfig]);
8328
8418
  const hasCustomActions = elements.some((el) => el.type === "action");
8419
+ const hasLogo = elements.some((el) => el.type === "logo");
8329
8420
  const bySlot = useMemo20(() => {
8330
8421
  const left = [];
8331
8422
  const center = [];
@@ -8447,7 +8538,8 @@ function Header({
8447
8538
  )
8448
8539
  }
8449
8540
  ) : null,
8450
- bySlot.left.map(renderElement)
8541
+ bySlot.left.map(renderElement),
8542
+ !hasLogo ? /* @__PURE__ */ jsx41(CityLogoFallback, {}) : null
8451
8543
  ] }),
8452
8544
  /* @__PURE__ */ jsx41(
8453
8545
  View29,
@@ -8479,6 +8571,7 @@ function Header({
8479
8571
  )
8480
8572
  }
8481
8573
  ) : null,
8574
+ /* @__PURE__ */ jsx41(CityLogoFallback, {}),
8482
8575
  /* @__PURE__ */ jsx41(Title, { text: screenTitle, level: "1" })
8483
8576
  ] }),
8484
8577
  /* @__PURE__ */ jsx41(View29, { style: styles29.actions, children: defaultActions })
@@ -8503,9 +8596,9 @@ var styles29 = StyleSheet30.create({
8503
8596
  alignItems: "center",
8504
8597
  justifyContent: "space-between",
8505
8598
  paddingHorizontal: SPACE.pageX,
8506
- paddingVertical: 12,
8599
+ paddingVertical: 6,
8507
8600
  gap: 10,
8508
- minHeight: 56
8601
+ minHeight: 44
8509
8602
  },
8510
8603
  left: {
8511
8604
  flexDirection: "row",
@@ -9031,8 +9124,13 @@ function LocationsMap(props) {
9031
9124
  }
9032
9125
 
9033
9126
  // src/components/page/Page.tsx
9034
- import { useCallback as useCallback13, useState as useState26 } from "react";
9127
+ import {
9128
+ useCallback as useCallback13,
9129
+ useContext as useContext10,
9130
+ useState as useState26
9131
+ } from "react";
9035
9132
  import { RefreshControl, ScrollView as ScrollView11, StyleSheet as StyleSheet32, View as View31 } from "react-native";
9133
+ import { HeaderHeightContext } from "@react-navigation/elements";
9036
9134
  import { jsx as jsx45 } from "react/jsx-runtime";
9037
9135
  function Page({
9038
9136
  children,
@@ -9042,7 +9140,9 @@ function Page({
9042
9140
  const { theme } = useTheme();
9043
9141
  const t = resolveTheme(theme);
9044
9142
  const themedStyles = createThemedStyles16(theme);
9045
- const topInset = useSafeAreaPadding(["top"]);
9143
+ const headerHeight = useContext10(HeaderHeightContext);
9144
+ const hasNavHeader = typeof headerHeight === "number" && headerHeight > 0;
9145
+ const topInset = useSafeAreaPadding(hasNavHeader ? [] : ["top"]);
9046
9146
  const appData = useOptionalAppData();
9047
9147
  const [refreshing, setRefreshing] = useState26(false);
9048
9148
  const enableRefresh = pullToRefresh ?? scroll;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wasto/cms-components",
3
- "version": "1.5.12",
3
+ "version": "1.5.15",
4
4
  "description": "Wast'O CMS React Native components",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -44,12 +44,16 @@
44
44
  "react-native-gesture-handler": "*",
45
45
  "react-native-markdown-display": "*",
46
46
  "react-native-reanimated": "*",
47
- "react-native-safe-area-context": "*"
47
+ "react-native-safe-area-context": "*",
48
+ "@react-navigation/elements": "*"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
51
  "@maplibre/maplibre-react-native": {
51
52
  "optional": true
52
53
  },
54
+ "@react-navigation/elements": {
55
+ "optional": true
56
+ },
53
57
  "@sentry/react-native": {
54
58
  "optional": true
55
59
  },
@@ -75,6 +79,7 @@
75
79
  "devDependencies": {
76
80
  "@maplibre/maplibre-react-native": "11.3.6",
77
81
  "@react-navigation/bottom-tabs": "^7.4.0",
82
+ "@react-navigation/elements": "^2.9.10",
78
83
  "@sentry/react-native": "~6.22.0",
79
84
  "@types/react": "~19.2.2",
80
85
  "expo-constants": "~18.0.9",
@@ -87,7 +92,7 @@
87
92
  "react-native-reanimated": "~4.1.1",
88
93
  "tsup": "^8.5.1",
89
94
  "typescript": "^6.0.2",
90
- "@wasto/sdk": "1.0.4"
95
+ "@wasto/sdk": "1.0.5"
91
96
  },
92
97
  "keywords": [],
93
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";
@@ -98,21 +99,16 @@ function resolveLogoUri(
98
99
  apiUrl: string,
99
100
  el: AppHeaderLogoElement,
100
101
  ): string | null {
101
- if (el.sourceType === "media") {
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) {
102
106
  return publicMediaUrl(apiUrl, el.mediaId);
103
107
  }
104
- const url = (el.url || "").trim();
105
- return url || null;
108
+ return null;
106
109
  }
107
110
 
108
- function HeaderLogo({
109
- element,
110
- }: {
111
- readonly element: AppHeaderLogoElement;
112
- }): ReactNode {
113
- const { apiUrl } = useWasto();
114
- const uri = resolveLogoUri(apiUrl, element);
115
- if (!uri) return null;
111
+ function HeaderLogoImage({ uri }: { readonly uri: string }): ReactNode {
116
112
  return (
117
113
  <Image
118
114
  source={{ uri }}
@@ -123,6 +119,29 @@ function HeaderLogo({
123
119
  );
124
120
  }
125
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
+
126
145
  function HeaderActionButton({
127
146
  onPress,
128
147
  accessibilityLabel,
@@ -174,6 +193,7 @@ export function Header({
174
193
  return raw.filter((el) => el.type !== "title");
175
194
  }, [headerConfig]);
176
195
  const hasCustomActions = elements.some((el) => el.type === "action");
196
+ const hasLogo = elements.some((el) => el.type === "logo");
177
197
 
178
198
  const bySlot = useMemo(() => {
179
199
  const left: AppHeaderElement[] = [];
@@ -300,6 +320,7 @@ export function Header({
300
320
  </HeaderActionButton>
301
321
  ) : null}
302
322
  {bySlot.left.map(renderElement)}
323
+ {!hasLogo ? <CityLogoFallback /> : null}
303
324
  </View>
304
325
  <View
305
326
  style={styles.slotCenter}
@@ -328,6 +349,7 @@ export function Header({
328
349
  />
329
350
  </HeaderActionButton>
330
351
  ) : null}
352
+ <CityLogoFallback />
331
353
  <Title text={screenTitle} level="1" />
332
354
  </View>
333
355
  <View style={styles.actions}>{defaultActions}</View>
@@ -357,9 +379,9 @@ const styles = StyleSheet.create({
357
379
  alignItems: "center",
358
380
  justifyContent: "space-between",
359
381
  paddingHorizontal: SPACE.pageX,
360
- paddingVertical: 12,
382
+ paddingVertical: 6,
361
383
  gap: 10,
362
- minHeight: 56,
384
+ minHeight: 44,
363
385
  },
364
386
  left: {
365
387
  flexDirection: "row",
@@ -1,5 +1,12 @@
1
- import { PropsWithChildren, useCallback, useState, type ReactNode } from "react";
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: top safe area + optional vertical scroll.
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 topInset = useSafeAreaPadding(["top"]);
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
 
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode } from "react";
2
- import { Platform } from "react-native";
2
+ import { AppState, Platform } from "react-native";
3
3
  import { useLocal } from "../../contexts/LocalContext";
4
4
  import { useWasto } from "../../contexts/WastoContext";
5
5
  import { useWastoClient } from "../../hooks/useWastoClient";
@@ -7,41 +7,79 @@ import { registerExpoPushToken } from "../../push/registerExpoPushToken";
7
7
 
8
8
  /**
9
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).
10
12
  * Renders nothing — mount inside LocalProvider.
11
13
  */
12
14
  export function PushTokenRegistrar(): ReactNode {
13
- const { deviceId } = useLocal();
15
+ const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
14
16
  const { previewMode, reportError } = useWasto();
15
17
  const client = useWastoClient();
16
18
  const lastRegistered = useRef<string | null>(null);
19
+ const inFlight = useRef(false);
17
20
 
18
21
  useEffect(() => {
19
22
  if (previewMode || Platform.OS === "web" || !deviceId) return;
20
- if (lastRegistered.current === deviceId) return;
21
23
 
22
24
  let cancelled = false;
23
25
 
24
- void (async () => {
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;
25
32
  try {
26
33
  const token = await registerExpoPushToken({
27
34
  deviceId,
28
35
  registerPushToken: (id: string, pushToken: string) =>
29
- client.public.devices.registerPushToken(id, { token: pushToken }),
36
+ client.public.devices.registerPushToken(id, {
37
+ token: pushToken,
38
+ cityId: selectedCityId,
39
+ wasteCollectionPlaceId: selectedPlaceId,
40
+ }),
41
+ onSkip: (reason, detail) => {
42
+ reportError(new Error(`push_skip:${reason}`), {
43
+ source: "push",
44
+ deviceId,
45
+ reason,
46
+ ...detail,
47
+ });
48
+ },
30
49
  });
31
50
  if (!cancelled && token) {
32
- lastRegistered.current = deviceId;
51
+ lastRegistered.current = registrationKey;
33
52
  }
34
53
  } catch (error) {
35
54
  if (!cancelled) {
36
55
  reportError(error, { source: "push", deviceId });
37
56
  }
57
+ } finally {
58
+ inFlight.current = false;
59
+ }
60
+ };
61
+
62
+ void tryRegister();
63
+
64
+ const sub = AppState.addEventListener("change", (state) => {
65
+ if (state === "active") {
66
+ lastRegistered.current = null;
67
+ void tryRegister();
38
68
  }
39
- })();
69
+ });
40
70
 
41
71
  return () => {
42
72
  cancelled = true;
73
+ sub.remove();
43
74
  };
44
- }, [client.public.devices, deviceId, previewMode, reportError]);
75
+ }, [
76
+ client.public.devices,
77
+ deviceId,
78
+ previewMode,
79
+ reportError,
80
+ selectedCityId,
81
+ selectedPlaceId,
82
+ ]);
45
83
 
46
84
  return null;
47
85
  }
@@ -93,18 +93,22 @@ export function LocalProvider({
93
93
 
94
94
  let resolvedDeviceId: string | null = null;
95
95
  if (!previewMode) {
96
- const newDeviceId = generateDeviceId();
96
+ const existingDeviceId = await AsyncStorage.getItem(keys.device);
97
+ const nextDeviceId = existingDeviceId || generateDeviceId();
97
98
  await client.public.analytics.track({
98
- id: newDeviceId,
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
- await AsyncStorage.setItem(keys.device, newDeviceId);
106
- setDeviceId(newDeviceId);
107
- resolvedDeviceId = newDeviceId;
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={{
@@ -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
- biweekly?: boolean;
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.biweekly && day.startDate) {
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 || weeks % 2 !== 0) continue;
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
- biweekly?: boolean;
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
- biweekly?: boolean;
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
- biweekly: day.biweekly,
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
- if (s.biweekly && s.startDate) {
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 >= 0 && weeksSinceStart % 2 === 0) {
299
- wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
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.
@@ -60,6 +60,8 @@ function resolveEasProjectId(): string | null {
60
60
  export type RegisterExpoPushTokenOptions = {
61
61
  deviceId: string;
62
62
  registerPushToken: (deviceId: string, token: string) => Promise<unknown>;
63
+ /** Called when registration is skipped or fails (not only thrown errors). */
64
+ onSkip?: (reason: string, detail?: Record<string, unknown>) => void;
63
65
  };
64
66
 
65
67
  /**
@@ -69,28 +71,35 @@ export type RegisterExpoPushTokenOptions = {
69
71
  export async function registerExpoPushToken(
70
72
  options: RegisterExpoPushTokenOptions,
71
73
  ): Promise<string | null> {
72
- if (Platform.OS === "web") return null;
74
+ const skip = (reason: string, detail?: Record<string, unknown>) => {
75
+ options.onSkip?.(reason, detail);
76
+ if (__DEV__) {
77
+ console.warn(`[wasto] push registration skipped: ${reason}`, detail ?? "");
78
+ }
79
+ };
80
+
81
+ if (Platform.OS === "web") {
82
+ skip("web_platform");
83
+ return null;
84
+ }
73
85
 
74
86
  const deviceId = options.deviceId?.trim();
75
- if (!deviceId) return null;
87
+ if (!deviceId) {
88
+ skip("missing_device_id");
89
+ return null;
90
+ }
76
91
 
77
92
  const Notifications = loadNotifications();
78
93
  if (!Notifications) {
79
- if (__DEV__) {
80
- console.warn(
81
- "[wasto] expo-notifications is not installed; push registration skipped",
82
- );
83
- }
94
+ skip("expo_notifications_missing");
84
95
  return null;
85
96
  }
86
97
 
87
98
  const projectId = resolveEasProjectId();
88
99
  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
- }
100
+ skip("eas_project_id_missing", {
101
+ hint: "Set EXPO_PUBLIC_EAS_PROJECT_ID or extra.eas.projectId before EAS build",
102
+ });
94
103
  return null;
95
104
  }
96
105
 
@@ -122,12 +131,26 @@ export async function registerExpoPushToken(
122
131
  const { status } = await Notifications.requestPermissionsAsync();
123
132
  finalStatus = status;
124
133
  }
125
- if (finalStatus !== "granted") return null;
134
+ if (finalStatus !== "granted") {
135
+ skip("permission_denied", { status: finalStatus });
136
+ return null;
137
+ }
126
138
 
127
- const push = await Notifications.getExpoPushTokenAsync({ projectId });
128
- const token = push?.data?.trim();
129
- if (!token) return null;
139
+ try {
140
+ const push = await Notifications.getExpoPushTokenAsync({ projectId });
141
+ const token = push?.data?.trim();
142
+ if (!token) {
143
+ skip("empty_expo_push_token", { projectId });
144
+ return null;
145
+ }
130
146
 
131
- await options.registerPushToken(deviceId, token);
132
- return token;
147
+ await options.registerPushToken(deviceId, token);
148
+ return token;
149
+ } catch (error) {
150
+ skip("get_expo_push_token_failed", {
151
+ projectId,
152
+ message: error instanceof Error ? error.message : String(error),
153
+ });
154
+ throw error;
155
+ }
133
156
  }