@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 +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +148 -52
- package/dist/index.mjs +154 -54
- package/package.json +8 -3
- package/src/components/header/Header.tsx +35 -13
- package/src/components/page/Page.tsx +13 -3
- package/src/components/push/PushTokenRegistrar.tsx +46 -8
- package/src/contexts/LocalContext.tsx +32 -5
- package/src/helpers/nextCollection.ts +6 -3
- package/src/hooks/useCollectionCalendar.ts +8 -9
- package/src/push/registerExpoPushToken.ts +41 -18
package/dist/index.d.mts
CHANGED
|
@@ -185,7 +185,8 @@ type PageProps = PropsWithChildren & {
|
|
|
185
185
|
pullToRefresh?: boolean;
|
|
186
186
|
};
|
|
187
187
|
/**
|
|
188
|
-
* Page shell:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
2193
|
+
if (!deviceId) {
|
|
2194
|
+
skip("missing_device_id");
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2159
2197
|
const Notifications = loadNotifications();
|
|
2160
2198
|
if (!Notifications) {
|
|
2161
|
-
|
|
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
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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")
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8193
|
-
return url || null;
|
|
8272
|
+
return null;
|
|
8194
8273
|
}
|
|
8195
|
-
function
|
|
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:
|
|
8521
|
+
paddingVertical: 6,
|
|
8429
8522
|
gap: 10,
|
|
8430
|
-
minHeight:
|
|
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
|
|
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
|
|
215
|
+
const existingDeviceId = await AsyncStorage.getItem(keys.device);
|
|
216
|
+
const nextDeviceId = existingDeviceId || generateDeviceId();
|
|
216
217
|
await client.public.analytics.track({
|
|
217
|
-
id:
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
2183
|
+
if (!deviceId) {
|
|
2184
|
+
skip("missing_device_id");
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2149
2187
|
const Notifications = loadNotifications();
|
|
2150
2188
|
if (!Notifications) {
|
|
2151
|
-
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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")
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8271
|
-
return url || null;
|
|
8350
|
+
return null;
|
|
8272
8351
|
}
|
|
8273
|
-
function
|
|
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:
|
|
8599
|
+
paddingVertical: 6,
|
|
8507
8600
|
gap: 10,
|
|
8508
|
-
minHeight:
|
|
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 {
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
return url || null;
|
|
108
|
+
return null;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
function
|
|
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:
|
|
382
|
+
paddingVertical: 6,
|
|
361
383
|
gap: 10,
|
|
362
|
-
minHeight:
|
|
384
|
+
minHeight: 44,
|
|
363
385
|
},
|
|
364
386
|
left: {
|
|
365
387
|
flexDirection: "row",
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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, {
|
|
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 =
|
|
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
|
-
}, [
|
|
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
|
|
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={{
|
|
@@ -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.
|
|
@@ -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
|
-
|
|
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)
|
|
87
|
+
if (!deviceId) {
|
|
88
|
+
skip("missing_device_id");
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
76
91
|
|
|
77
92
|
const Notifications = loadNotifications();
|
|
78
93
|
if (!Notifications) {
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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")
|
|
134
|
+
if (finalStatus !== "granted") {
|
|
135
|
+
skip("permission_denied", { status: finalStatus });
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
126
138
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
}
|