// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // UniverseShowcase.swift — le rendu LÉGENDAIRE d'un univers (onglet Explorer) : // bande héros teintée à l'accent avec logo en filigrane, pouls de l'univers // (compteurs roulants), villes rapides filtrantes, VITRINE immersive du // premier item, carrousel « En vedette » et mosaïque magazine 2 colonnes. // S'adapte : univers sans photos (Job·Ka, Trouve·Ka) → liste éditoriale. import SwiftUI // MARK: - Bande héros : gradient accent + logo en filigrane autour du SiteHero struct UniverseHeroBand: View { let universe: Universe @ViewBuilder var content: Content @Environment(\.colorScheme) private var scheme var body: some View { ZStack(alignment: .topTrailing) { RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(LinearGradient( colors: [universe.accent.opacity(scheme == .dark ? 0.22 : 0.16), universe.accent.opacity(0.03)], startPoint: .topTrailing, endPoint: .bottomLeading)) KALogo(universe: universe, size: 150) .opacity(scheme == .dark ? 0.10 : 0.07) .rotationEffect(.degrees(8)) .offset(x: 34, y: -26) .allowsHitTesting(false) .accessibilityHidden(true) content .padding(14) } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(universe.accent.opacity(0.25), lineWidth: 1)) } } // MARK: - En-tête de section éditorial (carré accent + mono uppercase) struct UniverseSectionHeader: View { let title: String let accent: Color var detail: String? = nil @Environment(\.colorScheme) private var scheme var body: some View { HStack(spacing: 8) { Rectangle().fill(accent).frame(width: 8, height: 8) Text(title.uppercased()) .font(KAFont.mono(10)) .kerning(1.0) .foregroundStyle(KATheme.ink(scheme)) if let d = detail { Text(d) .font(KAFont.mono(9, bold: false)) .foregroundStyle(KATheme.ink2(scheme)) } Spacer() } .padding(.top, 6) .accessibilityElement(children: .combine) .accessibilityAddTraits(.isHeader) } } // MARK: - Le pouls de l'univers (tuiles mono, compteurs roulants) struct UniverseStatStrip: View { let universe: Universe let liveTotal: Int? let shown: Int let cityCount: Int @Environment(\.colorScheme) private var scheme var body: some View { HStack(spacing: 10) { if let n = liveTotal { tile { KACountUp(value: n, font: KAFont.display(17), color: universe.accent) } label: { universe.unit } } tile { Text("\(shown)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) } label: { "affichés" } if cityCount > 1 { tile { Text("\(cityCount)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) } label: { "villes" } } Spacer(minLength: 0) } } private func tile(@ViewBuilder _ value: () -> some View, label: () -> String) -> some View { VStack(alignment: .leading, spacing: 1) { value() Text(label().uppercased()) .font(KAFont.mono(8)) .foregroundStyle(KATheme.ink2(scheme)) } .padding(.horizontal, 12).padding(.vertical, 8) .kaCard(accent: universe.accent) .accessibilityElement(children: .combine) } } // MARK: - Villes rapides (filtre en un tap) struct UniverseCityChips: View { let cities: [String] @Binding var selected: String? let accent: Color var onChange: () -> Void @Environment(\.colorScheme) private var scheme var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(cities, id: \.self) { city in let on = selected == city Button { Haptics.tap() selected = on ? nil : city onChange() } label: { Text(city) .font(.system(.caption, design: .rounded, weight: .bold)) .padding(.horizontal, 12).padding(.vertical, 7) .background(on ? accent : KATheme.surface(scheme), in: Capsule()) .foregroundStyle(on ? .white : KATheme.ink(scheme)) .overlay(Capsule().strokeBorder( on ? accent : KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2)) } .accessibilityLabel("\(on ? "Retirer le filtre" : "Filtrer sur") \(city)") } } .padding(.vertical, 2) } } } // MARK: - VITRINE : le premier item en pleine largeur, prix sur la photo struct UniverseShowcaseCard: View { let item: KAItem let universe: Universe @Environment(\.colorScheme) private var scheme var body: some View { ZStack(alignment: .bottomLeading) { // image en overlay d'un cadre fixe : jamais de débordement de layout Color.clear .frame(height: 224) .frame(maxWidth: .infinity) .overlay(KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)) .clipped() LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)], startPoint: .top, endPoint: .bottom) VStack(alignment: .leading, spacing: 5) { Text("À LA UNE") .font(KAFont.mono(8.5)) .kerning(1.2) .padding(.horizontal, 8).padding(.vertical, 4) .background(universe.accent, in: Capsule()) .foregroundStyle(.white) Text(item.title) .font(KAFont.display(19)) .foregroundStyle(.white) .lineLimit(2) .multilineTextAlignment(.leading) HStack(spacing: 8) { if let p = item.priceLabel { Text(p) .font(.system(.subheadline, design: .rounded, weight: .heavy)) .foregroundStyle(KATheme.lime) } if let c = item.city { Text(c).font(.caption).foregroundStyle(.white.opacity(0.75)) } } } .padding(14) } .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5)) .background(RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 1)) .offset(x: 6, y: 6)) .accessibilityElement(children: .combine) .accessibilityLabel("À la une : \(item.title)\(item.priceLabel.map { ", \($0)" } ?? "")") } } // MARK: - Carte du carrousel « En vedette » struct UniverseFeaturedCard: View { let item: KAItem let universe: Universe @Environment(\.colorScheme) private var scheme var body: some View { VStack(alignment: .leading, spacing: 0) { KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) .frame(width: 196, height: 112) .clipped() VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.subheadline.weight(.semibold)) .lineLimit(2, reservesSpace: true) .multilineTextAlignment(.leading) HStack { if let p = item.priceLabel { Text(p) .font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(universe.accent) .lineLimit(1) } Spacer() if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) } } } .padding(10) } .frame(width: 196, alignment: .topLeading) .kaCard(accent: universe.accent) } } // MARK: - Cellule de la mosaïque magazine (grille 2 colonnes) struct UniverseMosaicCell: View { let item: KAItem let universe: Universe @Environment(\.colorScheme) private var scheme var body: some View { VStack(alignment: .leading, spacing: 0) { if item.imageURL != nil { KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol) .frame(height: 108) .frame(maxWidth: .infinity) .clipped() } else { Rectangle() .fill(universe.accent.opacity(0.12)) .frame(height: 54) .overlay(Image(systemName: universe.symbol) .foregroundStyle(universe.accent.opacity(0.7))) } VStack(alignment: .leading, spacing: 3) { Text(item.title) .font(.caption.weight(.semibold)) .lineLimit(2, reservesSpace: true) .multilineTextAlignment(.leading) if let p = item.priceLabel { Text(p) .font(.system(.caption2, design: .rounded).weight(.bold)) .foregroundStyle(universe.accent) .lineLimit(1) } if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) } } .padding(9) } .frame(maxWidth: .infinity, alignment: .topLeading) .kaCard(accent: universe.accent) } }