1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Ecosystem.swift — la source de vérité des 13 univers Groupe-KA dans l'app :3// identité (nom, accent, symbole, tagline) + configuration d'API (endpoint de4// liste, clé des items, mapping JSON → KAItem). Ajouter un univers = ajouter5// une entrée ici, rien d'autre (voir docs/ARCHITECTURE.md).6import SwiftUI78// MARK: - JSON générique (les API des univers ont chacune leur forme)910enum JSONValue: Decodable {11 case string(String), number(Double), bool(Bool)12 case object([String: JSONValue]), array([JSONValue]), null1314 init(from decoder: Decoder) throws {15 let c = try decoder.singleValueContainer()16 if c.decodeNil() { self = .null }17 else if let b = try? c.decode(Bool.self) { self = .bool(b) }18 else if let n = try? c.decode(Double.self) { self = .number(n) }19 else if let s = try? c.decode(String.self) { self = .string(s) }20 else if let a = try? c.decode([JSONValue].self) { self = .array(a) }21 else { self = .object(try c.decode([String: JSONValue].self)) }22 }2324 var string: String? { if case .string(let s) = self { return s }; return nil }25 var number: Double? { if case .number(let n) = self { return n }; return nil }26 var array: [JSONValue]? { if case .array(let a) = self { return a }; return nil }27 var object: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }28 /// Texte « au mieux » (string, nombre formaté, liste jointe)29 var text: String? {30 switch self {31 case .string(let s): return s.isEmpty ? nil : s32 case .number(let n): return n == n.rounded() ? String(Int(n)) : String(n)33 case .array(let a): let parts = a.compactMap(\.text); return parts.isEmpty ? nil : parts.joined(separator: ", ")34 default: return nil35 }36 }37}3839extension [String: JSONValue] {40 func str(_ keys: String...) -> String? {41 for k in keys { if let v = self[k]?.text { return v } }42 return nil43 }44 func num(_ keys: String...) -> Double? {45 for k in keys { if let v = self[k]?.number { return v } }46 return nil47 }48}4950// MARK: - L'élément universel5152struct KAItem: Identifiable, Hashable, Codable {53 var id: String54 var universeID: String55 var title: String56 var subtitle: String?57 var priceLabel: String?58 var city: String?59 var url: URL?60 var imageURL: URL?61 var latitude: Double?62 var longitude: Double?63 /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…)64 var facts: [Fact]6566 struct Fact: Hashable, Codable { var label: String; var value: String }67}6869// MARK: - Univers7071struct Universe: Identifiable {72 let id: String73 let wordmark: String // « Lou·Ka »74 let name: String // « Lou-KA »75 let tagline: String76 let accentHex: String77 let symbol: String // SF Symbol78 let domain: String79 let unit: String // « logements », « offres »…80 /// Chemin de la liste (nil = univers sans liste native, ex. groupe-ka)81 let listPath: String?82 let itemsKey: String83 let searchParam: String84 let statTotalKeys: [String] // clés du total dans /api/stats85 let map: (@Sendable ([String: JSONValue]) -> KAItem?)?8687 var accent: Color { Color(hex: accentHex) }88 var baseURL: URL { URL(string: "https://\(domain)")! }89}9091enum Ecosystem {92 static let hubURL = URL(string: "https://www.groupe-ka.com")!93 static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")!94 static let statusURL = URL(string: "https://www.groupe-ka.com/status")!95 static let contacts: [(email: String, role: String)] = [96 ("contact@groupe-ka.com", "Projets, partenariats & données"),97 ("info@groupe-ka.com", "Médias & questions générales"),98 ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),99 ]100 static let legal: [(label: String, path: String)] = [101 ("Conditions d'utilisation", "/conditions"),102 ("Politique de confidentialité", "/confidentialite"),103 ("Renseignements personnels (Loi 25)", "/loi-25"),104 ("Transparence des robots", "/bots"),105 ]106 static let disclaimer = "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction. Données lues à la source — rien d'inventé, tout est traçable."107108 static func universe(_ id: String) -> Universe? { all.first { $0.id == id } }109110 // Aides de mapping communes111 private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem {112 var id: String?113 for k in idKeys { if let v = o[k]?.text { id = v; break } }114 return KAItem(115 id: "\(universe):\(id ?? UUID().uuidString)",116 universeID: universe,117 title: o.str("title", "name", "display_name") ?? "Sans titre",118 subtitle: nil, priceLabel: nil,119 city: o.str("city"),120 url: o.str("url").flatMap(URL.init(string:)),121 imageURL: o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)),122 latitude: o.num("lat", "latitude"),123 longitude: o.num("lng", "lon", "longitude"),124 facts: []125 )126 }127128 // MARK: les 12 univers (+ le hub, sans liste)129 static let all: [Universe] = [130 Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA",131 tagline: "Tous les logements à louer", accentHex: "#ff6a00",132 symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements",133 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",134 statTotalKeys: ["total"],135 map: { o in136 var it = base(o, universe: "lou-ka")137 it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")138 if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois" }139 it.facts = [140 o.str("unit_type").map { .init(label: "Taille", value: $0) },141 o.str("address").map { .init(label: "Adresse", value: $0) },142 o.str("available_by").map { .init(label: "Disponible", value: $0) },143 ].compactMap { $0 }144 return it145 }),146 Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA",147 tagline: "Toutes les propriétés à vendre", accentHex: "#e23744",148 symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés",149 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",150 statTotalKeys: ["total"],151 map: { o in152 var it = base(o, universe: "immo-ka")153 it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")154 if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0 }155 it.facts = [156 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },157 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },158 o.str("address").map { .init(label: "Adresse", value: $0) },159 ].compactMap { $0 }160 return it161 }),162 Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix",163 tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148",164 symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées",165 listPath: nil, itemsKey: "", searchParam: "q",166 statTotalKeys: ["units_total"], map: nil),167 Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA",168 tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a",169 symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules",170 listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q",171 statTotalKeys: ["total"],172 map: { o in173 var it = base(o, universe: "auto-ka")174 it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")175 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)176 it.facts = [177 o.str("make").map { .init(label: "Marque", value: $0) },178 o.str("model").map { .init(label: "Modèle", value: $0) },179 o.str("transmission").map { .init(label: "Boîte", value: $0) },180 o.str("fuel").map { .init(label: "Carburant", value: $0) },181 ].compactMap { $0 }182 return it183 }),184 Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA",185 tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e",186 symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits",187 listPath: "/api/products", itemsKey: "items", searchParam: "q",188 statTotalKeys: ["totals.products", "total"],189 map: { o in190 var it = base(o, universe: "fabri-ka")191 it.subtitle = o.str("store_id", "store")192 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)193 it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }194 return it195 }),196 Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA",197 tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55",198 symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits",199 listPath: "/api/products", itemsKey: "products", searchParam: "q",200 statTotalKeys: ["total"],201 map: { o in202 var it = base(o, universe: "food-ka")203 it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ")204 if let p = o.num("price"), p > 0.2 { it.priceLabel = p.money2 }205 it.facts = [206 o.str("category").map { .init(label: "Catégorie", value: $0) },207 (o["on_sale"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Solde", value: "Oui 🏷️") } ; return nil }),208 ].compactMap { $0 }209 return it210 }),211 Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA",212 tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00",213 symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants",214 listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q",215 statTotalKeys: ["restaurants", "total"],216 map: { o in217 var it = base(o, universe: "resto-ka")218 let cuisines = o["cuisines"]?.text219 it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ")220 it.facts = [221 o.str("address").map { .init(label: "Adresse", value: $0) },222 o.str("chain").map { .init(label: "Chaîne", value: $0) },223 ].compactMap { $0 }224 return it225 }),226 Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA",227 tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c",228 symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements",229 listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q",230 statTotalKeys: ["total_active", "total"],231 map: { o in232 var it = base(o, universe: "sorti-ka")233 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")234 it.facts = [235 o.str("start_date").map { .init(label: "Début", value: $0) },236 o.str("venue").map { .init(label: "Lieu", value: $0) },237 (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }),238 ].compactMap { $0 }239 return it240 }),241 Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA",242 tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8",243 symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs",244 listPath: "/api/creators", itemsKey: "items", searchParam: "q",245 statTotalKeys: ["creators", "total"],246 map: { o in247 var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"])248 it.subtitle = [o["niches"]?.text, o.str("primary_platform")].compactMap { $0 }.joined(separator: " · ")249 if it.url == nil, let plats = o["platforms"]?.array?.first?.object {250 it.url = plats.str("url").flatMap(URL.init(string:))251 }252 return it253 }),254 Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA",255 tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599",256 symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi",257 listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q",258 statTotalKeys: ["total"],259 map: { o in260 var it = base(o, universe: "job-ka")261 it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ")262 if let a = o.num("salary_year_min"), a > 20000 { it.priceLabel = a.money0 + "/an" }263 it.facts = [264 o.str("employer").map { .init(label: "Employeur", value: $0) },265 o.str("work_mode").map { .init(label: "Mode", value: $0) },266 o.str("employment_type").map { .init(label: "Type", value: $0) },267 ].compactMap { $0 }268 return it269 }),270 Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA",271 tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6",272 symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées",273 listPath: "/api/search", itemsKey: "results", searchParam: "q",274 statTotalKeys: ["pages_indexed"],275 map: { o in276 var it = base(o, universe: "trouve-ka", idKeys: ["url"])277 it.title = (o.str("title") ?? "Page web").strippingHTML278 it.subtitle = o.str("snippet")?.strippingHTML279 it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 }280 return it281 }),282 Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA",283 tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb",284 symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API",285 listPath: nil, itemsKey: "", searchParam: "q",286 statTotalKeys: ["total"], map: nil),287 ]288}289290// MARK: - petites extensions291292extension Double {293 var money0: String {294 let f = NumberFormatter(); f.numberStyle = .currency295 f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0296 return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $"297 }298 var money2: String {299 let f = NumberFormatter(); f.numberStyle = .currency300 f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2301 return f.string(from: NSNumber(value: self)) ?? "\(self) $"302 }303}304305extension String {306 var strippingHTML: String {307 replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)308 .replacingOccurrences(of: "&", with: "&")309 .replacingOccurrences(of: " ", with: " ")310 }311}312313extension Color {314 init(hex: String) {315 var h = hex.trimmingCharacters(in: .alphanumerics.inverted)316 if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }317 let v = UInt64(h, radix: 16) ?? 0318 self.init(.sRGB,319 red: Double((v >> 16) & 0xFF) / 255,320 green: Double((v >> 8) & 0xFF) / 255,321 blue: Double(v & 0xFF) / 255)322 }323}324