1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// UniverseShowcase.swift — le rendu LÉGENDAIRE d'un univers (onglet Explorer) :3// bande héros teintée à l'accent avec logo en filigrane, pouls de l'univers4// (compteurs roulants), villes rapides filtrantes, VITRINE immersive du5// premier item, carrousel « En vedette » et mosaïque magazine 2 colonnes.6// S'adapte : univers sans photos (Job·Ka, Trouve·Ka) → liste éditoriale.7import SwiftUI89// MARK: - Bande héros : gradient accent + logo en filigrane autour du SiteHero1011struct UniverseHeroBand<Content: View>: View {12 let universe: Universe13 @ViewBuilder var content: Content14 @Environment(\.colorScheme) private var scheme1516 var body: some View {17 ZStack(alignment: .topTrailing) {18 RoundedRectangle(cornerRadius: 18, style: .continuous)19 .fill(LinearGradient(20 colors: [universe.accent.opacity(scheme == .dark ? 0.22 : 0.16),21 universe.accent.opacity(0.03)],22 startPoint: .topTrailing, endPoint: .bottomLeading))23 KALogo(universe: universe, size: 150)24 .opacity(scheme == .dark ? 0.10 : 0.07)25 .rotationEffect(.degrees(8))26 .offset(x: 34, y: -26)27 .allowsHitTesting(false)28 .accessibilityHidden(true)29 content30 .padding(14)31 }32 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))33 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)34 .strokeBorder(universe.accent.opacity(0.25), lineWidth: 1))35 }36}3738// MARK: - En-tête de section éditorial (carré accent + mono uppercase)3940struct UniverseSectionHeader: View {41 let title: String42 let accent: Color43 var detail: String? = nil44 @Environment(\.colorScheme) private var scheme4546 var body: some View {47 HStack(spacing: 8) {48 Rectangle().fill(accent).frame(width: 8, height: 8)49 Text(title.uppercased())50 .font(KAFont.mono(10))51 .kerning(1.0)52 .foregroundStyle(KATheme.ink(scheme))53 if let d = detail {54 Text(d)55 .font(KAFont.mono(9, bold: false))56 .foregroundStyle(KATheme.ink2(scheme))57 }58 Spacer()59 }60 .padding(.top, 6)61 .accessibilityElement(children: .combine)62 .accessibilityAddTraits(.isHeader)63 }64}6566// MARK: - Le pouls de l'univers (tuiles mono, compteurs roulants)6768struct UniverseStatStrip: View {69 let universe: Universe70 let liveTotal: Int?71 let shown: Int72 let cityCount: Int73 @Environment(\.colorScheme) private var scheme7475 var body: some View {76 HStack(spacing: 10) {77 if let n = liveTotal {78 tile { KACountUp(value: n, font: KAFont.display(17), color: universe.accent) }79 label: { universe.unit }80 }81 tile { Text("\(shown)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) }82 label: { "affichés" }83 if cityCount > 1 {84 tile { Text("\(cityCount)").font(KAFont.display(17)).foregroundStyle(KATheme.ink(scheme)) }85 label: { "villes" }86 }87 Spacer(minLength: 0)88 }89 }9091 private func tile(@ViewBuilder _ value: () -> some View, label: () -> String) -> some View {92 VStack(alignment: .leading, spacing: 1) {93 value()94 Text(label().uppercased())95 .font(KAFont.mono(8))96 .foregroundStyle(KATheme.ink2(scheme))97 }98 .padding(.horizontal, 12).padding(.vertical, 8)99 .kaCard(accent: universe.accent)100 .accessibilityElement(children: .combine)101 }102}103104// MARK: - Villes rapides (filtre en un tap)105106struct UniverseCityChips: View {107 let cities: [String]108 @Binding var selected: String?109 let accent: Color110 var onChange: () -> Void111 @Environment(\.colorScheme) private var scheme112113 var body: some View {114 ScrollView(.horizontal, showsIndicators: false) {115 HStack(spacing: 8) {116 ForEach(cities, id: \.self) { city in117 let on = selected == city118 Button {119 Haptics.tap()120 selected = on ? nil : city121 onChange()122 } label: {123 Text(city)124 .font(.system(.caption, design: .rounded, weight: .bold))125 .padding(.horizontal, 12).padding(.vertical, 7)126 .background(on ? accent : KATheme.surface(scheme), in: Capsule())127 .foregroundStyle(on ? .white : KATheme.ink(scheme))128 .overlay(Capsule().strokeBorder(129 on ? accent : KATheme.ink(scheme).opacity(0.3), lineWidth: 1.2))130 }131 .accessibilityLabel("\(on ? "Retirer le filtre" : "Filtrer sur") \(city)")132 }133 }134 .padding(.vertical, 2)135 }136 }137}138139// MARK: - VITRINE : le premier item en pleine largeur, prix sur la photo140141struct UniverseShowcaseCard: View {142 let item: KAItem143 let universe: Universe144 @Environment(\.colorScheme) private var scheme145146 var body: some View {147 ZStack(alignment: .bottomLeading) {148 KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)149 .frame(height: 224)150 .frame(maxWidth: .infinity)151 .clipped()152 LinearGradient(colors: [.clear, .black.opacity(0.05), .black.opacity(0.78)],153 startPoint: .top, endPoint: .bottom)154 VStack(alignment: .leading, spacing: 5) {155 Text("À LA UNE")156 .font(KAFont.mono(8.5))157 .kerning(1.2)158 .padding(.horizontal, 8).padding(.vertical, 4)159 .background(universe.accent, in: Capsule())160 .foregroundStyle(.white)161 Text(item.title)162 .font(KAFont.display(19))163 .foregroundStyle(.white)164 .lineLimit(2)165 .multilineTextAlignment(.leading)166 HStack(spacing: 8) {167 if let p = item.priceLabel {168 Text(p)169 .font(.system(.subheadline, design: .rounded, weight: .heavy))170 .foregroundStyle(KATheme.lime)171 }172 if let c = item.city {173 Text(c).font(.caption).foregroundStyle(.white.opacity(0.75))174 }175 }176 }177 .padding(14)178 }179 .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))180 .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)181 .strokeBorder(KATheme.ink(scheme).opacity(scheme == .dark ? 0.35 : 0.85), lineWidth: 1.5))182 .background(RoundedRectangle(cornerRadius: 16, style: .continuous)183 .fill(universe.accent.opacity(scheme == .dark ? 0.35 : 1))184 .offset(x: 6, y: 6))185 .accessibilityElement(children: .combine)186 .accessibilityLabel("À la une : \(item.title)\(item.priceLabel.map { ", \($0)" } ?? "")")187 }188}189190// MARK: - Carte du carrousel « En vedette »191192struct UniverseFeaturedCard: View {193 let item: KAItem194 let universe: Universe195 @Environment(\.colorScheme) private var scheme196197 var body: some View {198 VStack(alignment: .leading, spacing: 0) {199 KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)200 .frame(width: 196, height: 112)201 .clipped()202 VStack(alignment: .leading, spacing: 4) {203 Text(item.title)204 .font(.subheadline.weight(.semibold))205 .lineLimit(2, reservesSpace: true)206 .multilineTextAlignment(.leading)207 HStack {208 if let p = item.priceLabel {209 Text(p)210 .font(.system(.caption, design: .rounded).weight(.bold))211 .foregroundStyle(universe.accent)212 .lineLimit(1)213 }214 Spacer()215 if let c = item.city {216 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)217 }218 }219 }220 .padding(10)221 }222 .frame(width: 196, alignment: .topLeading)223 .kaCard(accent: universe.accent)224 }225}226227// MARK: - Cellule de la mosaïque magazine (grille 2 colonnes)228229struct UniverseMosaicCell: View {230 let item: KAItem231 let universe: Universe232 @Environment(\.colorScheme) private var scheme233234 var body: some View {235 VStack(alignment: .leading, spacing: 0) {236 if item.imageURL != nil {237 KAImage(url: item.imageURL, accent: universe.accent, symbol: universe.symbol)238 .frame(height: 108)239 .frame(maxWidth: .infinity)240 .clipped()241 } else {242 Rectangle()243 .fill(universe.accent.opacity(0.12))244 .frame(height: 54)245 .overlay(Image(systemName: universe.symbol)246 .foregroundStyle(universe.accent.opacity(0.7)))247 }248 VStack(alignment: .leading, spacing: 3) {249 Text(item.title)250 .font(.caption.weight(.semibold))251 .lineLimit(2, reservesSpace: true)252 .multilineTextAlignment(.leading)253 if let p = item.priceLabel {254 Text(p)255 .font(.system(.caption2, design: .rounded).weight(.bold))256 .foregroundStyle(universe.accent)257 .lineLimit(1)258 }259 if let c = item.city {260 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)261 }262 }263 .padding(9)264 }265 .frame(maxWidth: .infinity, alignment: .topLeading)266 .kaCard(accent: universe.accent)267 }268}269