import SwiftUI extension StoreDetailView { func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.white) .frame(width: 32, height: 32) .background(Color.white.opacity(0.24)) .clipShape(Circle()) } .buttonStyle(.plain) } func statItem(title: String, value: String) -> some View { VStack(spacing: 4) { Text(title) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) Text(value) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } .frame(maxWidth: .infinity) } var ratingChip: some View { HStack(spacing: 6) { Image(systemName: "star.fill") .font(.caption) .foregroundStyle(Color(hex: "#F5B335")) Text(String(format: "%.1f", storeRating ?? 0)) .font(AppTypography.caption) .foregroundStyle(AppColors.textPrimary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(AppColors.brandSoft) .clipShape(Capsule()) } var storeSubtitle: String { let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if category.isEmpty { return "Restaurant" } return category } var deliveryValueLabel: String { if let minOrder = info?.minOrder { return formatCurrency(minOrder) } if let storeDistance, storeDistance.isEmpty == false { return storeDistance } return "R$ --" } var isStoreOpen: Bool { info?.isOpen ?? true } var summaryCardHeight: CGFloat { summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) } var closedStoreBannerText: String { let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if label.isEmpty { return "Loja fechada • Consulte o horário de abertura" } let normalized = label.lowercased() if normalized.hasPrefix("fechado") { let cleaned = label.replacingOccurrences(of: "Fechado", with: "") .replacingOccurrences(of: "fechado", with: "") .trimmingCharacters(in: CharacterSet(charactersIn: " -:•")) if cleaned.isEmpty == false { return "Loja fechada • \(cleaned)" } } return "Loja fechada • \(label)" } @MainActor func loadStoreData(forceRefresh: Bool = false) async { isLoading = true errorMessage = nil let infoCacheKey = "store-info:\(storeId)" let catalogCacheKey = "store-catalog:\(storeId)" if forceRefresh == false, let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { info = cachedInfo categories = cachedCatalog selectedCategoryId = cachedCatalog.first?.id isLoading = false return } if forceRefresh { AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)") AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)") } do { async let infoRequest = ApiService().storeInfo(storeId: storeId) async let catalogRequest = ApiService().storeCatalog(storeId: storeId) let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) if infoResponse.error { errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." isLoading = false return } if catalogResponse.error { errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo." isLoading = false return } info = infoResponse.result categories = catalogResponse.result ?? [] selectedCategoryId = categories.first?.id if let info = infoResponse.result { AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) } AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) isLoading = false } catch { isLoading = false if let network = error as? NetworkError { errorMessage = network.errorDescription ?? "Erro ao carregar loja." return } if let service = error as? ApiServiceError { errorMessage = service.errorDescription ?? "Erro ao carregar loja." return } errorMessage = "Erro ao carregar loja." } } func resolvedURL(_ raw: String?) -> String? { ImageSourceResolver.resolve(raw) } func formatCurrency(_ value: Double?) -> String { guard let value else { return "R$ --" } return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String { guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else { return formatCurrency(product.price) } if let firstSizeId = category.pizzaConfig?.sizes.first?.id, let firstSizePrice = product.pizzaPrices[firstSizeId] { return "A partir de \(formatCurrency(firstSizePrice))" } if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value { return "A partir de \(formatCurrency(fallback))" } return formatCurrency(product.price) } var topSectionHeight: CGFloat { cardTopInset + summaryCardHeight } func sectionAnchorId(for categoryId: String) -> String { "category-section-\(categoryId)" } func syncCategoryWithScroll() { guard isLoading == false else { return } guard isProgrammaticCategoryScroll == false else { return } guard categoryHeaderOffsets.isEmpty == false else { return } // Section whose header is nearest to the top content area wins. let topThreshold: CGFloat = 180 let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value } if let current = sorted.last(where: { $0.value <= topThreshold })?.key { selectedCategoryId = current return } if let firstVisible = sorted.first?.key { selectedCategoryId = firstVisible } } func quantityInCart(for productId: String) -> Int { appState.cart.items .filter { $0.storeId == storeId && $0.productId == productId } .reduce(0) { $0 + $1.quantity } } func quantityInCart(for item: StoreCatalogListItem) -> Int { if item.isPizzaSummary { let ids = Set(item.pizzaProductIds) return appState.cart.items .filter { $0.storeId == storeId && ids.contains($0.productId) } .reduce(0) { $0 + $1.quantity } } return quantityInCart(for: item.product.id) } func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] { if category.isPizzaCategory { guard let first = category.products.first else { return [] } let representativeImage = category.products .compactMap(\.image) .first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } return [ StoreCatalogListItem( id: "\(category.id)::pizza-summary", product: first, title: "Pizza de varios sabores", description: "Escolha o tamanho da sua fome", imageURL: representativeImage ?? first.image, isPizzaSummary: true, pizzaCategoryId: category.id, pizzaProductIds: category.products.map(\.id) ) ] } return category.products.map { product in StoreCatalogListItem( id: product.id, product: product, title: product.name, description: product.description, imageURL: product.image ) } } func requestAddToCart(_ item: CartItemState) { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } pendingCartAction = .add if shouldAskForStoreSwitch(for: storeId) { pendingCartItem = item pendingProductSheet = nil showSwitchStoreAlert = true return } applyAddToCart(item) } func requestSetCartItem(_ item: CartItemState) { guard isStoreOpen || item.quantity <= 0 else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } pendingCartAction = .set if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 { pendingCartItem = item pendingProductSheet = nil showSwitchStoreAlert = true return } applySetCartItem(item) } func requestOpenProductSheet(_ product: StoreCatalogProduct) { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } pendingCartAction = .openProductSheet if shouldAskForStoreSwitch(for: storeId) { pendingCartItem = nil pendingProductSheet = product showSwitchStoreAlert = true return } selectedProduct = product } func requestOpenPizzaSheet(categoryId: String) { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } pendingCartAction = .openPizzaSheet if shouldAskForStoreSwitch(for: storeId) { pendingCartItem = nil pendingProductSheet = nil pendingPizzaCategoryId = categoryId showSwitchStoreAlert = true return } selectedPizzaCategoryId = categoryId } func applyAddToCart(_ item: CartItemState) { if appState.cart.storeId == nil { appState.cart.storeId = storeId appState.cart.storeName = storeName } appState.cart.add(item: item) SnackbarCenter.shared.show(title: "Produto adicionado ao carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 2.0) } func applySetCartItem(_ item: CartItemState) { if item.quantity > 0, appState.cart.storeId == nil { appState.cart.storeId = storeId appState.cart.storeName = storeName } appState.cart.set(item: item) if item.quantity > 0 { SnackbarCenter.shared.show(title: "Carrinho atualizado.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) } else { SnackbarCenter.shared.show(title: "Produto removido do carrinho.", style: .success, icon: "checkmark.seal.fill", duration: 1.8) } } func currentQuantity(forCartItemId itemId: String) -> Int { appState.cart.items.first(where: { $0.id == itemId })?.quantity ?? 0 } func shouldAskForStoreSwitch(for targetStoreId: String) -> Bool { guard appState.cart.items.isEmpty == false else { return false } guard let currentStoreId = currentCartStoreId(), currentStoreId.isEmpty == false else { return false } return currentStoreId != targetStoreId } func currentCartStoreId() -> String? { if let storeId = appState.cart.storeId, storeId.isEmpty == false { return storeId } return appState.cart.items.first?.storeId } } enum CartAction { case add case set case openProductSheet case openPizzaSheet }