// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // UniverseExplorerView.swift — l'explorateur premium d'un univers : héros // (wordmark, tagline, compteur live, lien site), recherche + FILTRES MÉTIER, // grille photo multi-colonnes (grandes fenêtres) ou liste, fiche détaillée // riche (galerie, faits, description, liens, menu resto), sauvegarde de la // recherche avec alerte. Vrai-Prix a son expérience NATIVE (search+estimate). // Adapté de l'app iOS KA (UniversesView.swift + VraiPrixView.swift). import SwiftUI struct UniverseExplorerView: View { let universe: Universe @EnvironmentObject private var pulse: EcosystemPulse @EnvironmentObject private var state: AppState @EnvironmentObject private var recents: RecentsStore @Environment(\.colorScheme) private var scheme @State private var items: [KAItem] = [] @State private var selected: KAItem? @State private var query = "" @State private var filterParams: [String: String] = [:] @State private var loading = true @State private var errorText: String? @State private var asGrid = true @State private var savedFlash = false /// Bascule Données (explorateur natif) | Site (le vrai site web, intégré). enum Mode: String, CaseIterable { case donnees = "Données", site = "Site" } @State private var mode: Mode init(universe: Universe) { self.universe = universe // Les univers sans liste native s'ouvrent directement sur leur site. let nativeless = universe.listPath == nil || universe.map == nil let hasCustom = universe.id == "vrai-prix" || universe.id == "api-ka" _mode = State(initialValue: nativeless && !hasCustom ? .site : .donnees) } var body: some View { Group { if mode == .site { SiteView(universe: universe) } else if universe.id == "vrai-prix" { VraiPrixExplorer(universe: universe) } else if universe.id == "api-ka" { ApiPlaygroundView(universe: universe) } else if universe.listPath == nil || universe.map == nil { SiteView(universe: universe) } else { explorer } } .toolbar { ToolbarItem(placement: .principal) { Picker("Présentation de \(universe.name)", selection: $mode) { ForEach(Mode.allCases, id: \.self) { m in Text(m.rawValue).tag(m) } } .pickerStyle(.segmented) .labelsHidden() .frame(width: 170) .help("Données = explorateur natif ; Site = le site web \(universe.domain), intégré") } } } // MARK: explorateur grille/liste + fiche private var explorer: some View { VStack(spacing: 0) { hero Divider().opacity(0.4) HStack(spacing: 0) { results .frame(maxWidth: .infinity) Divider().opacity(0.4) Group { if let item = selected { ItemDetailPane(item: item) } else { KAEmptyState(symbol: universe.symbol, title: "Choisissez un élément", message: "La fiche détaillée s'affichera ici.") .frame(maxHeight: .infinity) .background(KATheme.paper(scheme)) } } .frame(width: 440) } } .task { consumePrefill() await load() } .onChange(of: state.prefill?.universeID) { if state.prefill?.universeID == universe.id { consumePrefill() Task { await load() } } } } private var hero: some View { VStack(alignment: .leading, spacing: 12) { SiteHero(universe: universe, liveTotal: pulse.totals[universe.id]) { mode = .site } HStack(spacing: 8) { Picker("Présentation", selection: $asGrid) { Image(systemName: "square.grid.2x2").tag(true) Image(systemName: "list.bullet").tag(false) } .pickerStyle(.segmented) .labelsHidden() .frame(width: 90) KASearchField(prompt: "Chercher dans \(universe.name)…", text: $query) { Task { await load() } } FilterBar(universe: universe, params: $filterParams) { Task { await load() } } if !query.isEmpty || !filterParams.isEmpty { Button { recents.saveSearch(name: "", universeID: universe.id, query: query, params: filterParams) savedFlash = true Task { try? await Task.sleep(for: .seconds(2)); savedFlash = false } } label: { Label(savedFlash ? "Alerte créée ✓" : "Sauvegarder + alerte", systemImage: savedFlash ? "bell.badge.fill" : "bell.badge") .font(.caption.weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 7) .background(savedFlash ? KATheme.green : universe.accent, in: Capsule()) .foregroundStyle(.white) } .buttonStyle(.plain) .help("Sauvegarder cette recherche — KA recompte le total à chaque ouverture et signale les nouveautés") } } } .padding(.horizontal, 16).padding(.vertical, 12) } @ViewBuilder private var results: some View { ScrollView { Group { if universe.id == "trouve-ka" && query.isEmpty { KAEmptyState(symbol: "magnifyingglass", title: "Cherchez le web québécois", message: "Trouve·Ka fouille \(pulse.totals[universe.id].map { $0.fr } ?? "plus d'un million de") pages indexées — tapez un mot-clé ci-dessus.") } else if loading { LazyVStack(spacing: 10) { ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } } } else if let e = errorText, items.isEmpty { KAEmptyState(symbol: "wifi.exclamationmark", title: "Impossible de charger", message: e) } else if items.isEmpty { KAEmptyState(symbol: "tray", title: "Aucun résultat", message: "Essayez d'autres mots-clés ou élargissez les filtres.") } else if asGrid { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(items) { item in Button { selected = item } label: { KAItemCard(item: item, selected: selected?.id == item.id) } .buttonStyle(KAPressStyle()) } } } else { LazyVStack(spacing: 10) { ForEach(items) { item in Button { selected = item } label: { KAItemRow(item: item) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(universe.accent, lineWidth: selected?.id == item.id ? 2.4 : 0) .padding(.trailing, 6).padding(.bottom, 6) ) } .buttonStyle(KAPressStyle()) } } } } .padding(14) .animation(.snappy(duration: 0.3), value: items) } .background(KATheme.paper(scheme)) } private func consumePrefill() { if let p = state.prefill, p.universeID == universe.id { query = p.query filterParams = p.params state.prefill = nil } } private func load() async { // Trouve·Ka est un moteur : pas de liste sans requête if universe.id == "trouve-ka" && query.isEmpty { items = []; selected = nil; loading = false; errorText = nil return } loading = items.isEmpty errorText = nil do { // Trouve·Ka (FastAPI) plafonne limit à 50 — 422 au-delà let cap = universe.id == "trouve-ka" ? 50 : 60 items = try await UniverseService.fetch(universe, query: query.isEmpty ? nil : query, limit: cap, params: filterParams) if let sel = selected, !items.contains(where: { $0.id == sel.id }) { selected = nil } if selected == nil { selected = items.first } } catch { errorText = (error as? URLError)?.code == .notConnectedToInternet ? "Vous êtes hors ligne." : "Le service ne répond pas." } loading = false } } // MARK: - Héros de site (reproduction fidèle de la page d'accueil de l'univers) // Spec : app iOS KA (UniversesView.swift, struct SiteHero) — adapté desktop. struct SiteHero: View { let universe: Universe let liveTotal: Int? var openSite: (() -> Void)? = nil @Environment(\.colorScheme) private var scheme private var copy: HeroCopy? { KAHeroes.hero(universe.id) } var body: some View { VStack(alignment: .leading, spacing: 10) { ticker if let c = copy { if let k = c.kicker { HStack(spacing: 10) { Rectangle().fill(KATheme.green).frame(width: 26, height: 2.5) Text(k.uppercased()) .font(KAFont.mono(10)) .foregroundStyle(scheme == .dark ? KATheme.lime : KATheme.green) .kerning(1.1) .lineLimit(1) } } HStack(alignment: .top, spacing: 18) { VStack(alignment: .leading, spacing: 7) { headline(c) Text(c.sub) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .lineLimit(3) } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 9) { statPill if let openSite { Button(action: openSite) { Text("VOIR LE SITE ↗") .font(KAFont.mono(10)) .foregroundStyle(universe.accent) .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(KAPressStyle()) .help("Ouvrir \(universe.domain) dans l'app") .accessibilityLabel("Voir le site \(universe.name) dans l'app") } } } } } } /// Bandeau ticker encre — la signature du haut des sites private var ticker: some View { HStack(spacing: 9) { KALogo(id: universe.id, size: 18, fallbackSymbol: universe.symbol, fallbackAccent: universe.accent) Text(universe.unit.uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.9)) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text("EN DIRECT") .font(KAFont.mono(9.5)) .foregroundStyle(universe.accent) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text(universe.domain.replacingOccurrences(of: "www.", with: "").uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) Text("◆").font(KAFont.mono(8)).foregroundStyle(universe.accent) Text(universe.tagline.uppercased()) .font(KAFont.mono(9.5)) .foregroundStyle(.white.opacity(0.6)) .lineLimit(1) Spacer(minLength: 0) } .padding(.horizontal, 12).padding(.vertical, 8) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } /// Titre display avec le segment surligné SUR l'accent (comme le h1 du site) private func headline(_ c: HeroCopy) -> some View { var attr = AttributedString(c.headline) attr.font = KAFont.display(24) attr.foregroundColor = KATheme.ink(scheme) if let hl = c.highlight, let range = attr.range(of: hl, options: .caseInsensitive) { attr[range].backgroundColor = universe.accent attr[range].foregroundColor = .white } return Text(attr).lineSpacing(2) } private var statPill: some View { HStack(spacing: 7) { Circle().fill(universe.accent).frame(width: 8, height: 8) if let n = liveTotal { Text(n.fr) .font(KAFont.display(14)) .contentTransition(.numericText()) Text(universe.unit) .font(.caption).foregroundStyle(KATheme.ink2(scheme)) } else { Text("En direct").font(.caption).foregroundStyle(KATheme.ink2(scheme)) } } .padding(.horizontal, 12).padding(.vertical, 7) .background(KATheme.surface(scheme), in: Capsule()) .overlay(Capsule().strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) .background(Capsule().fill(scheme == .dark ? Color.clear : KATheme.inkLight).offset(x: 4, y: 4)) .padding(.trailing, 4).padding(.bottom, 4) } } // MARK: - Fiche universelle (panneau de droite) struct ItemDetailPane: View { let item: KAItem @Environment(\.colorScheme) private var scheme @EnvironmentObject private var favorites: FavoritesStore @EnvironmentObject private var recents: RecentsStore @State private var galleryIndex = 0 @State private var menu: [RestoMenuSection] = [] private var universe: Universe? { Ecosystem.universe(item.universeID) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { gallery HStack { if let u = universe { KAChip(text: u.wordmark, accent: u.accent) } Spacer() if favorites.collections.count > 1 && !favorites.isFavorite(item) { Menu { ForEach(favorites.collections) { c in Button(c.name) { favorites.toggle(item, in: c.id) } } } label: { Image(systemName: "heart") } .menuStyle(.borderlessButton) .fixedSize() .help("Ajouter à une collection") } else { Button { favorites.toggle(item) } label: { Image(systemName: favorites.isFavorite(item) ? "heart.fill" : "heart") .foregroundStyle(favorites.isFavorite(item) ? .red : .primary) } .buttonStyle(.plain) .help(favorites.isFavorite(item) ? "Retirer des favoris" : "Ajouter aux favoris") } if let url = item.url { ShareLink(item: url, subject: Text(item.title)) { Image(systemName: "square.and.arrow.up") } .buttonStyle(.plain) .help("Partager le lien") } } .font(.title3) Text(item.title).font(.title2.weight(.bold)).textSelection(.enabled) if let sub = item.subtitle, !sub.isEmpty { Text(sub).font(.body).foregroundStyle(KATheme.ink2(scheme)).textSelection(.enabled) } HStack(spacing: 10) { if let p = item.priceLabel { Text(p).font(.system(.title3, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } if let c = item.city, !c.isEmpty { KAChip(text: c) } } if let detail = item.detail, !detail.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("À propos").font(.headline) Text(detail) .font(.subheadline) .foregroundStyle(KATheme.ink2(scheme)) .textSelection(.enabled) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .kaCard() } if !item.facts.isEmpty { VStack(spacing: 0) { ForEach(item.facts, id: \.self) { f in HStack(alignment: .top) { Text(f.label).font(.subheadline).foregroundStyle(.secondary) Spacer() Text(f.value).font(.subheadline.weight(.semibold)) .multilineTextAlignment(.trailing) .textSelection(.enabled) } .padding(.vertical, 9).padding(.horizontal, 14) if f != item.facts.last { Divider() } } } .kaCard() } if !item.links.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Comptes & liens").font(.headline) ForEach(item.links, id: \.self) { l in if let u = URL(string: l.value) { Link(destination: u) { HStack { Image(systemName: "link") Text(l.label).font(.subheadline.weight(.semibold)) Spacer() Image(systemName: "arrow.up.right").font(.caption) } .padding(11) .background((universe?.accent ?? .gray).opacity(0.1), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } .foregroundStyle(universe?.accent ?? .primary) } } } } if !menu.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack { Text("Menu & prix réels").font(.headline) Spacer() KAChip(text: "\(menu.reduce(0) { $0 + $1.items.count }) plats", accent: universe?.accent) } ForEach(menu.prefix(8)) { section in DisclosureGroup { VStack(spacing: 0) { ForEach(section.items, id: \.name) { dish in HStack(alignment: .top) { Text(dish.name).font(.subheadline) Spacer() if let p = dish.price { Text(p).font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(universe?.accent ?? .primary) } } .padding(.vertical, 6) Divider().opacity(dish.name == section.items.last?.name ? 0 : 0.6) } } .padding(.top, 4) } label: { Text(section.name).font(.subheadline.weight(.bold)) } .padding(.horizontal, 14).padding(.vertical, 8) .kaCard() } } } if let url = item.url { Link(destination: url) { Label("Voir à la source", systemImage: "arrow.up.right.square") .font(.headline) .frame(maxWidth: .infinity).padding(.vertical, 13) .background(universe?.accent ?? KATheme.green, in: RoundedRectangle(cornerRadius: 11, style: .continuous)) .foregroundStyle(.white) } .buttonStyle(.plain) Text("Groupe KA est un agrégateur : la transaction se fait chez la source originale.") .font(.caption2).foregroundStyle(.tertiary) } } .padding(18) } .background(KATheme.paper(scheme)) .task(id: item.id) { galleryIndex = 0 menu = [] recents.record(item) // historique consulté if item.universeID == "resto-ka" { let uid = String(item.id.dropFirst("resto-ka:".count)) menu = await RestoMenuLoader.load(uid: uid) } } } // MARK: galerie (image principale + vignettes cliquables) @ViewBuilder private var gallery: some View { if !item.imageURLs.isEmpty { VStack(spacing: 8) { ZStack(alignment: .bottomTrailing) { KAImage(url: item.imageURLs[min(galleryIndex, item.imageURLs.count - 1)], accent: universe?.accent ?? .gray, symbol: universe?.symbol ?? "photo") .frame(maxWidth: .infinity) .frame(height: 250) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(.primary.opacity(0.3), lineWidth: 1.2)) .accessibilityLabel("Photo \(galleryIndex + 1) sur \(item.imageURLs.count) : \(item.title)") if item.imageURLs.count > 1 { Text("\(galleryIndex + 1)/\(item.imageURLs.count)") .font(.system(.caption2, design: .monospaced).weight(.bold)) .padding(.horizontal, 8).padding(.vertical, 4) .background(.ultraThinMaterial, in: Capsule()) .padding(8) } } if item.imageURLs.count > 1 { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(Array(item.imageURLs.enumerated()), id: \.offset) { i, url in Button { galleryIndex = i } label: { KAImage(url: url, accent: universe?.accent ?? .gray) .frame(width: 64, height: 44) .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous) .strokeBorder(i == galleryIndex ? (universe?.accent ?? .blue) : .primary.opacity(0.2), lineWidth: i == galleryIndex ? 2.2 : 1)) } .buttonStyle(.plain) .accessibilityLabel("Photo \(i + 1)") } } .padding(2) } } } } } }