SPB Git

spb/ka-ios

Public
16.9 KB · 324 lines swift
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: "&amp;", with: "&")309            .replacingOccurrences(of: "&nbsp;", 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
Files · 94a2a56dd85a861c4319a2f53a8841ad0481989d
Menu

Theme