@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.
- package/README.md +47 -47
- package/dist/index.d.mts +181 -51
- package/dist/index.d.ts +181 -51
- package/dist/index.js +3614 -3015
- package/dist/index.mjs +3021 -2418
- package/package.json +12 -2
- package/src/components/announcement-modal/AnnouncementModal.tsx +183 -183
- package/src/components/article/Article.tsx +21 -21
- package/src/components/article/ArticleCard.tsx +81 -81
- package/src/components/article/ArticleExcerpts.tsx +80 -80
- package/src/components/article/ArticleList.tsx +114 -114
- package/src/components/article/ArticleMarkdown.tsx +170 -170
- package/src/components/article/ArticleReaderModal.tsx +93 -93
- package/src/components/article/ArticleSingle.tsx +57 -57
- package/src/components/bottom-tab/BottomTab.tsx +392 -392
- package/src/components/button/Button.tsx +154 -154
- package/src/components/challenge/Badges.tsx +84 -84
- package/src/components/challenge/BadgesModal.tsx +166 -166
- package/src/components/challenge/Challenge.tsx +380 -380
- package/src/components/challenge/ChallengeModal.tsx +231 -231
- package/src/components/challenge/ChallengeVignette.tsx +249 -249
- package/src/components/challenge/ChallengesModal.tsx +243 -243
- package/src/components/challenge/QuizModal.tsx +381 -381
- package/src/components/challenge/ResultModal.tsx +144 -144
- package/src/components/chat/Chat.tsx +928 -928
- package/src/components/collection-calendar/CollectionCalendar.tsx +274 -274
- package/src/components/columns/Columns.tsx +33 -33
- package/src/components/consignes-modal/ConsignesModal.tsx +267 -267
- package/src/components/content-inset/ContentInset.tsx +33 -33
- package/src/components/header/Header.tsx +415 -391
- package/src/components/image/Image.tsx +15 -15
- package/src/components/image/ImageFrame.tsx +102 -102
- package/src/components/image/ImageMedia.tsx +31 -31
- package/src/components/image/ImageUrl.tsx +30 -30
- package/src/components/locations-map/LocationSheet.tsx +417 -417
- package/src/components/locations-map/LocationsMap.native.tsx +260 -260
- package/src/components/locations-map/LocationsMap.tsx +13 -13
- package/src/components/locations-map/LocationsMap.web.tsx +10 -10
- package/src/components/locations-map/LocationsMapShell.tsx +546 -527
- package/src/components/locations-map/LocationsMapView.tsx +108 -108
- package/src/components/locations-map/LocationsMapView.web.tsx +331 -331
- package/src/components/nav-button/NavButton.tsx +59 -59
- package/src/components/next-collection/NextCollection.tsx +636 -621
- package/src/components/notifications/NotificationsScreen.tsx +247 -247
- package/src/components/page/Page.tsx +102 -64
- package/src/components/parapraph/Paragraph.tsx +67 -67
- package/src/components/profile/ProfileScreen.tsx +236 -236
- package/src/components/push/PushTokenRegistrar.tsx +47 -0
- package/src/components/recycling-info/RecyclingInfo.tsx +189 -189
- package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -17
- package/src/components/search/Search.tsx +320 -320
- package/src/components/select-street-modal/SelectStreetModal.tsx +475 -475
- package/src/components/title/Title.tsx +100 -100
- package/src/components/vision/Vision.tsx +447 -447
- package/src/components/vision/VisionActionCard.tsx +117 -117
- package/src/components/vision/VisionCapture.tsx +30 -30
- package/src/components/vision/VisionTapOverlay.tsx +122 -122
- package/src/contexts/AnnouncementsContext.tsx +171 -171
- package/src/contexts/AppDataContext.tsx +458 -0
- package/src/contexts/ChallengesContext.tsx +182 -182
- package/src/contexts/HeaderConfigContext.tsx +29 -0
- package/src/contexts/LocalContext.tsx +227 -227
- package/src/contexts/ThemeContext.tsx +37 -37
- package/src/contexts/WastoContext.tsx +132 -127
- package/src/helpers/blogPost.ts +72 -72
- package/src/helpers/generateQuizQuestions.ts +247 -247
- package/src/helpers/getContrastColor.ts +18 -18
- package/src/helpers/haptics.ts +20 -20
- package/src/helpers/hoursSchedule.ts +230 -230
- package/src/helpers/htmlContentToMarkdown.ts +114 -114
- package/src/helpers/layout.ts +54 -54
- package/src/helpers/loadLinearGradient.ts +22 -22
- package/src/helpers/mapLocations.ts +170 -0
- package/src/helpers/maquetteTheme.ts +76 -76
- package/src/helpers/markdownUnderlinePlugin.ts +32 -32
- package/src/helpers/nextCollection.ts +122 -0
- package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -80
- package/src/helpers/publicMediaUrl.ts +8 -8
- package/src/helpers/resolveApiAssetUrl.ts +21 -21
- package/src/helpers/systemRoutes.ts +7 -7
- package/src/helpers/themeStyles.ts +114 -114
- package/src/helpers/ttlCache.ts +41 -0
- package/src/helpers/useSafeAreaPadding.ts +14 -14
- package/src/hooks/useBadges.ts +100 -100
- package/src/hooks/useBlogPosts.ts +109 -105
- package/src/hooks/useChallenges.ts +110 -110
- package/src/hooks/useCities.ts +56 -56
- package/src/hooks/useCityAnnouncements.ts +118 -118
- package/src/hooks/useCollectionCalendar.ts +421 -421
- package/src/hooks/useColumns.ts +19 -19
- package/src/hooks/useConsignes.ts +62 -62
- package/src/hooks/useLocations.ts +120 -248
- package/src/hooks/useNextCollection.ts +163 -238
- package/src/hooks/useRecyclingInfo.ts +90 -90
- package/src/hooks/useWastoClient.ts +16 -16
- package/src/index.ts +161 -142
- package/src/navigation/NavigationContext.tsx +56 -56
- package/src/push/registerExpoPushToken.ts +133 -0
- package/src/sentry/initSentry.ts +120 -120
- package/src/types/Theme.ts +72 -72
- 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(/ /gi, " ")
|
|
11
|
-
.replace(/&/g, "&")
|
|
12
|
-
.replace(/</g, "<")
|
|
13
|
-
.replace(/>/g, ">")
|
|
14
|
-
.replace(/"/g, '"')
|
|
15
|
-
.replace(/'/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, "")
|
|
59
|
-
.replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, "")
|
|
60
|
-
.replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "")
|
|
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(/ /gi, " ")
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/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, "")
|
|
59
|
+
.replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, "")
|
|
60
|
+
.replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*\/?>/gi, "")
|
|
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
|
+
}
|
package/src/helpers/layout.ts
CHANGED
|
@@ -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
|
+
}
|