@wasto/cms-components 1.5.12 → 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 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) */
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) */
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;
@@ -2210,33 +2236,58 @@ async function registerExpoPushToken(options) {
2210
2236
 
2211
2237
  // src/components/push/PushTokenRegistrar.tsx
2212
2238
  function PushTokenRegistrar() {
2213
- const { deviceId } = useLocal();
2239
+ const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
2214
2240
  const { previewMode, reportError: reportError2 } = useWasto();
2215
2241
  const client = useWastoClient();
2216
2242
  const lastRegistered = (0, import_react8.useRef)(null);
2243
+ const inFlight = (0, import_react8.useRef)(false);
2217
2244
  (0, import_react8.useEffect)(() => {
2218
2245
  if (previewMode || import_react_native8.Platform.OS === "web" || !deviceId) return;
2219
- if (lastRegistered.current === deviceId) return;
2220
2246
  let cancelled = false;
2221
- void (async () => {
2247
+ const tryRegister = async () => {
2248
+ if (cancelled || inFlight.current) return;
2249
+ const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
2250
+ if (lastRegistered.current === registrationKey) return;
2251
+ inFlight.current = true;
2222
2252
  try {
2223
2253
  const token = await registerExpoPushToken({
2224
2254
  deviceId,
2225
- registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, { token: pushToken })
2255
+ registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, {
2256
+ token: pushToken,
2257
+ cityId: selectedCityId,
2258
+ wasteCollectionPlaceId: selectedPlaceId
2259
+ })
2226
2260
  });
2227
2261
  if (!cancelled && token) {
2228
- lastRegistered.current = deviceId;
2262
+ lastRegistered.current = registrationKey;
2229
2263
  }
2230
2264
  } catch (error) {
2231
2265
  if (!cancelled) {
2232
2266
  reportError2(error, { source: "push", deviceId });
2233
2267
  }
2268
+ } finally {
2269
+ inFlight.current = false;
2234
2270
  }
2235
- })();
2271
+ };
2272
+ void tryRegister();
2273
+ const sub = import_react_native8.AppState.addEventListener("change", (state) => {
2274
+ if (state === "active") {
2275
+ lastRegistered.current = null;
2276
+ void tryRegister();
2277
+ }
2278
+ });
2236
2279
  return () => {
2237
2280
  cancelled = true;
2281
+ sub.remove();
2238
2282
  };
2239
- }, [client.public.devices, deviceId, previewMode, reportError2]);
2283
+ }, [
2284
+ client.public.devices,
2285
+ deviceId,
2286
+ previewMode,
2287
+ reportError2,
2288
+ selectedCityId,
2289
+ selectedPlaceId
2290
+ ]);
2240
2291
  return null;
2241
2292
  }
2242
2293
 
@@ -6369,7 +6420,7 @@ function buildSchedule(wasteTypes, places, selectedPlaceId) {
6369
6420
  dayOfWeek: dayIndex,
6370
6421
  wasteTypeId: day.wasteTypeId,
6371
6422
  color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
6372
- biweekly: day.biweekly,
6423
+ everyWeeks: Math.max(1, Number(day.everyWeeks) || 1),
6373
6424
  startDate: day.startDate
6374
6425
  });
6375
6426
  }
@@ -6383,14 +6434,13 @@ function computeWasteTypesForDay(day, year, month, schedule, exceptions) {
6383
6434
  const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
6384
6435
  const wasteTypes = [];
6385
6436
  for (const s of daySchedules) {
6386
- if (s.biweekly && s.startDate) {
6437
+ const everyWeeks = Math.max(1, Number(s.everyWeeks) || 1);
6438
+ if (s.startDate) {
6387
6439
  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 });
6440
+ if (weeksSinceStart < 0) continue;
6441
+ if (everyWeeks > 1 && weeksSinceStart % everyWeeks !== 0) continue;
6393
6442
  }
6443
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6394
6444
  }
6395
6445
  const filtered = [];
6396
6446
  for (const wt of wasteTypes) {
@@ -8186,18 +8236,14 @@ var DEFAULT_HEADER_ACTIONS = [
8186
8236
  }
8187
8237
  ];
8188
8238
  function resolveLogoUri(apiUrl, el) {
8189
- if (el.sourceType === "media") {
8239
+ const url = (el.url || "").trim();
8240
+ if (url) return url;
8241
+ if (el.sourceType === "media" || el.mediaId) {
8190
8242
  return publicMediaUrl(apiUrl, el.mediaId);
8191
8243
  }
8192
- const url = (el.url || "").trim();
8193
- return url || null;
8244
+ return null;
8194
8245
  }
8195
- function HeaderLogo({
8196
- element
8197
- }) {
8198
- const { apiUrl } = useWasto();
8199
- const uri = resolveLogoUri(apiUrl, element);
8200
- if (!uri) return null;
8246
+ function HeaderLogoImage({ uri }) {
8201
8247
  return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
8202
8248
  import_react_native34.Image,
8203
8249
  {
@@ -8208,6 +8254,22 @@ function HeaderLogo({
8208
8254
  }
8209
8255
  );
8210
8256
  }
8257
+ function HeaderLogo({
8258
+ element
8259
+ }) {
8260
+ const { apiUrl } = useWasto();
8261
+ const uri = resolveLogoUri(apiUrl, element);
8262
+ if (!uri) return null;
8263
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(HeaderLogoImage, { uri });
8264
+ }
8265
+ function CityLogoFallback() {
8266
+ const { apiUrl } = useWasto();
8267
+ const appData = useOptionalAppData();
8268
+ const logoMediaId = typeof appData?.city?.logoMediaId === "string" ? appData.city.logoMediaId : null;
8269
+ const uri = publicMediaUrl(apiUrl, logoMediaId);
8270
+ if (!uri) return null;
8271
+ return /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(HeaderLogoImage, { uri });
8272
+ }
8211
8273
  function HeaderActionButton({
8212
8274
  onPress,
8213
8275
  accessibilityLabel,
@@ -8248,6 +8310,7 @@ function Header({
8248
8310
  return raw.filter((el) => el.type !== "title");
8249
8311
  }, [headerConfig]);
8250
8312
  const hasCustomActions = elements.some((el) => el.type === "action");
8313
+ const hasLogo = elements.some((el) => el.type === "logo");
8251
8314
  const bySlot = (0, import_react35.useMemo)(() => {
8252
8315
  const left = [];
8253
8316
  const center = [];
@@ -8369,7 +8432,8 @@ function Header({
8369
8432
  )
8370
8433
  }
8371
8434
  ) : null,
8372
- bySlot.left.map(renderElement)
8435
+ bySlot.left.map(renderElement),
8436
+ !hasLogo ? /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(CityLogoFallback, {}) : null
8373
8437
  ] }),
8374
8438
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(
8375
8439
  import_react_native34.View,
@@ -8401,6 +8465,7 @@ function Header({
8401
8465
  )
8402
8466
  }
8403
8467
  ) : null,
8468
+ /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(CityLogoFallback, {}),
8404
8469
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(Title, { text: screenTitle, level: "1" })
8405
8470
  ] }),
8406
8471
  /* @__PURE__ */ (0, import_jsx_runtime41.jsx)(import_react_native34.View, { style: styles29.actions, children: defaultActions })
@@ -8425,9 +8490,9 @@ var styles29 = import_react_native34.StyleSheet.create({
8425
8490
  alignItems: "center",
8426
8491
  justifyContent: "space-between",
8427
8492
  paddingHorizontal: SPACE.pageX,
8428
- paddingVertical: 12,
8493
+ paddingVertical: 6,
8429
8494
  gap: 10,
8430
- minHeight: 56
8495
+ minHeight: 44
8431
8496
  },
8432
8497
  left: {
8433
8498
  flexDirection: "row",
@@ -8952,6 +9017,7 @@ function LocationsMap(props) {
8952
9017
  // src/components/page/Page.tsx
8953
9018
  var import_react38 = require("react");
8954
9019
  var import_react_native36 = require("react-native");
9020
+ var import_elements = require("@react-navigation/elements");
8955
9021
  var import_jsx_runtime45 = require("react/jsx-runtime");
8956
9022
  function Page({
8957
9023
  children,
@@ -8961,7 +9027,9 @@ function Page({
8961
9027
  const { theme } = useTheme();
8962
9028
  const t = resolveTheme(theme);
8963
9029
  const themedStyles = createThemedStyles16(theme);
8964
- const topInset = useSafeAreaPadding(["top"]);
9030
+ const headerHeight = (0, import_react38.useContext)(import_elements.HeaderHeightContext);
9031
+ const hasNavHeader = typeof headerHeight === "number" && headerHeight > 0;
9032
+ const topInset = useSafeAreaPadding(hasNavHeader ? [] : ["top"]);
8965
9033
  const appData = useOptionalAppData();
8966
9034
  const [refreshing, setRefreshing] = (0, import_react38.useState)(false);
8967
9035
  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";
@@ -2200,33 +2226,58 @@ async function registerExpoPushToken(options) {
2200
2226
 
2201
2227
  // src/components/push/PushTokenRegistrar.tsx
2202
2228
  function PushTokenRegistrar() {
2203
- const { deviceId } = useLocal();
2229
+ const { deviceId, selectedCityId, selectedPlaceId } = useLocal();
2204
2230
  const { previewMode, reportError: reportError2 } = useWasto();
2205
2231
  const client = useWastoClient();
2206
2232
  const lastRegistered = useRef3(null);
2233
+ const inFlight = useRef3(false);
2207
2234
  useEffect6(() => {
2208
2235
  if (previewMode || Platform5.OS === "web" || !deviceId) return;
2209
- if (lastRegistered.current === deviceId) return;
2210
2236
  let cancelled = false;
2211
- void (async () => {
2237
+ const tryRegister = async () => {
2238
+ if (cancelled || inFlight.current) return;
2239
+ const registrationKey = `${deviceId}:${selectedPlaceId ?? ""}:${selectedCityId ?? ""}`;
2240
+ if (lastRegistered.current === registrationKey) return;
2241
+ inFlight.current = true;
2212
2242
  try {
2213
2243
  const token = await registerExpoPushToken({
2214
2244
  deviceId,
2215
- registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, { token: pushToken })
2245
+ registerPushToken: (id, pushToken) => client.public.devices.registerPushToken(id, {
2246
+ token: pushToken,
2247
+ cityId: selectedCityId,
2248
+ wasteCollectionPlaceId: selectedPlaceId
2249
+ })
2216
2250
  });
2217
2251
  if (!cancelled && token) {
2218
- lastRegistered.current = deviceId;
2252
+ lastRegistered.current = registrationKey;
2219
2253
  }
2220
2254
  } catch (error) {
2221
2255
  if (!cancelled) {
2222
2256
  reportError2(error, { source: "push", deviceId });
2223
2257
  }
2258
+ } finally {
2259
+ inFlight.current = false;
2224
2260
  }
2225
- })();
2261
+ };
2262
+ void tryRegister();
2263
+ const sub = AppState.addEventListener("change", (state) => {
2264
+ if (state === "active") {
2265
+ lastRegistered.current = null;
2266
+ void tryRegister();
2267
+ }
2268
+ });
2226
2269
  return () => {
2227
2270
  cancelled = true;
2271
+ sub.remove();
2228
2272
  };
2229
- }, [client.public.devices, deviceId, previewMode, reportError2]);
2273
+ }, [
2274
+ client.public.devices,
2275
+ deviceId,
2276
+ previewMode,
2277
+ reportError2,
2278
+ selectedCityId,
2279
+ selectedPlaceId
2280
+ ]);
2230
2281
  return null;
2231
2282
  }
2232
2283
 
@@ -6387,7 +6438,7 @@ function buildSchedule(wasteTypes, places, selectedPlaceId) {
6387
6438
  dayOfWeek: dayIndex,
6388
6439
  wasteTypeId: day.wasteTypeId,
6389
6440
  color: typeof color === "string" && color.trim().length > 0 ? color : "#7C7C7C",
6390
- biweekly: day.biweekly,
6441
+ everyWeeks: Math.max(1, Number(day.everyWeeks) || 1),
6391
6442
  startDate: day.startDate
6392
6443
  });
6393
6444
  }
@@ -6401,14 +6452,13 @@ function computeWasteTypesForDay(day, year, month, schedule, exceptions) {
6401
6452
  const daySchedules = schedule.filter((s) => s.dayOfWeek === mondayBasedDay);
6402
6453
  const wasteTypes = [];
6403
6454
  for (const s of daySchedules) {
6404
- if (s.biweekly && s.startDate) {
6455
+ const everyWeeks = Math.max(1, Number(s.everyWeeks) || 1);
6456
+ if (s.startDate) {
6405
6457
  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 });
6458
+ if (weeksSinceStart < 0) continue;
6459
+ if (everyWeeks > 1 && weeksSinceStart % everyWeeks !== 0) continue;
6411
6460
  }
6461
+ wasteTypes.push({ wasteTypeId: s.wasteTypeId, color: s.color });
6412
6462
  }
6413
6463
  const filtered = [];
6414
6464
  for (const wt of wasteTypes) {
@@ -8264,18 +8314,14 @@ var DEFAULT_HEADER_ACTIONS = [
8264
8314
  }
8265
8315
  ];
8266
8316
  function resolveLogoUri(apiUrl, el) {
8267
- if (el.sourceType === "media") {
8317
+ const url = (el.url || "").trim();
8318
+ if (url) return url;
8319
+ if (el.sourceType === "media" || el.mediaId) {
8268
8320
  return publicMediaUrl(apiUrl, el.mediaId);
8269
8321
  }
8270
- const url = (el.url || "").trim();
8271
- return url || null;
8322
+ return null;
8272
8323
  }
8273
- function HeaderLogo({
8274
- element
8275
- }) {
8276
- const { apiUrl } = useWasto();
8277
- const uri = resolveLogoUri(apiUrl, element);
8278
- if (!uri) return null;
8324
+ function HeaderLogoImage({ uri }) {
8279
8325
  return /* @__PURE__ */ jsx41(
8280
8326
  Image11,
8281
8327
  {
@@ -8286,6 +8332,22 @@ function HeaderLogo({
8286
8332
  }
8287
8333
  );
8288
8334
  }
8335
+ function HeaderLogo({
8336
+ element
8337
+ }) {
8338
+ const { apiUrl } = useWasto();
8339
+ const uri = resolveLogoUri(apiUrl, element);
8340
+ if (!uri) return null;
8341
+ return /* @__PURE__ */ jsx41(HeaderLogoImage, { uri });
8342
+ }
8343
+ function CityLogoFallback() {
8344
+ const { apiUrl } = useWasto();
8345
+ const appData = useOptionalAppData();
8346
+ const logoMediaId = typeof appData?.city?.logoMediaId === "string" ? appData.city.logoMediaId : null;
8347
+ const uri = publicMediaUrl(apiUrl, logoMediaId);
8348
+ if (!uri) return null;
8349
+ return /* @__PURE__ */ jsx41(HeaderLogoImage, { uri });
8350
+ }
8289
8351
  function HeaderActionButton({
8290
8352
  onPress,
8291
8353
  accessibilityLabel,
@@ -8326,6 +8388,7 @@ function Header({
8326
8388
  return raw.filter((el) => el.type !== "title");
8327
8389
  }, [headerConfig]);
8328
8390
  const hasCustomActions = elements.some((el) => el.type === "action");
8391
+ const hasLogo = elements.some((el) => el.type === "logo");
8329
8392
  const bySlot = useMemo20(() => {
8330
8393
  const left = [];
8331
8394
  const center = [];
@@ -8447,7 +8510,8 @@ function Header({
8447
8510
  )
8448
8511
  }
8449
8512
  ) : null,
8450
- bySlot.left.map(renderElement)
8513
+ bySlot.left.map(renderElement),
8514
+ !hasLogo ? /* @__PURE__ */ jsx41(CityLogoFallback, {}) : null
8451
8515
  ] }),
8452
8516
  /* @__PURE__ */ jsx41(
8453
8517
  View29,
@@ -8479,6 +8543,7 @@ function Header({
8479
8543
  )
8480
8544
  }
8481
8545
  ) : null,
8546
+ /* @__PURE__ */ jsx41(CityLogoFallback, {}),
8482
8547
  /* @__PURE__ */ jsx41(Title, { text: screenTitle, level: "1" })
8483
8548
  ] }),
8484
8549
  /* @__PURE__ */ jsx41(View29, { style: styles29.actions, children: defaultActions })
@@ -8503,9 +8568,9 @@ var styles29 = StyleSheet30.create({
8503
8568
  alignItems: "center",
8504
8569
  justifyContent: "space-between",
8505
8570
  paddingHorizontal: SPACE.pageX,
8506
- paddingVertical: 12,
8571
+ paddingVertical: 6,
8507
8572
  gap: 10,
8508
- minHeight: 56
8573
+ minHeight: 44
8509
8574
  },
8510
8575
  left: {
8511
8576
  flexDirection: "row",
@@ -9031,8 +9096,13 @@ function LocationsMap(props) {
9031
9096
  }
9032
9097
 
9033
9098
  // src/components/page/Page.tsx
9034
- import { useCallback as useCallback13, useState as useState26 } from "react";
9099
+ import {
9100
+ useCallback as useCallback13,
9101
+ useContext as useContext10,
9102
+ useState as useState26
9103
+ } from "react";
9035
9104
  import { RefreshControl, ScrollView as ScrollView11, StyleSheet as StyleSheet32, View as View31 } from "react-native";
9105
+ import { HeaderHeightContext } from "@react-navigation/elements";
9036
9106
  import { jsx as jsx45 } from "react/jsx-runtime";
9037
9107
  function Page({
9038
9108
  children,
@@ -9042,7 +9112,9 @@ function Page({
9042
9112
  const { theme } = useTheme();
9043
9113
  const t = resolveTheme(theme);
9044
9114
  const themedStyles = createThemedStyles16(theme);
9045
- const topInset = useSafeAreaPadding(["top"]);
9115
+ const headerHeight = useContext10(HeaderHeightContext);
9116
+ const hasNavHeader = typeof headerHeight === "number" && headerHeight > 0;
9117
+ const topInset = useSafeAreaPadding(hasNavHeader ? [] : ["top"]);
9046
9118
  const appData = useOptionalAppData();
9047
9119
  const [refreshing, setRefreshing] = useState26(false);
9048
9120
  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.14",
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,71 @@ 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
+ }),
30
41
  });
31
42
  if (!cancelled && token) {
32
- lastRegistered.current = deviceId;
43
+ lastRegistered.current = registrationKey;
33
44
  }
34
45
  } catch (error) {
35
46
  if (!cancelled) {
36
47
  reportError(error, { source: "push", deviceId });
37
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();
38
60
  }
39
- })();
61
+ });
40
62
 
41
63
  return () => {
42
64
  cancelled = true;
65
+ sub.remove();
43
66
  };
44
- }, [client.public.devices, deviceId, previewMode, reportError]);
67
+ }, [
68
+ client.public.devices,
69
+ deviceId,
70
+ previewMode,
71
+ reportError,
72
+ selectedCityId,
73
+ selectedPlaceId,
74
+ ]);
45
75
 
46
76
  return null;
47
77
  }
@@ -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.