@wasto/cms-components 1.5.10

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 (94) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.mts +465 -0
  3. package/dist/index.d.ts +465 -0
  4. package/dist/index.js +10445 -0
  5. package/dist/index.mjs +10553 -0
  6. package/package.json +90 -0
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
  8. package/src/components/article/Article.tsx +22 -0
  9. package/src/components/article/ArticleCard.tsx +81 -0
  10. package/src/components/article/ArticleExcerpts.tsx +80 -0
  11. package/src/components/article/ArticleList.tsx +114 -0
  12. package/src/components/article/ArticleMarkdown.tsx +170 -0
  13. package/src/components/article/ArticleReaderModal.tsx +93 -0
  14. package/src/components/article/ArticleSingle.tsx +57 -0
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -0
  16. package/src/components/button/Button.tsx +154 -0
  17. package/src/components/challenge/Badges.tsx +84 -0
  18. package/src/components/challenge/BadgesModal.tsx +166 -0
  19. package/src/components/challenge/Challenge.tsx +380 -0
  20. package/src/components/challenge/ChallengeModal.tsx +231 -0
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -0
  22. package/src/components/challenge/ChallengesModal.tsx +243 -0
  23. package/src/components/challenge/QuizModal.tsx +381 -0
  24. package/src/components/challenge/ResultModal.tsx +144 -0
  25. package/src/components/chat/Chat.tsx +928 -0
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
  27. package/src/components/columns/Columns.tsx +34 -0
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
  29. package/src/components/content-inset/ContentInset.tsx +33 -0
  30. package/src/components/header/Header.tsx +391 -0
  31. package/src/components/image/Image.tsx +16 -0
  32. package/src/components/image/ImageFrame.tsx +102 -0
  33. package/src/components/image/ImageMedia.tsx +31 -0
  34. package/src/components/image/ImageUrl.tsx +30 -0
  35. package/src/components/locations-map/LocationSheet.tsx +417 -0
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -0
  37. package/src/components/locations-map/LocationsMap.tsx +13 -0
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -0
  39. package/src/components/locations-map/LocationsMapShell.tsx +527 -0
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -0
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
  42. package/src/components/nav-button/NavButton.tsx +59 -0
  43. package/src/components/next-collection/NextCollection.tsx +621 -0
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -0
  45. package/src/components/page/Page.tsx +64 -0
  46. package/src/components/parapraph/Paragraph.tsx +67 -0
  47. package/src/components/profile/ProfileScreen.tsx +236 -0
  48. package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
  49. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
  50. package/src/components/search/Search.tsx +320 -0
  51. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
  52. package/src/components/title/Title.tsx +100 -0
  53. package/src/components/vision/Vision.tsx +447 -0
  54. package/src/components/vision/VisionActionCard.tsx +117 -0
  55. package/src/components/vision/VisionCapture.tsx +30 -0
  56. package/src/components/vision/VisionTapOverlay.tsx +122 -0
  57. package/src/contexts/AnnouncementsContext.tsx +171 -0
  58. package/src/contexts/ChallengesContext.tsx +182 -0
  59. package/src/contexts/LocalContext.tsx +227 -0
  60. package/src/contexts/ThemeContext.tsx +37 -0
  61. package/src/contexts/WastoContext.tsx +127 -0
  62. package/src/helpers/blogPost.ts +72 -0
  63. package/src/helpers/generateQuizQuestions.ts +247 -0
  64. package/src/helpers/getContrastColor.ts +19 -0
  65. package/src/helpers/haptics.ts +20 -0
  66. package/src/helpers/hoursSchedule.ts +230 -0
  67. package/src/helpers/htmlContentToMarkdown.ts +114 -0
  68. package/src/helpers/layout.ts +54 -0
  69. package/src/helpers/loadLinearGradient.ts +22 -0
  70. package/src/helpers/maquetteTheme.ts +76 -0
  71. package/src/helpers/markdownUnderlinePlugin.ts +32 -0
  72. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
  73. package/src/helpers/publicMediaUrl.ts +8 -0
  74. package/src/helpers/resolveApiAssetUrl.ts +21 -0
  75. package/src/helpers/systemRoutes.ts +7 -0
  76. package/src/helpers/themeStyles.ts +114 -0
  77. package/src/helpers/useSafeAreaPadding.ts +14 -0
  78. package/src/hooks/useBadges.ts +100 -0
  79. package/src/hooks/useBlogPosts.ts +105 -0
  80. package/src/hooks/useChallenges.ts +110 -0
  81. package/src/hooks/useCities.ts +56 -0
  82. package/src/hooks/useCityAnnouncements.ts +118 -0
  83. package/src/hooks/useCollectionCalendar.ts +421 -0
  84. package/src/hooks/useColumns.ts +20 -0
  85. package/src/hooks/useConsignes.ts +62 -0
  86. package/src/hooks/useLocations.ts +248 -0
  87. package/src/hooks/useNextCollection.ts +238 -0
  88. package/src/hooks/useRecyclingInfo.ts +90 -0
  89. package/src/hooks/useWastoClient.ts +16 -0
  90. package/src/index.ts +142 -0
  91. package/src/navigation/NavigationContext.tsx +56 -0
  92. package/src/sentry/initSentry.ts +120 -0
  93. package/src/types/Theme.ts +72 -0
  94. package/src/types/css-modules.d.ts +1 -0
@@ -0,0 +1,230 @@
1
+ import {
2
+ WEEKDAYS,
3
+ WEEKDAY_LABELS_FR,
4
+ formatOpeningHoursWeekly,
5
+ formatTimeDisplay,
6
+ getBrowserTimeZone,
7
+ parseTimeMinutes,
8
+ resolveHoursDisplay,
9
+ resolveHoursText,
10
+ resolveOpeningHoursUtc,
11
+ type TimeSlot,
12
+ type Weekday,
13
+ } from "@wasto/sdk";
14
+
15
+ export type HoursDayRow = {
16
+ label: string;
17
+ hours: string;
18
+ closed: boolean;
19
+ };
20
+
21
+ export type HoursStatus = {
22
+ open: boolean;
23
+ text: string;
24
+ };
25
+
26
+ export type HoursSchedule = {
27
+ rows: HoursDayRow[];
28
+ status: HoursStatus | null;
29
+ summary: string | null;
30
+ };
31
+
32
+ const DAY_ALIASES: Record<string, Weekday> = {
33
+ lun: "monday",
34
+ lundi: "monday",
35
+ mar: "tuesday",
36
+ mardi: "tuesday",
37
+ mer: "wednesday",
38
+ mercredi: "wednesday",
39
+ jeu: "thursday",
40
+ jeudi: "thursday",
41
+ ven: "friday",
42
+ vendredi: "friday",
43
+ sam: "saturday",
44
+ samedi: "saturday",
45
+ dim: "sunday",
46
+ dimanche: "sunday",
47
+ };
48
+
49
+ function parseTimeToken(raw: string): string | null {
50
+ const m = raw.trim().match(/^(\d{1,2})(?:[h:](\d{2}))?$/i);
51
+ if (!m) return null;
52
+ const h = Number(m[1]);
53
+ const min = m[2] ? Number(m[2]) : 0;
54
+ if (h > 24 || min > 59) return null;
55
+ const hours = Math.min(h, 23);
56
+ return `${String(hours).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
57
+ }
58
+
59
+ function formatSlotDisplay(slot: TimeSlot): string {
60
+ const fmt = (hhmm: string) => {
61
+ const [h, m] = hhmm.split(":").map(Number);
62
+ return `${h}:${String(m).padStart(2, "0")}`;
63
+ };
64
+ return `${fmt(slot.open)} - ${fmt(slot.close)}`;
65
+ }
66
+
67
+ function emptyByDay(): Map<Weekday, TimeSlot[]> {
68
+ const byDay = new Map<Weekday, TimeSlot[]>();
69
+ for (const day of WEEKDAYS) byDay.set(day, []);
70
+ return byDay;
71
+ }
72
+
73
+ function rowsFromByDay(byDay: Map<Weekday, TimeSlot[]>): HoursDayRow[] {
74
+ return WEEKDAYS.map((day) => {
75
+ const slots = byDay.get(day) ?? [];
76
+ return {
77
+ label: WEEKDAY_LABELS_FR[day],
78
+ hours: slots.length ? slots.map(formatSlotDisplay).join(", ") : "Fermé",
79
+ closed: slots.length === 0,
80
+ };
81
+ });
82
+ }
83
+
84
+ function weekdayFromLocalDate(at: Date): Weekday {
85
+ const jsDay = at.getDay();
86
+ return WEEKDAYS[(jsDay + 6) % 7]!;
87
+ }
88
+
89
+ function statusFromByDay(
90
+ byDay: Map<Weekday, TimeSlot[]>,
91
+ at = new Date(),
92
+ ): HoursStatus | null {
93
+ const hasAny = WEEKDAYS.some((day) => (byDay.get(day) ?? []).length > 0);
94
+ if (!hasAny) return null;
95
+
96
+ const weekday = weekdayFromLocalDate(at);
97
+ const nowMin = at.getHours() * 60 + at.getMinutes();
98
+ const todaySlots = byDay.get(weekday) ?? [];
99
+
100
+ for (const slot of todaySlots) {
101
+ const open = parseTimeMinutes(slot.open);
102
+ const close = parseTimeMinutes(slot.close);
103
+ if (open == null || close == null) continue;
104
+ if (nowMin >= open && nowMin < close) {
105
+ return { open: true, text: `Ouvert - Ferme à ${formatTimeDisplay(slot.close)}` };
106
+ }
107
+ }
108
+
109
+ const startIdx = WEEKDAYS.indexOf(weekday);
110
+ for (let i = 0; i < 7; i++) {
111
+ const day = WEEKDAYS[(startIdx + i) % 7]!;
112
+ const daySlots = byDay.get(day) ?? [];
113
+ for (const slot of daySlots) {
114
+ const open = parseTimeMinutes(slot.open);
115
+ if (open == null) continue;
116
+ if (i === 0 && open <= nowMin) continue;
117
+ const openLabel = formatTimeDisplay(slot.open);
118
+ if (i === 0) return { open: false, text: `Fermé - Ouvre à ${openLabel}` };
119
+ return {
120
+ open: false,
121
+ text: `Fermé - Ouvre ${WEEKDAY_LABELS_FR[day]} à ${openLabel}`,
122
+ };
123
+ }
124
+ }
125
+
126
+ return { open: false, text: "Fermé" };
127
+ }
128
+
129
+ function applyDayRange(
130
+ byDay: Map<Weekday, TimeSlot[]>,
131
+ from: Weekday,
132
+ to: Weekday,
133
+ slot: TimeSlot,
134
+ ) {
135
+ const startIdx = WEEKDAYS.indexOf(from);
136
+ const endIdx = WEEKDAYS.indexOf(to);
137
+ if (startIdx < 0 || endIdx < 0) return;
138
+ for (let i = startIdx; ; i = (i + 1) % 7) {
139
+ byDay.set(WEEKDAYS[i]!, [slot]);
140
+ if (i === endIdx) break;
141
+ }
142
+ }
143
+
144
+ function parseTextHours(text: string): Map<Weekday, TimeSlot[]> | null {
145
+ const t = text.trim();
146
+ if (!t) return null;
147
+ const byDay = emptyByDay();
148
+
149
+ if (/24\s*h?\s*\/\s*24/i.test(t) || /^24\s*h/i.test(t)) {
150
+ for (const day of WEEKDAYS) byDay.set(day, [{ open: "00:00", close: "23:59" }]);
151
+ return byDay;
152
+ }
153
+
154
+ const timeRe = /(\d{1,2})(?:[:h](\d{2}))?h?\s*[-–à]\s*(\d{1,2})(?:[:h](\d{2}))?h?/i;
155
+ const timeMatch = t.match(timeRe);
156
+ if (!timeMatch || timeMatch.index == null) return null;
157
+
158
+ const open = parseTimeToken(`${timeMatch[1]}h${timeMatch[2] ?? "00"}`);
159
+ const close = parseTimeToken(`${timeMatch[3]}h${timeMatch[4] ?? "00"}`);
160
+ if (!open || !close) return null;
161
+ const slot: TimeSlot = { open, close };
162
+
163
+ const dayPart = t.slice(0, timeMatch.index);
164
+ const dayRe =
165
+ /(lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche|lun|mar|mer|jeu|ven|sam|dim)\.?\b/gi;
166
+ const daysFound: Weekday[] = [];
167
+ let match: RegExpExecArray | null;
168
+ while ((match = dayRe.exec(dayPart)) !== null) {
169
+ const raw = match[1].toLowerCase();
170
+ const day = DAY_ALIASES[raw] ?? DAY_ALIASES[raw.slice(0, 3)];
171
+ if (day) daysFound.push(day);
172
+ }
173
+
174
+ if (daysFound.length === 0) {
175
+ for (const day of WEEKDAYS) byDay.set(day, [slot]);
176
+ return byDay;
177
+ }
178
+
179
+ if (daysFound.length >= 2 && /[-–à]/.test(dayPart)) {
180
+ applyDayRange(byDay, daysFound[0]!, daysFound[1]!, slot);
181
+ return byDay;
182
+ }
183
+
184
+ for (const day of daysFound) byDay.set(day, [slot]);
185
+ return byDay;
186
+ }
187
+
188
+ function byDayFromWeekly(
189
+ weekly: ReturnType<typeof formatOpeningHoursWeekly>,
190
+ ): Map<Weekday, TimeSlot[]> {
191
+ const byDay = emptyByDay();
192
+ for (const row of weekly) {
193
+ byDay.set(row.day, row.closed ? [] : row.slots);
194
+ }
195
+ return byDay;
196
+ }
197
+
198
+ export function buildHoursSchedule(point: {
199
+ hours?: string | null;
200
+ details?: Record<string, unknown> | null;
201
+ }): HoursSchedule {
202
+ const utc = resolveOpeningHoursUtc(point);
203
+ if (utc) {
204
+ const weekly = formatOpeningHoursWeekly(utc, getBrowserTimeZone());
205
+ const byDay = byDayFromWeekly(weekly);
206
+ return {
207
+ rows: weekly.map((row) => ({
208
+ label: row.label,
209
+ hours: row.closed ? "Fermé" : row.slots.map(formatSlotDisplay).join(", "),
210
+ closed: row.closed,
211
+ })),
212
+ status: statusFromByDay(byDay),
213
+ summary: resolveHoursDisplay(point),
214
+ };
215
+ }
216
+
217
+ const text =
218
+ resolveHoursText(point) ??
219
+ (typeof point.hours === "string" && point.hours.trim() ? point.hours.trim() : null);
220
+ if (!text) return { rows: [], status: null, summary: null };
221
+
222
+ const parsed = parseTextHours(text);
223
+ if (!parsed) return { rows: [], status: null, summary: text };
224
+
225
+ return {
226
+ rows: rowsFromByDay(parsed),
227
+ status: statusFromByDay(parsed),
228
+ summary: text,
229
+ };
230
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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;
@@ -0,0 +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();
@@ -0,0 +1,76 @@
1
+ import type { Theme } from "../types/Theme";
2
+ import { MAQUETTE_COLORS, MAQUETTE_RADII } from "./layout";
3
+
4
+ /**
5
+ * Default theme aligned with `.idea/maquette` tokens.
6
+ * Runtime apps override via CMS `config.theme` (console).
7
+ */
8
+ export function createMaquetteTheme(overrides?: Partial<Theme>): Theme {
9
+ const base: Theme = {
10
+ colors: {
11
+ primary: MAQUETTE_COLORS.primary,
12
+ onPrimary: MAQUETTE_COLORS.onPrimary,
13
+ surface: MAQUETTE_COLORS.surface,
14
+ onSurface: MAQUETTE_COLORS.onSurface,
15
+ background: MAQUETTE_COLORS.background,
16
+ onBackground: MAQUETTE_COLORS.onBackground,
17
+ success: MAQUETTE_COLORS.success,
18
+ onSuccess: MAQUETTE_COLORS.onSuccess,
19
+ error: MAQUETTE_COLORS.error,
20
+ onError: MAQUETTE_COLORS.onError,
21
+ border: MAQUETTE_COLORS.border,
22
+ primarySoft: MAQUETTE_COLORS.primarySoft,
23
+ surfaceElevated: MAQUETTE_COLORS.surfaceElevated,
24
+ muted: MAQUETTE_COLORS.muted,
25
+ gradient: {
26
+ from: MAQUETTE_COLORS.primary,
27
+ to: MAQUETTE_COLORS.primarySoft,
28
+ },
29
+ },
30
+ radius: MAQUETTE_RADII.radius,
31
+ radii: {
32
+ sm: MAQUETTE_RADII.sm,
33
+ md: MAQUETTE_RADII.md,
34
+ lg: MAQUETTE_RADII.lg,
35
+ pill: MAQUETTE_RADII.pill,
36
+ },
37
+ shadow: {
38
+ card: {
39
+ shadowColor: "#0F172A",
40
+ shadowOffset: { width: 0, height: 1 },
41
+ shadowOpacity: 0.06,
42
+ shadowRadius: 3,
43
+ elevation: 1,
44
+ },
45
+ soft: {
46
+ shadowColor: "#0F172A",
47
+ shadowOffset: { width: 0, height: 1 },
48
+ shadowOpacity: 0.04,
49
+ shadowRadius: 2,
50
+ elevation: 1,
51
+ },
52
+ },
53
+ };
54
+
55
+ if (!overrides) return base;
56
+
57
+ return {
58
+ ...base,
59
+ ...overrides,
60
+ colors: {
61
+ ...base.colors,
62
+ ...overrides.colors,
63
+ gradient: {
64
+ ...base.colors.gradient,
65
+ ...overrides.colors?.gradient,
66
+ },
67
+ },
68
+ radii: overrides.radii ? { ...base.radii!, ...overrides.radii } : base.radii,
69
+ shadow: overrides.shadow
70
+ ? {
71
+ card: { ...base.shadow!.card, ...overrides.shadow.card },
72
+ soft: { ...base.shadow!.soft, ...overrides.shadow.soft },
73
+ }
74
+ : base.shadow,
75
+ };
76
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Parses `++text++` as underline (`ins`), matching Console Turndown output.
3
+ */
4
+ export function markdownUnderlinePlugin(md: {
5
+ inline: {
6
+ ruler: {
7
+ after: (name: string, ruleName: string, fn: (state: any, silent: boolean) => boolean) => void;
8
+ };
9
+ };
10
+ }): void {
11
+ md.inline.ruler.after("emphasis", "underline", (state: any, silent: boolean) => {
12
+ if (
13
+ state.src.charCodeAt(state.pos) !== 0x2b ||
14
+ state.src.charCodeAt(state.pos + 1) !== 0x2b
15
+ ) {
16
+ return false;
17
+ }
18
+ const close = state.src.indexOf("++", state.pos + 2);
19
+ if (close < state.pos + 2) return false;
20
+ if (silent) return true;
21
+
22
+ const content = state.src.slice(state.pos + 2, close);
23
+ const open = state.push("ins_open", "ins", 1);
24
+ open.markup = "++";
25
+ const text = state.push("text", "", 0);
26
+ text.content = content;
27
+ const end = state.push("ins_close", "ins", -1);
28
+ end.markup = "++";
29
+ state.pos = close + 2;
30
+ return true;
31
+ });
32
+ }
@@ -0,0 +1,80 @@
1
+ import type { WasteType } from "../hooks/useNextCollection";
2
+
3
+ export type RecyclingInfoDetail = {
4
+ id: string | number;
5
+ material?: string;
6
+ description?: string;
7
+ imageUrl?: string;
8
+ };
9
+
10
+ export type RecyclingInfoItem = {
11
+ id: string;
12
+ /** Alias explicite de `id` (utile côté UI/props). */
13
+ wasteTypeId: string;
14
+ name: string;
15
+ description?: string;
16
+ icon?: string;
17
+ imageUrl?: string;
18
+ color?: string;
19
+ colorLight?: string;
20
+ rules?: string[];
21
+ details?: RecyclingInfoDetail[];
22
+ };
23
+
24
+ export function normalizeWasteTypeToRecyclingInfo(wt: WasteType): RecyclingInfoItem | null {
25
+ const id = typeof wt?.id === "string" ? wt.id : null;
26
+ if (!id) return null;
27
+
28
+ const extended = wt as WasteType & {
29
+ description?: unknown;
30
+ colorLight?: unknown;
31
+ details?: unknown;
32
+ rules?: unknown;
33
+ imageUrl?: unknown;
34
+ };
35
+
36
+ const detailsRaw = extended.details;
37
+ const details = Array.isArray(detailsRaw)
38
+ ? detailsRaw
39
+ .map((raw: unknown, idx: number) => {
40
+ const d = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
41
+ const rid = d.id;
42
+ const detailId =
43
+ typeof rid === "string" || typeof rid === "number" ? rid : `${id}-${idx}`;
44
+ const imageUrl =
45
+ typeof d.imageUrl === "string" && d.imageUrl.trim()
46
+ ? d.imageUrl.trim()
47
+ : undefined;
48
+ return {
49
+ id: detailId,
50
+ material: typeof d.material === "string" ? d.material : undefined,
51
+ description: typeof d.description === "string" ? d.description : undefined,
52
+ imageUrl,
53
+ };
54
+ })
55
+ .filter((d) => d.material || d.description || d.imageUrl)
56
+ : undefined;
57
+
58
+ const rulesRaw = extended.rules;
59
+ const rules = Array.isArray(rulesRaw)
60
+ ? rulesRaw.filter((r): r is string => typeof r === "string" && r.trim().length > 0)
61
+ : undefined;
62
+
63
+ const imageUrl =
64
+ typeof extended.imageUrl === "string" && extended.imageUrl.trim()
65
+ ? extended.imageUrl.trim()
66
+ : undefined;
67
+
68
+ return {
69
+ id,
70
+ wasteTypeId: id,
71
+ name: typeof wt.name === "string" ? wt.name : "",
72
+ description: typeof extended.description === "string" ? extended.description : undefined,
73
+ icon: typeof wt.icon === "string" ? wt.icon : undefined,
74
+ imageUrl,
75
+ color: typeof wt.color === "string" ? wt.color : undefined,
76
+ colorLight: typeof extended.colorLight === "string" ? extended.colorLight : undefined,
77
+ rules,
78
+ details,
79
+ };
80
+ }
@@ -0,0 +1,8 @@
1
+ export function publicMediaUrl(
2
+ apiUrl: string,
3
+ mediaId: string | null | undefined
4
+ ): string | null {
5
+ const id = (mediaId ?? "").trim();
6
+ if (!id) return null;
7
+ return `${apiUrl.replace(/\/$/, "")}/public/cms/media/${encodeURIComponent(id)}/file`;
8
+ }
@@ -0,0 +1,21 @@
1
+ /** Résout une URL d’asset API (chemin `/public/...` ou URL absolue / data:). */
2
+ export function resolveApiAssetUrl(
3
+ apiUrl: string,
4
+ url: string | null | undefined,
5
+ ): string | null {
6
+ const v = (url ?? "").trim();
7
+ if (!v) return null;
8
+ if (
9
+ v.startsWith("data:") ||
10
+ v.startsWith("http://") ||
11
+ v.startsWith("https://") ||
12
+ v.startsWith("file:") ||
13
+ v.startsWith("blob:")
14
+ ) {
15
+ return v;
16
+ }
17
+ if (v.startsWith("/")) {
18
+ return `${apiUrl.replace(/\/$/, "")}${v}`;
19
+ }
20
+ return v;
21
+ }
@@ -0,0 +1,7 @@
1
+ /** Fixed Expo Router paths for shell screens (apps générées + playground). */
2
+ export const SYSTEM_ROUTES = {
3
+ notifications: "/(system)/notifications",
4
+ profile: "/(system)/profile",
5
+ } as const;
6
+
7
+ export type SystemRouteKey = keyof typeof SYSTEM_ROUTES;