// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // VraiPrixView.swift — l'expérience Vrai-Prix NATIVE, fidèle au site : // recherche d'adresse (FTS 3,7 M unités) → fiche d'estimation complète // (estimation, fourchette basse-haute, indice de confiance A-D, valeur au // rôle, historique 2021-2026 en barres, portrait de la propriété). import SwiftUI struct VPResult: Identifiable, Hashable { let id: String let adresse: String let municipalite: String let type: String? } struct VraiPrixView: View { let universe: Universe @State private var query = "" @State private var results: [VPResult] = [] @State private var searching = false @Environment(\.colorScheme) private var scheme var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("LA VALEUR RÉELLE · 3 747 008 PROPRIÉTÉS") .font(.system(size: 10, design: .monospaced).weight(.bold)) .foregroundStyle(universe.accent) Text("Estimez n'importe quelle adresse du Québec") .font(.title3.weight(.bold)) } HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("1305 Chemin Sainte-Foy, Québec…", text: $query) .autocorrectionDisabled() .submitLabel(.search) .onSubmit { Task { await search() } } if searching { ProgressView() } } .padding(13) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.primary.opacity(0.4), lineWidth: 1.3)) if results.isEmpty && !query.isEmpty && !searching { KAEmptyState(symbol: "house.slash", title: "Adresse introuvable", message: "Essayez numéro + rue + ville.") } ForEach(results) { r in NavigationLink(value: r) { VStack(alignment: .leading, spacing: 3) { Text(r.adresse).font(.subheadline.weight(.semibold)) HStack { Text(r.municipalite).font(.caption).foregroundStyle(.secondary) if let t = r.type { KAChip(text: t, accent: universe.accent) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12).kaCard(accent: universe.accent) } .buttonStyle(.plain) } } .padding(16) } .background(KATheme.paper(scheme)) .navigationDestination(for: VPResult.self) { EstimateView(result: $0, universe: universe) } } private func search() async { let q = query.trimmingCharacters(in: .whitespaces) guard q.count > 2 else { return } searching = true Haptics.rigid() defer { searching = false } guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/search") else { return } comps.queryItems = [.init(name: "q", value: q)] guard let url = comps.url, let root = try? await APIClient.shared.json(url), let arr = root.object?["results"]?.array else { results = []; return } results = arr.compactMap { v in guard let o = v.object, let id = o.str("id"), let ad = o.str("adresse") else { return nil } return VPResult(id: id, adresse: [ad, o.str("apt")].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: " app. "), municipalite: o.str("municipalite") ?? "", type: o.str("typeProp")) } if !results.isEmpty { Haptics.success() } } } // MARK: - Fiche d'estimation struct EstimateView: View { let result: VPResult let universe: Universe @State private var unit: [String: JSONValue]? @State private var res: [String: JSONValue]? @State private var failed = false @Environment(\.colorScheme) private var scheme var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { Text(result.adresse).font(.title2.weight(.bold)) Text(result.municipalite).font(.subheadline).foregroundStyle(.secondary) if let res { estimateCard(res) if let u = unit { historyCard(u); portraitCard(u) } Link(destination: URL(string: "https://www.vrai-prix.com/estimation/\(result.id)")!) { Label("Fiche complète + rapport PDF sur Vrai-Prix", systemImage: "doc.richtext") .font(.headline) .frame(maxWidth: .infinity).padding(.vertical, 14) .background(universe.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(.white) } } else if failed { KAEmptyState(symbol: "wifi.exclamationmark", title: "Estimation indisponible", message: "Le service ne répond pas — réessayez.") } else { ProgressView("Le moteur estime…").frame(maxWidth: .infinity).padding(40) } } .padding(16) } .background(KATheme.paper(scheme)) .navigationBarTitleDisplayMode(.inline) .tint(universe.accent) .task { await load() } } private func estimateCard(_ r: [String: JSONValue]) -> some View { VStack(alignment: .leading, spacing: 8) { Text("ESTIMATION VRAI-PRIX") .font(.system(size: 10, design: .monospaced).weight(.bold)) .foregroundStyle(Color(hex: "#f5f3ee").opacity(0.6)) Text((r.num("estimate") ?? 0).money0) .font(.system(size: 38, weight: .bold, design: .rounded)) .foregroundStyle(universe.accent) .minimumScaleFactor(0.6).lineLimit(1) if let lo = r.num("low"), let hi = r.num("high") { Text("Fourchette \(lo.money0) – \(hi.money0)") .font(.subheadline).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.85)) } HStack(spacing: 8) { if let level = r.str("confidenceLevel") { Text("Confiance \(level)") .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 4) .background(confColor(level), in: Capsule()) .foregroundStyle(.black) } if let pct = r.num("confidencePct") { Text("\(Int(pct)) %").font(.caption).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.7)) } Spacer() if let role = unit?.num("valeurRole") { VStack(alignment: .trailing, spacing: 0) { Text("Rôle 2026").font(.system(size: 9, design: .monospaced)).foregroundStyle(Color(hex: "#f5f3ee").opacity(0.55)) Text(role.money0).font(.caption.weight(.bold)).foregroundStyle(Color(hex: "#f5f3ee")) } } } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } private func historyCard(_ u: [String: JSONValue]) -> some View { Group { if let hist = u["history"]?.array, hist.count > 1 { VStack(alignment: .leading, spacing: 10) { Text("Valeur au rôle, 2021 → 2026").font(.headline) let pts: [(String, Double)] = hist.compactMap { h in guard let o = h.object, let y = o.num("year"), let v = o.num("value") else { return nil } return (String(Int(y)), v) } let maxV = pts.map(\.1).max() ?? 1 HStack(alignment: .bottom, spacing: 10) { ForEach(pts, id: \.0) { (year, v) in VStack(spacing: 4) { Text(v.compact).font(.system(size: 8, design: .monospaced)).foregroundStyle(.secondary) RoundedRectangle(cornerRadius: 3) .fill(universe.accent.opacity(year == pts.last?.0 ? 1 : 0.45)) .frame(height: max(CGFloat(v / maxV) * 90, 6)) Text(year).font(.system(size: 9, design: .monospaced).weight(.bold)) } .frame(maxWidth: .infinity) } } } .padding(14).kaCard(accent: universe.accent) } } } private func portraitCard(_ u: [String: JSONValue]) -> some View { let rows: [(String, String?)] = [ ("Type", u.str("cubfLibelle") ?? u.str("typeProp")), ("Année de construction", u.num("anneeConstruction").map { String(Int($0)) }), ("Aire des étages", u.num("aireEtagesM2").map { "\(Int($0)) m²" }), ("Terrain", u.num("superficieTerrainM2").map { "\(Int($0)) m²" }), ("Logements", u.num("nbLogements").map { String(Int($0)) }), ] return VStack(spacing: 0) { ForEach(rows.filter { $0.1 != nil }, id: \.0) { (label, value) in HStack { Text(label).font(.subheadline).foregroundStyle(.secondary) Spacer() Text(value ?? "").font(.subheadline.weight(.semibold)) } .padding(.vertical, 10).padding(.horizontal, 14) Divider().opacity(label == rows.last?.0 ? 0 : 1) } } .kaCard() } private func confColor(_ level: String) -> Color { switch level { case "A": return Color(hex: "#d9f26b") case "B": return Color(hex: "#a8e063") case "C": return Color(hex: "#e8a33d") default: return Color(hex: "#ff8a80") } } private func load() async { guard var comps = URLComponents(string: "https://www.vrai-prix.com/api/estimate") else { return } comps.queryItems = [.init(name: "id", value: result.id)] guard let url = comps.url, let root = try? await APIClient.shared.json(url, ttl: 3600), let obj = root.object else { failed = true; return } unit = obj["unit"]?.object res = obj["result"]?.object if res == nil { failed = true } else { Haptics.success() } } } // MARK: - Menu de resto (Resto·Ka) struct RestoMenuSection: Identifiable { let id = UUID() let name: String let items: [(name: String, price: String?)] } enum RestoMenuLoader { static func load(uid: String) async -> [RestoMenuSection] { guard let url = URL(string: "https://www.resto-ka.com/api/restaurants/\(uid)"), let root = try? await APIClient.shared.json(url, ttl: 600), let menus = root.object?["menus"]?.array else { return [] } var sections: [RestoMenuSection] = [] for menu in menus.prefix(1) { for s in menu.object?["sections"]?.array ?? [] { guard let so = s.object, let name = so.str("name") else { continue } let items: [(String, String?)] = (so["items"]?.array ?? []).prefix(30).compactMap { i in guard let io = i.object, let n = io.str("name") else { return nil } return (n, io.num("price").flatMap { $0 > 0 ? $0.money2 : nil }) } if !items.isEmpty { sections.append(RestoMenuSection(name: name, items: items)) } } } return sections } }