@wasto/cms-components 1.5.10 → 1.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +47 -47
  2. package/dist/index.d.mts +181 -51
  3. package/dist/index.d.ts +181 -51
  4. package/dist/index.js +3614 -3015
  5. package/dist/index.mjs +3021 -2418
  6. package/package.json +12 -2
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -183
  8. package/src/components/article/Article.tsx +21 -21
  9. package/src/components/article/ArticleCard.tsx +81 -81
  10. package/src/components/article/ArticleExcerpts.tsx +80 -80
  11. package/src/components/article/ArticleList.tsx +114 -114
  12. package/src/components/article/ArticleMarkdown.tsx +170 -170
  13. package/src/components/article/ArticleReaderModal.tsx +93 -93
  14. package/src/components/article/ArticleSingle.tsx +57 -57
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -392
  16. package/src/components/button/Button.tsx +154 -154
  17. package/src/components/challenge/Badges.tsx +84 -84
  18. package/src/components/challenge/BadgesModal.tsx +166 -166
  19. package/src/components/challenge/Challenge.tsx +380 -380
  20. package/src/components/challenge/ChallengeModal.tsx +231 -231
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -249
  22. package/src/components/challenge/ChallengesModal.tsx +243 -243
  23. package/src/components/challenge/QuizModal.tsx +381 -381
  24. package/src/components/challenge/ResultModal.tsx +144 -144
  25. package/src/components/chat/Chat.tsx +928 -928
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -274
  27. package/src/components/columns/Columns.tsx +33 -33
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -267
  29. package/src/components/content-inset/ContentInset.tsx +33 -33
  30. package/src/components/header/Header.tsx +415 -391
  31. package/src/components/image/Image.tsx +15 -15
  32. package/src/components/image/ImageFrame.tsx +102 -102
  33. package/src/components/image/ImageMedia.tsx +31 -31
  34. package/src/components/image/ImageUrl.tsx +30 -30
  35. package/src/components/locations-map/LocationSheet.tsx +417 -417
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -260
  37. package/src/components/locations-map/LocationsMap.tsx +13 -13
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -10
  39. package/src/components/locations-map/LocationsMapShell.tsx +546 -527
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -108
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -331
  42. package/src/components/nav-button/NavButton.tsx +59 -59
  43. package/src/components/next-collection/NextCollection.tsx +636 -621
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -247
  45. package/src/components/page/Page.tsx +102 -64
  46. package/src/components/parapraph/Paragraph.tsx +67 -67
  47. package/src/components/profile/ProfileScreen.tsx +236 -236
  48. package/src/components/push/PushTokenRegistrar.tsx +47 -0
  49. package/src/components/recycling-info/RecyclingInfo.tsx +189 -189
  50. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -17
  51. package/src/components/search/Search.tsx +320 -320
  52. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -475
  53. package/src/components/title/Title.tsx +100 -100
  54. package/src/components/vision/Vision.tsx +447 -447
  55. package/src/components/vision/VisionActionCard.tsx +117 -117
  56. package/src/components/vision/VisionCapture.tsx +30 -30
  57. package/src/components/vision/VisionTapOverlay.tsx +122 -122
  58. package/src/contexts/AnnouncementsContext.tsx +171 -171
  59. package/src/contexts/AppDataContext.tsx +458 -0
  60. package/src/contexts/ChallengesContext.tsx +182 -182
  61. package/src/contexts/HeaderConfigContext.tsx +29 -0
  62. package/src/contexts/LocalContext.tsx +227 -227
  63. package/src/contexts/ThemeContext.tsx +37 -37
  64. package/src/contexts/WastoContext.tsx +132 -127
  65. package/src/helpers/blogPost.ts +72 -72
  66. package/src/helpers/generateQuizQuestions.ts +247 -247
  67. package/src/helpers/getContrastColor.ts +18 -18
  68. package/src/helpers/haptics.ts +20 -20
  69. package/src/helpers/hoursSchedule.ts +230 -230
  70. package/src/helpers/htmlContentToMarkdown.ts +114 -114
  71. package/src/helpers/layout.ts +54 -54
  72. package/src/helpers/loadLinearGradient.ts +22 -22
  73. package/src/helpers/mapLocations.ts +170 -0
  74. package/src/helpers/maquetteTheme.ts +76 -76
  75. package/src/helpers/markdownUnderlinePlugin.ts +32 -32
  76. package/src/helpers/nextCollection.ts +122 -0
  77. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -80
  78. package/src/helpers/publicMediaUrl.ts +8 -8
  79. package/src/helpers/resolveApiAssetUrl.ts +21 -21
  80. package/src/helpers/systemRoutes.ts +7 -7
  81. package/src/helpers/themeStyles.ts +114 -114
  82. package/src/helpers/ttlCache.ts +41 -0
  83. package/src/helpers/useSafeAreaPadding.ts +14 -14
  84. package/src/hooks/useBadges.ts +100 -100
  85. package/src/hooks/useBlogPosts.ts +109 -105
  86. package/src/hooks/useChallenges.ts +110 -110
  87. package/src/hooks/useCities.ts +56 -56
  88. package/src/hooks/useCityAnnouncements.ts +118 -118
  89. package/src/hooks/useCollectionCalendar.ts +421 -421
  90. package/src/hooks/useColumns.ts +19 -19
  91. package/src/hooks/useConsignes.ts +62 -62
  92. package/src/hooks/useLocations.ts +120 -248
  93. package/src/hooks/useNextCollection.ts +163 -238
  94. package/src/hooks/useRecyclingInfo.ts +90 -90
  95. package/src/hooks/useWastoClient.ts +16 -16
  96. package/src/index.ts +161 -142
  97. package/src/navigation/NavigationContext.tsx +56 -56
  98. package/src/push/registerExpoPushToken.ts +133 -0
  99. package/src/sentry/initSentry.ts +120 -120
  100. package/src/types/Theme.ts +72 -72
  101. package/src/types/css-modules.d.ts +1 -1
@@ -1,114 +1,114 @@
1
- const HTML_HINT =
2
- /<(table|thead|tbody|tr|td|th|p|div|h[1-6]|ul|ol|li|blockquote|pre|u|ins|strong|em|img|br)\b/i;
3
-
4
- export function looksLikeHtml(content: string): boolean {
5
- return HTML_HINT.test(content);
6
- }
7
-
8
- function decodeEntities(value: string): string {
9
- return value
10
- .replace(/&nbsp;/gi, " ")
11
- .replace(/&amp;/g, "&")
12
- .replace(/&lt;/g, "<")
13
- .replace(/&gt;/g, ">")
14
- .replace(/&quot;/g, '"')
15
- .replace(/&#39;/g, "'");
16
- }
17
-
18
- function stripTags(html: string): string {
19
- return decodeEntities(
20
- html
21
- .replace(/<br\s*\/?>/gi, "\n")
22
- .replace(/<\/p>/gi, "\n")
23
- .replace(/<[^>]+>/g, "")
24
- .replace(/\s+\n/g, "\n")
25
- .replace(/\n\s+/g, "\n")
26
- .replace(/[ \t]+/g, " ")
27
- .trim(),
28
- );
29
- }
30
-
31
- function cellText(html: string): string {
32
- return stripTags(html).replace(/\n/g, " ").replace(/\|/g, "\\|");
33
- }
34
-
35
- export function htmlTableToGfm(tableHtml: string): string {
36
- const rows = [...tableHtml.matchAll(/<tr[\s\S]*?<\/tr>/gi)].map((row) =>
37
- [...row[0].matchAll(/<t[hd][\s\S]*?<\/t[hd]>/gi)].map((cell) => cellText(cell[0])),
38
- );
39
- if (rows.length === 0) return "";
40
- const width = Math.max(...rows.map((r) => r.length), 1);
41
- const padded = rows.map((r) => [...r, ...Array.from({ length: width - r.length }, () => "")]);
42
- const header = padded[0];
43
- const sep = header.map(() => "---");
44
- return [
45
- `| ${header.join(" | ")} |`,
46
- `| ${sep.join(" | ")} |`,
47
- ...padded.slice(1).map((r) => `| ${r.join(" | ")} |`),
48
- ].join("\n");
49
- }
50
-
51
- function convertInline(html: string): string {
52
- return html
53
- .replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**")
54
- .replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*")
55
- .replace(/<(u|ins)>([\s\S]*?)<\/\1>/gi, "++$2++")
56
- .replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`")
57
- .replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
58
- .replace(/<img\b[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "![$1]($2)")
59
- .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, "![$2]($1)")
60
- .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "![]($1)")
61
- .replace(/<br\s*\/?>/gi, " \n");
62
- }
63
-
64
- /**
65
- * Converts leaked / legacy HTML (especially Tiptap tables and underline) to GFM.
66
- * Safe to run on mixed Markdown + HTML.
67
- */
68
- export function htmlContentToMarkdown(content: string): string {
69
- if (!content || !looksLikeHtml(content)) return content;
70
-
71
- let out = content.replace(
72
- /<div[^>]*class="[^"]*tableWrapper[^"]*"[^>]*>([\s\S]*?)<\/div>/gi,
73
- "$1",
74
- );
75
- out = out.replace(/<table[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlTableToGfm(table)}\n\n`);
76
- out = out.replace(/<(u|ins)>([\s\S]*?)<\/\1>/gi, "++$2++");
77
-
78
- if (!looksLikeHtml(out)) {
79
- return out.replace(/\n{3,}/g, "\n\n").trim();
80
- }
81
-
82
- out = out.replace(/<h([1-3])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level: string, inner: string) => {
83
- return `\n\n${"#".repeat(Number(level))} ${stripTags(convertInline(inner))}\n\n`;
84
- });
85
- out = out.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_m, inner: string) => {
86
- const body = stripTags(convertInline(inner))
87
- .split("\n")
88
- .map((line) => `> ${line}`)
89
- .join("\n");
90
- return `\n\n${body}\n\n`;
91
- });
92
- out = out.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_m, inner: string) => {
93
- return `\n\n\`\`\`\n${decodeEntities(inner).replace(/<\/?code>/gi, "").trim()}\n\`\`\`\n\n`;
94
- });
95
- out = out.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_m, inner: string) => {
96
- const items = [...String(inner).matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map(
97
- (item) => `- ${stripTags(convertInline(item[1]))}`,
98
- );
99
- return `\n\n${items.join("\n")}\n\n`;
100
- });
101
- out = out.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_m, inner: string) => {
102
- const items = [...String(inner).matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map(
103
- (item, i) => `${i + 1}. ${stripTags(convertInline(item[1]))}`,
104
- );
105
- return `\n\n${items.join("\n")}\n\n`;
106
- });
107
- out = out.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, (_m, inner: string) => {
108
- return `\n\n${stripTags(convertInline(inner))}\n\n`;
109
- });
110
- out = convertInline(out);
111
- out = out.replace(/<\/?(div|span|tbody|thead|tfoot)[^>]*>/gi, "");
112
- out = stripTags(out);
113
- return out.replace(/\n{3,}/g, "\n\n").trim();
114
- }
1
+ const HTML_HINT =
2
+ /<(table|thead|tbody|tr|td|th|p|div|h[1-6]|ul|ol|li|blockquote|pre|u|ins|strong|em|img|br)\b/i;
3
+
4
+ export function looksLikeHtml(content: string): boolean {
5
+ return HTML_HINT.test(content);
6
+ }
7
+
8
+ function decodeEntities(value: string): string {
9
+ return value
10
+ .replace(/&nbsp;/gi, " ")
11
+ .replace(/&amp;/g, "&")
12
+ .replace(/&lt;/g, "<")
13
+ .replace(/&gt;/g, ">")
14
+ .replace(/&quot;/g, '"')
15
+ .replace(/&#39;/g, "'");
16
+ }
17
+
18
+ function stripTags(html: string): string {
19
+ return decodeEntities(
20
+ html
21
+ .replace(/<br\s*\/?>/gi, "\n")
22
+ .replace(/<\/p>/gi, "\n")
23
+ .replace(/<[^>]+>/g, "")
24
+ .replace(/\s+\n/g, "\n")
25
+ .replace(/\n\s+/g, "\n")
26
+ .replace(/[ \t]+/g, " ")
27
+ .trim(),
28
+ );
29
+ }
30
+
31
+ function cellText(html: string): string {
32
+ return stripTags(html).replace(/\n/g, " ").replace(/\|/g, "\\|");
33
+ }
34
+
35
+ export function htmlTableToGfm(tableHtml: string): string {
36
+ const rows = [...tableHtml.matchAll(/<tr[\s\S]*?<\/tr>/gi)].map((row) =>
37
+ [...row[0].matchAll(/<t[hd][\s\S]*?<\/t[hd]>/gi)].map((cell) => cellText(cell[0])),
38
+ );
39
+ if (rows.length === 0) return "";
40
+ const width = Math.max(...rows.map((r) => r.length), 1);
41
+ const padded = rows.map((r) => [...r, ...Array.from({ length: width - r.length }, () => "")]);
42
+ const header = padded[0];
43
+ const sep = header.map(() => "---");
44
+ return [
45
+ `| ${header.join(" | ")} |`,
46
+ `| ${sep.join(" | ")} |`,
47
+ ...padded.slice(1).map((r) => `| ${r.join(" | ")} |`),
48
+ ].join("\n");
49
+ }
50
+
51
+ function convertInline(html: string): string {
52
+ return html
53
+ .replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**")
54
+ .replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*")
55
+ .replace(/<(u|ins)>([\s\S]*?)<\/\1>/gi, "++$2++")
56
+ .replace(/<code>([\s\S]*?)<\/code>/gi, "`$1`")
57
+ .replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
58
+ .replace(/<img\b[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "![$1]($2)")
59
+ .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, "![$2]($1)")
60
+ .replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "![]($1)")
61
+ .replace(/<br\s*\/?>/gi, " \n");
62
+ }
63
+
64
+ /**
65
+ * Converts leaked / legacy HTML (especially Tiptap tables and underline) to GFM.
66
+ * Safe to run on mixed Markdown + HTML.
67
+ */
68
+ export function htmlContentToMarkdown(content: string): string {
69
+ if (!content || !looksLikeHtml(content)) return content;
70
+
71
+ let out = content.replace(
72
+ /<div[^>]*class="[^"]*tableWrapper[^"]*"[^>]*>([\s\S]*?)<\/div>/gi,
73
+ "$1",
74
+ );
75
+ out = out.replace(/<table[\s\S]*?<\/table>/gi, (table) => `\n\n${htmlTableToGfm(table)}\n\n`);
76
+ out = out.replace(/<(u|ins)>([\s\S]*?)<\/\1>/gi, "++$2++");
77
+
78
+ if (!looksLikeHtml(out)) {
79
+ return out.replace(/\n{3,}/g, "\n\n").trim();
80
+ }
81
+
82
+ out = out.replace(/<h([1-3])[^>]*>([\s\S]*?)<\/h\1>/gi, (_m, level: string, inner: string) => {
83
+ return `\n\n${"#".repeat(Number(level))} ${stripTags(convertInline(inner))}\n\n`;
84
+ });
85
+ out = out.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_m, inner: string) => {
86
+ const body = stripTags(convertInline(inner))
87
+ .split("\n")
88
+ .map((line) => `> ${line}`)
89
+ .join("\n");
90
+ return `\n\n${body}\n\n`;
91
+ });
92
+ out = out.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_m, inner: string) => {
93
+ return `\n\n\`\`\`\n${decodeEntities(inner).replace(/<\/?code>/gi, "").trim()}\n\`\`\`\n\n`;
94
+ });
95
+ out = out.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_m, inner: string) => {
96
+ const items = [...String(inner).matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map(
97
+ (item) => `- ${stripTags(convertInline(item[1]))}`,
98
+ );
99
+ return `\n\n${items.join("\n")}\n\n`;
100
+ });
101
+ out = out.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_m, inner: string) => {
102
+ const items = [...String(inner).matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map(
103
+ (item, i) => `${i + 1}. ${stripTags(convertInline(item[1]))}`,
104
+ );
105
+ return `\n\n${items.join("\n")}\n\n`;
106
+ });
107
+ out = out.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, (_m, inner: string) => {
108
+ return `\n\n${stripTags(convertInline(inner))}\n\n`;
109
+ });
110
+ out = convertInline(out);
111
+ out = out.replace(/<\/?(div|span|tbody|thead|tfoot)[^>]*>/gi, "");
112
+ out = stripTags(out);
113
+ return out.replace(/\n{3,}/g, "\n\n").trim();
114
+ }
@@ -1,54 +1,54 @@
1
- /**
2
- * Layout tokens from `.idea/maquette/00-tokens-et-style.md`.
3
- * Structural spacing only — colors/radii come from Theme (CMS custom).
4
- */
5
- export const SPACE = {
6
- /** Horizontal padding for text / section content */
7
- pageX: 16,
8
- /** Tighter horizontal (search, chips, CTA bottom) */
9
- pageXTight: 12,
10
- /** Vertical gap between section header and content */
11
- sectionY: 24,
12
- /** Internal card / challenge row padding */
13
- cardPad: 20,
14
- /** Stack gap between list rows */
15
- stack: 12,
16
- /** Primary button height */
17
- buttonH: 42,
18
- /** Round icon hit target */
19
- iconHit: 34,
20
- /** Glyph inside icon hit */
21
- iconGlyph: 18,
22
- /** Approx. tab bar content height (excl. safe area) */
23
- tabBarH: 86,
24
- /** Next-collection card height (maquette ~196) */
25
- collectionCardH: 196,
26
- /** Next-collection card width target */
27
- collectionCardW: 286,
28
- } as const;
29
-
30
- /** Suggested radii when building a maquette-aligned Theme (defaults, not hardcoded in UI). */
31
- export const MAQUETTE_RADII = {
32
- sm: 12,
33
- md: 16,
34
- lg: 24,
35
- pill: 999,
36
- radius: 20,
37
- } as const;
38
-
39
- export const MAQUETTE_COLORS = {
40
- background: "#F5F5F5",
41
- surface: "#FFFFFF",
42
- onSurface: "#090909",
43
- onBackground: "#090909",
44
- primary: "#00D100",
45
- onPrimary: "#FFFFFF",
46
- primarySoft: "#A6ECA6",
47
- muted: "#9A9A9A",
48
- border: "#E5E5E5",
49
- success: "#FEEA16",
50
- onSuccess: "#090909",
51
- error: "#D16602",
52
- onError: "#FFFFFF",
53
- surfaceElevated: "#FFFFFF",
54
- } as const;
1
+ /**
2
+ * Layout tokens from `.idea/maquette/00-tokens-et-style.md`.
3
+ * Structural spacing only — colors/radii come from Theme (CMS custom).
4
+ */
5
+ export const SPACE = {
6
+ /** Horizontal padding for text / section content */
7
+ pageX: 16,
8
+ /** Tighter horizontal (search, chips, CTA bottom) */
9
+ pageXTight: 12,
10
+ /** Vertical gap between section header and content */
11
+ sectionY: 24,
12
+ /** Internal card / challenge row padding */
13
+ cardPad: 20,
14
+ /** Stack gap between list rows */
15
+ stack: 12,
16
+ /** Primary button height */
17
+ buttonH: 42,
18
+ /** Round icon hit target */
19
+ iconHit: 34,
20
+ /** Glyph inside icon hit */
21
+ iconGlyph: 18,
22
+ /** Approx. tab bar content height (excl. safe area) */
23
+ tabBarH: 86,
24
+ /** Next-collection card height (maquette ~196) */
25
+ collectionCardH: 196,
26
+ /** Next-collection card width target */
27
+ collectionCardW: 286,
28
+ } as const;
29
+
30
+ /** Suggested radii when building a maquette-aligned Theme (defaults, not hardcoded in UI). */
31
+ export const MAQUETTE_RADII = {
32
+ sm: 12,
33
+ md: 16,
34
+ lg: 24,
35
+ pill: 999,
36
+ radius: 20,
37
+ } as const;
38
+
39
+ export const MAQUETTE_COLORS = {
40
+ background: "#F5F5F5",
41
+ surface: "#FFFFFF",
42
+ onSurface: "#090909",
43
+ onBackground: "#090909",
44
+ primary: "#00D100",
45
+ onPrimary: "#FFFFFF",
46
+ primarySoft: "#A6ECA6",
47
+ muted: "#9A9A9A",
48
+ border: "#E5E5E5",
49
+ success: "#FEEA16",
50
+ onSuccess: "#090909",
51
+ error: "#D16602",
52
+ onError: "#FFFFFF",
53
+ surfaceElevated: "#FFFFFF",
54
+ } as const;
@@ -1,22 +1,22 @@
1
- type LinearGradientComponent = typeof import("expo-linear-gradient").LinearGradient;
2
-
3
- /**
4
- * Load LinearGradient only when the native ExpoLinearGradient module is available.
5
- * Returns null otherwise so callers can fall back to a solid View / color
6
- * (avoids RN "Component not implemented…").
7
- */
8
- export function loadLinearGradient(): LinearGradientComponent | null {
9
- try {
10
- // eslint-disable-next-line @typescript-eslint/no-require-imports
11
- const { requireOptionalNativeModule } = require("expo-modules-core");
12
- if (!requireOptionalNativeModule("ExpoLinearGradient")) {
13
- return null;
14
- }
15
- // eslint-disable-next-line @typescript-eslint/no-require-imports
16
- return require("expo-linear-gradient").LinearGradient as LinearGradientComponent;
17
- } catch {
18
- return null;
19
- }
20
- }
21
-
22
- export const LinearGradientOrNull = loadLinearGradient();
1
+ type LinearGradientComponent = typeof import("expo-linear-gradient").LinearGradient;
2
+
3
+ /**
4
+ * Load LinearGradient only when the native ExpoLinearGradient module is available.
5
+ * Returns null otherwise so callers can fall back to a solid View / color
6
+ * (avoids RN "Component not implemented…").
7
+ */
8
+ export function loadLinearGradient(): LinearGradientComponent | null {
9
+ try {
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const { requireOptionalNativeModule } = require("expo-modules-core");
12
+ if (!requireOptionalNativeModule("ExpoLinearGradient")) {
13
+ return null;
14
+ }
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ return require("expo-linear-gradient").LinearGradient as LinearGradientComponent;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export const LinearGradientOrNull = loadLinearGradient();
@@ -0,0 +1,170 @@
1
+ import {
2
+ buildHoursSchedule,
3
+ type HoursDayRow,
4
+ type HoursStatus,
5
+ } from "./hoursSchedule";
6
+
7
+ export type LocationCategory = "recycling" | "decheterie" | "collection_place" | "sorting_center";
8
+
9
+ export type MapLocation = {
10
+ id: string;
11
+ name: string;
12
+ category: LocationCategory;
13
+ address?: string;
14
+ phone?: string;
15
+ hoursDisplay?: string;
16
+ hoursRows?: HoursDayRow[];
17
+ hoursStatus?: HoursStatus | null;
18
+ lat: number;
19
+ lon: number;
20
+ badges?: string[];
21
+ };
22
+
23
+ export const LOCATION_CATEGORY_META: Record<
24
+ LocationCategory,
25
+ { label: string; color: string }
26
+ > = {
27
+ recycling: { label: "Points de collecte", color: "#00C2EE" },
28
+ decheterie: { label: "Déchèteries", color: "#ea580c" },
29
+ collection_place: { label: "Lieux de ramassage", color: "#7c3aed" },
30
+ sorting_center: { label: "Centres de tri", color: "#8B00D8" },
31
+ };
32
+
33
+ export const REUNION_CENTER = { lat: -21.115, lon: 55.53 };
34
+
35
+ /** GeoJSON order is [lon, lat]. Rejects NaN / out-of-range. */
36
+ export function isValidLatLon(lat: number, lon: number): boolean {
37
+ return (
38
+ Number.isFinite(lat) &&
39
+ Number.isFinite(lon) &&
40
+ lat >= -90 &&
41
+ lat <= 90 &&
42
+ lon >= -180 &&
43
+ lon <= 180
44
+ );
45
+ }
46
+
47
+ function toNumber(value: unknown): number | null {
48
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
49
+ if (typeof value === "string" && value.trim() !== "") {
50
+ const n = Number(value);
51
+ return Number.isFinite(n) ? n : null;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Named lon/lat fields — no SVG conversion. */
57
+ function parseLngLat(
58
+ lonCandidate: unknown,
59
+ latCandidate: unknown,
60
+ ): { lat: number; lon: number } | null {
61
+ const lon = toNumber(lonCandidate);
62
+ const lat = toNumber(latCandidate);
63
+ if (lon == null || lat == null || !isValidLatLon(lat, lon)) return null;
64
+ return { lat, lon };
65
+ }
66
+
67
+ /**
68
+ * Prefer WGS84 latitude/longitude. Legacy coordinateX/Y are aliases
69
+ * (X = longitude, Y = latitude) already in WGS84 after API migration.
70
+ */
71
+ function parseRecyclingPointCoords(p: Record<string, unknown>): { lat: number; lon: number } | null {
72
+ const details =
73
+ p.details != null && typeof p.details === "object"
74
+ ? (p.details as Record<string, unknown>)
75
+ : null;
76
+
77
+ const named = parseLngLat(
78
+ p.longitude ?? p.lon ?? details?.longitude ?? details?.lon,
79
+ p.latitude ?? p.lat ?? details?.latitude ?? details?.lat,
80
+ );
81
+ if (named) return named;
82
+
83
+ // Legacy aliases: coordinateX = longitude, coordinateY = latitude
84
+ return parseLngLat(p.coordinateX, p.coordinateY);
85
+ }
86
+
87
+ function categorizeRecyclingType(type?: string): LocationCategory {
88
+ const t = (type ?? "").toLowerCase();
89
+ if (t.includes("déchèterie") || t.includes("decheterie") || t.includes("dechetterie")) {
90
+ return "decheterie";
91
+ }
92
+ if (t.includes("tri") || t.includes("sorting")) {
93
+ return "sorting_center";
94
+ }
95
+ return "recycling";
96
+ }
97
+
98
+ /** Normalise points de recyclage + lieux de collecte en pins carte. */
99
+ export function normalizeMapLocations(
100
+ recyclingPoints: unknown[],
101
+ wasteCollectionPlaces: unknown[],
102
+ ): MapLocation[] {
103
+ const out: MapLocation[] = [];
104
+
105
+ for (const raw of recyclingPoints) {
106
+ const p = raw as Record<string, unknown>;
107
+ const coords = parseRecyclingPointCoords(p);
108
+ if (!coords) continue;
109
+ const id = String(p.id ?? "");
110
+ if (!id) continue;
111
+ const hoursSource = {
112
+ hours: typeof p.hours === "string" ? p.hours : null,
113
+ details:
114
+ p.details != null && typeof p.details === "object"
115
+ ? (p.details as Record<string, unknown>)
116
+ : null,
117
+ };
118
+ const schedule = buildHoursSchedule(hoursSource);
119
+ out.push({
120
+ id: `rp-${id}`,
121
+ name: String(p.name ?? "Point de collecte"),
122
+ category: categorizeRecyclingType(typeof p.type === "string" ? p.type : undefined),
123
+ address: typeof p.address === "string" ? p.address : undefined,
124
+ phone: typeof p.phone === "string" ? p.phone : undefined,
125
+ hoursDisplay: schedule.summary ?? undefined,
126
+ hoursRows: schedule.rows.length ? schedule.rows : undefined,
127
+ hoursStatus: schedule.status,
128
+ lat: coords.lat,
129
+ lon: coords.lon,
130
+ badges: schedule.status
131
+ ? [schedule.status.text]
132
+ : schedule.summary
133
+ ? [schedule.summary]
134
+ : undefined,
135
+ });
136
+ }
137
+
138
+ for (const raw of wasteCollectionPlaces) {
139
+ const place = raw as Record<string, unknown>;
140
+ const locs = Array.isArray(place.attachedLocations) ? place.attachedLocations : [];
141
+ const loc = locs.find((item) => {
142
+ const l = item as Record<string, unknown>;
143
+ return l.latitude != null && l.longitude != null;
144
+ }) as Record<string, unknown> | undefined;
145
+ if (!loc) continue;
146
+ const coords = parseLngLat(loc.longitude, loc.latitude);
147
+ if (!coords) continue;
148
+ const id = String(place.id ?? loc.id ?? "");
149
+ if (!id) continue;
150
+ out.push({
151
+ id: `cp-${id}`,
152
+ name: String(place.name ?? loc.name ?? "Lieu de ramassage"),
153
+ category: "collection_place",
154
+ address: typeof loc.reference === "string" ? loc.reference : undefined,
155
+ lat: coords.lat,
156
+ lon: coords.lon,
157
+ });
158
+ }
159
+
160
+ return out;
161
+ }
162
+
163
+ export function computeMapCenter(locations: MapLocation[]): { lat: number; lon: number } {
164
+ const valid = locations.filter((l) => isValidLatLon(l.lat, l.lon));
165
+ if (valid.length === 0) return REUNION_CENTER;
166
+ const lat = valid.reduce((sum, l) => sum + l.lat, 0) / valid.length;
167
+ const lon = valid.reduce((sum, l) => sum + l.lon, 0) / valid.length;
168
+ if (!isValidLatLon(lat, lon)) return REUNION_CENTER;
169
+ return { lat, lon };
170
+ }