Swift 100%
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 /// Galerie complète (les sites en ont souvent plusieurs)62 var imageURLs: [URL] = []63 var latitude: Double?64 var longitude: Double?65 /// Description longue (fiche)66 var detail: String?67 /// Paires libres affichées sur la fiche (« Année : 2021 », « Salaire : 65 000 $ »…)68 var facts: [Fact]69 /// Liens riches (boutons) — ex. les comptes d'un créateur70 var links: [Fact] = []71 /// Étiquettes de la fiche complète (commodités, caractéristiques, cuisines…)72 /// — optionnelles : les favoris/historique sérialisés avant leur ajout73 /// doivent continuer à se décoder74 var tags: [String]? = nil75 /// Résumé « en bref » de la fiche (digest du site)76 var brief: String? = nil77 /// Prix NUMÉRIQUE (tri, gamme de prix du profil, hypothèque) — priceLabel78 /// reste l'affichage. Optionnel : les favoris sérialisés avant restent lisibles.79 var price: Double? = nil80 /// Date ISO « yyyy-MM-dd » (événements Sorti·Ka — regroupement temporel)81 var date: String? = nil8283 struct Fact: Hashable, Codable { var label: String; var value: String }84}8586// MARK: - Univers8788struct Universe: Identifiable {89 let id: String90 let wordmark: String // « Lou·Ka »91 let name: String // « Lou-KA »92 let tagline: String93 let accentHex: String94 let symbol: String // SF Symbol95 let domain: String96 let unit: String // « logements », « offres »…97 /// Chemin de la liste (nil = univers sans liste native, ex. groupe-ka)98 let listPath: String?99 let itemsKey: String100 let searchParam: String101 let statTotalKeys: [String] // clés du total dans /api/stats102 let map: (@Sendable ([String: JSONValue]) -> KAItem?)?103104 var accent: Color { Color(hex: accentHex) }105 var baseURL: URL { URL(string: "https://\(domain)")! }106}107108enum Ecosystem {109 static let hubURL = URL(string: "https://www.groupe-ka.com")!110 static let signupURL = URL(string: "https://www.groupe-ka.com/connexion")!111 static let statusURL = URL(string: "https://www.groupe-ka.com/status")!112 static let contacts: [(email: String, role: String)] = [113 ("contact@groupe-ka.com", "Projets, partenariats & données"),114 ("info@groupe-ka.com", "Médias & questions générales"),115 ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),116 ]117 static let legal: [(label: String, path: String)] = [118 ("Conditions d'utilisation", "/conditions"),119 ("Politique de confidentialité", "/confidentialite"),120 ("Renseignements personnels (Loi 25)", "/loi-25"),121 ("Transparence des robots", "/bots"),122 ]123 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."124125 static func universe(_ id: String) -> Universe? { all.first { $0.id == id } }126127 // Aides de mapping communes128 private static func base(_ o: [String: JSONValue], universe: String, idKeys: [String] = ["uid"]) -> KAItem {129 var id: String?130 for k in idKeys { if let v = o[k]?.text { id = v; break } }131 // galerie : champ `images` (liste) ou `image` — jusqu'à 10, http seulement132 var gallery: [URL] = (o["images"]?.array ?? [])133 .compactMap { $0.text }134 .filter { $0.hasPrefix("http") }135 .prefix(10)136 .compactMap(URL.init(string:))137 if gallery.isEmpty,138 let single = o.str("image", "image_url", "photo", "thumbnail").flatMap(URL.init(string:)) {139 gallery = [single]140 }141 let image = gallery.first142 return KAItem(143 id: "\(universe):\(id ?? UUID().uuidString)",144 universeID: universe,145 title: o.str("title", "name", "display_name") ?? "Sans titre",146 subtitle: nil, priceLabel: nil,147 city: o.str("city"),148 url: o.str("url").flatMap(URL.init(string:)),149 imageURL: image,150 imageURLs: gallery,151 latitude: o.num("lat", "latitude"),152 longitude: o.num("lng", "lon", "longitude"),153 detail: o.str("description", "menu_summary", "bio").map { $0.strippingHTML.trimmingCharacters(in: .whitespacesAndNewlines) },154 facts: []155 )156 }157158 // MARK: les 12 univers (+ le hub, sans liste)159 static let all: [Universe] = [160 Universe(id: "lou-ka", wordmark: "Lou·Ka", name: "Lou-KA",161 tagline: "Tous les logements à louer", accentHex: "#ff6a00",162 symbol: "key.fill", domain: "www.lou-ka.com", unit: "logements",163 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",164 statTotalKeys: ["total"],165 map: { o in166 var it = base(o, universe: "lou-ka")167 it.subtitle = [o.str("unit_type"), o.str("address")].compactMap { $0 }.joined(separator: " · ")168 if let p = o.num("price"), p > 50 { it.priceLabel = p.money0 + "/mois"; it.price = p }169 it.facts = [170 o.str("unit_type").map { .init(label: "Taille", value: $0) },171 o.str("address").map { .init(label: "Adresse", value: $0) },172 o.str("available_by").map { .init(label: "Disponible", value: $0) },173 o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },174 o.str("source").map { .init(label: "Gestionnaire", value: $0) },175 ].compactMap { $0 }176 return it177 }),178 Universe(id: "immo-ka", wordmark: "Immo·Ka", name: "Immo-KA",179 tagline: "Toutes les propriétés à vendre", accentHex: "#e23744",180 symbol: "house.fill", domain: "www.immo-ka.com", unit: "propriétés",181 listPath: "/api/listings", itemsKey: "listings", searchParam: "q",182 statTotalKeys: ["total"],183 map: { o in184 var it = base(o, universe: "immo-ka")185 it.subtitle = [o.str("property_type"), o.str("region")].compactMap { $0 }.joined(separator: " · ")186 if let p = o.num("price"), p > 1000 { it.priceLabel = p.money0; it.price = p }187 it.facts = [188 o.num("bedrooms").map { .init(label: "Chambres", value: String(Int($0))) },189 o.num("bathrooms").map { .init(label: "Salles de bain", value: String(Int($0))) },190 o.num("area_sqft").flatMap { $0 > 50 ? KAItem.Fact(label: "Superficie", value: "\(Int($0)) pi²") : nil },191 o.str("address").map { .init(label: "Adresse", value: $0) },192 o.str("source").map { .init(label: "Source", value: $0) },193 ].compactMap { $0 }194 return it195 }),196 Universe(id: "vrai-prix", wordmark: "Vrai-Prix", name: "Vrai-Prix",197 tagline: "La valeur réelle de chaque propriété", accentHex: "#ff5148",198 symbol: "chart.line.uptrend.xyaxis", domain: "www.vrai-prix.com", unit: "propriétés estimées",199 listPath: nil, itemsKey: "", searchParam: "q",200 statTotalKeys: ["units_total"], map: nil),201 Universe(id: "auto-ka", wordmark: "Auto·Ka", name: "Auto-KA",202 tagline: "Les voitures usagées du Québec", accentHex: "#ff5a2a",203 symbol: "car.fill", domain: "www.auto-ka.com", unit: "véhicules",204 listPath: "/api/vehicles", itemsKey: "vehicles", searchParam: "q",205 statTotalKeys: ["total"],206 map: { o in207 var it = base(o, universe: "auto-ka")208 it.subtitle = [o.num("year").map { String(Int($0)) }, o.str("mileage_label")].compactMap { $0 }.joined(separator: " · ")209 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money0)210 it.price = o.num("price")211 it.facts = [212 o.str("make").map { .init(label: "Marque", value: $0) },213 o.str("model").map { .init(label: "Modèle", value: $0) },214 o.str("transmission").map { .init(label: "Boîte", value: $0) },215 o.str("fuel").map { .init(label: "Carburant", value: $0) },216 o.str("drivetrain").map { .init(label: "Rouage", value: $0) },217 o.str("body_type").map { .init(label: "Carrosserie", value: $0) },218 o.str("dealer_name").map { .init(label: "Concessionnaire", value: $0) },219 o.str("mileage_label").map { .init(label: "Kilométrage", value: $0) },220 ].compactMap { $0 }221 return it222 }),223 Universe(id: "fabri-ka", wordmark: "Fabri·Ka", name: "Fabri-KA",224 tagline: "Les produits fabriqués au Québec", accentHex: "#c4532e",225 symbol: "shippingbox.fill", domain: "www.fabri-ka.com", unit: "produits",226 listPath: "/api/products", itemsKey: "items", searchParam: "q",227 statTotalKeys: ["totals.products", "total"],228 map: { o in229 var it = base(o, universe: "fabri-ka")230 it.subtitle = o.str("store_id", "store")231 it.priceLabel = o.str("price_label") ?? o.num("price").map(\.money2)232 it.price = o.num("price")233 it.facts = [o.str("category").map { .init(label: "Catégorie", value: $0) }].compactMap { $0 }234 return it235 }),236 Universe(id: "food-ka", wordmark: "Food·Ka", name: "Food-KA",237 tagline: "Les prix d'épicerie, suivis à la source", accentHex: "#1f9d55",238 symbol: "cart.fill", domain: "www.food-ka.com", unit: "produits",239 listPath: "/api/products", itemsKey: "products", searchParam: "q",240 statTotalKeys: ["total"],241 map: { o in242 var it = base(o, universe: "food-ka")243 it.subtitle = [o.str("brand"), o.str("size_label")].compactMap { $0 }.joined(separator: " · ")244 let onSale: Bool = { if case .bool(true) = o["on_sale"] ?? .null { return true }; return false }()245 if let p = o.num("price"), p > 0.2 {246 if onSale, let reg = o.num("regular_price"), reg > p {247 it.priceLabel = "\(p.money2) 🏷️ (rég. \(reg.money2))"248 } else { it.priceLabel = p.money2 }249 it.price = p250 }251 it.facts = [252 o.str("category").map { .init(label: "Catégorie", value: $0) },253 o.num("unit_price").flatMap { $0 > 0.001 ? KAItem.Fact(label: "Prix unitaire", value: $0.money2) : nil },254 onSale ? KAItem.Fact(label: "Solde", value: "Oui 🏷️") : nil,255 ].compactMap { $0 }256 return it257 }),258 Universe(id: "resto-ka", wordmark: "Resto·Ka", name: "Resto-KA",259 tagline: "Chaque resto, chaque plat, chaque prix", accentHex: "#f08c00",260 symbol: "fork.knife", domain: "www.resto-ka.com", unit: "restaurants",261 listPath: "/api/restaurants", itemsKey: "restaurants", searchParam: "q",262 statTotalKeys: ["restaurants", "total"],263 map: { o in264 var it = base(o, universe: "resto-ka")265 let cuisines = o["cuisines"]?.text266 it.subtitle = [cuisines, o.str("price_range")].compactMap { $0 }.joined(separator: " · ")267 it.facts = [268 o.str("address").map { .init(label: "Adresse", value: $0) },269 o.str("chain").map { .init(label: "Chaîne", value: $0) },270 ].compactMap { $0 }271 return it272 }),273 Universe(id: "sorti-ka", wordmark: "Sorti·Ka", name: "Sorti-KA",274 tagline: "Toutes les sorties, dans les 17 régions", accentHex: "#d6336c",275 symbol: "ticket.fill", domain: "www.sorti-ka.com", unit: "événements",276 listPath: "/api/events?upcoming=true", itemsKey: "events", searchParam: "q",277 statTotalKeys: ["total_active", "total"],278 map: { o in279 var it = base(o, universe: "sorti-ka")280 it.date = o.str("start_date")281 it.subtitle = [o.str("start_date"), o.str("venue")].compactMap { $0 }.joined(separator: " · ")282 it.facts = [283 o.str("start_date").map { .init(label: "Début", value: $0) },284 o.str("end_date").map { .init(label: "Fin", value: $0) },285 o.str("venue").map { .init(label: "Lieu", value: $0) },286 o.str("region").map { .init(label: "Région", value: $0) },287 (o["is_free"].flatMap { if case .bool(true) = $0 { return KAItem.Fact(label: "Entrée", value: "Gratuite 🎉") } ; return nil }),288 o.num("price_min").flatMap { $0 > 1 ? KAItem.Fact(label: "Billets dès", value: $0.money0) : nil },289 ].compactMap { $0 }290 return it291 }),292 Universe(id: "crea-ka", wordmark: "Créa·Ka", name: "Créa-KA",293 tagline: "Les créateurs d'ici, tous leurs liens", accentHex: "#7048e8",294 symbol: "sparkles", domain: "www.crea-ka.com", unit: "créateurs",295 listPath: "/api/creators", itemsKey: "items", searchParam: "q",296 statTotalKeys: ["creators", "total"],297 map: { o in298 var it = base(o, universe: "crea-ka", idKeys: ["cid", "uid", "display_name"])299 let plats = o["platforms"]?.array?.compactMap(\.object) ?? []300 let totalFollowers = plats.compactMap { $0.num("followers") }.reduce(0, +)301 it.subtitle = [o["niches"]?.text,302 totalFollowers > 0 ? totalFollowers.compact + " abonnés" : nil]303 .compactMap { $0 }.joined(separator: " · ")304 if it.url == nil { it.url = plats.first?.str("url").flatMap(URL.init(string:)) }305 it.facts = plats.prefix(6).compactMap { pl in306 guard let name = pl.str("platform") else { return nil }307 let f = pl.num("followers").map { $0.compact } ?? ""308 return KAItem.Fact(label: name.capitalized, value: f.isEmpty ? "@" + (pl.str("handle") ?? "") : f + " abonnés")309 }310 it.links = plats.prefix(6).compactMap { pl in311 guard let name = pl.str("platform"), let url = pl.str("url") else { return nil }312 return KAItem.Fact(label: name.capitalized, value: url)313 }314 return it315 }),316 Universe(id: "job-ka", wordmark: "Job·Ka", name: "Job-KA",317 tagline: "Tous les emplois des employeurs québécois", accentHex: "#0c8599",318 symbol: "briefcase.fill", domain: "www.job-ka.com", unit: "offres d'emploi",319 listPath: "/api/jobs", itemsKey: "jobs", searchParam: "q",320 statTotalKeys: ["total"],321 map: { o in322 var it = base(o, universe: "job-ka")323 it.subtitle = [o.str("employer"), o.str("city")].compactMap { $0 }.joined(separator: " · ")324 let sMin = o.num("salary_year_min"), sMax = o.num("salary_year_max")325 if let a = sMin, a > 20000 {326 it.priceLabel = (sMax != nil && sMax! > a) ? "\(a.money0)–\(sMax!.money0)/an" : a.money0 + "/an"327 it.price = a328 } else if let h = o.num("salary_hour_min"), h > 10 {329 it.priceLabel = h.money2 + "/h"330 }331 it.facts = [332 o.str("employer").map { .init(label: "Employeur", value: $0) },333 o.str("work_mode").map { .init(label: "Mode de travail", value: $0) },334 o.str("employment_type").map { .init(label: "Type", value: $0) },335 o.str("ats").map { .init(label: "Plateforme carrière", value: $0) },336 o.str("date_posted").map { .init(label: "Publiée le", value: String($0.prefix(10))) },337 ].compactMap { $0 }338 return it339 }),340 Universe(id: "trouve-ka", wordmark: "Trouve·Ka", name: "Trouve-KA",341 tagline: "Le moteur de recherche du web québécois", accentHex: "#1c7ed6",342 symbol: "magnifyingglass", domain: "www.trouve-ka.com", unit: "pages indexées",343 listPath: "/api/search", itemsKey: "results", searchParam: "q",344 statTotalKeys: ["pages_indexed"],345 map: { o in346 var it = base(o, universe: "trouve-ka", idKeys: ["url"])347 it.title = (o.str("title") ?? "Page web").strippingHTML348 it.subtitle = o.str("snippet")?.strippingHTML349 it.facts = [o.str("domain").map { .init(label: "Domaine", value: $0) }].compactMap { $0 }350 return it351 }),352 Universe(id: "api-ka", wordmark: "API·Ka", name: "API-KA",353 tagline: "La donnée de l'écosystème, par API", accentHex: "#3b5bdb",354 symbol: "terminal.fill", domain: "www.api-ka.com", unit: "appels API",355 listPath: nil, itemsKey: "", searchParam: "q",356 statTotalKeys: ["total"], map: nil),357 ]358}359360// MARK: - petites extensions361362extension Double {363 /// 17 100 000 → « 17,1 M », 5 200 → « 5,2 k »364 var compact: String {365 if self >= 1_000_000 { return String(format: "%.1f M", self / 1_000_000).replacingOccurrences(of: ".", with: ",") }366 if self >= 10_000 { return String(format: "%.0f k", self / 1_000) }367 if self >= 1_000 { return String(format: "%.1f k", self / 1_000).replacingOccurrences(of: ".", with: ",") }368 return String(Int(self))369 }370 var money0: String {371 let f = NumberFormatter(); f.numberStyle = .currency372 f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 0373 return f.string(from: NSNumber(value: self)) ?? "\(Int(self)) $"374 }375 var money2: String {376 let f = NumberFormatter(); f.numberStyle = .currency377 f.locale = Locale(identifier: "fr_CA"); f.maximumFractionDigits = 2378 return f.string(from: NSNumber(value: self)) ?? "\(self) $"379 }380}381382extension String {383 var strippingHTML: String {384 replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)385 .replacingOccurrences(of: "&", with: "&")386 .replacingOccurrences(of: " ", with: " ")387 }388}389390extension Color {391 init(hex: String) {392 var h = hex.trimmingCharacters(in: .alphanumerics.inverted)393 if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }394 let v = UInt64(h, radix: 16) ?? 0395 self.init(.sRGB,396 red: Double((v >> 16) & 0xFF) / 255,397 green: Double((v >> 8) & 0xFF) / 255,398 blue: Double(v & 0xFF) / 255)399 }400}401