import SwiftUI extension StoreDetailView { var topSection: some View { ZStack(alignment: .top) { heroSection .frame(height: topSectionHeight) // Hard cut: cover cannot appear below this line. Rectangle() .fill(AppColors.backgroundLight) .frame(height: max(0, topSectionHeight - coverVisibleUntilY)) .offset(y: coverVisibleUntilY) summaryCard .padding(.horizontal, 16) .padding(.top, cardTopInset) storeLogoBadge .padding(.top, cardTopInset - (storeLogoSize / 2)) } .frame(height: topSectionHeight) } var heroSection: some View { ZStack(alignment: .top) { AsyncStoreImage(imageURL: resolvedURL(storeCoverURL)) .frame(height: topSectionHeight) .overlay( LinearGradient( colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], startPoint: .top, endPoint: .bottom ) ) .ignoresSafeArea(.container, edges: .top) VStack(spacing: 0) { HStack { heroIconButton(icon: "chevron.left") { dismiss() } Spacer() heroIconButton(icon: "magnifyingglass") {} heroIconButton( icon: isFavoriteStore ? "heart.fill" : "heart", foregroundStyle: isFavoriteStore ? Color.red : Color.white ) { Task { await toggleFavoriteStore() } } } .padding(.horizontal, 14) .padding(.top, UIDevice.topNotch) Spacer() Text("RESTAURANT") .font(AppTypography.overline) .tracking(1.8) .foregroundStyle(Color.white.opacity(0.92)) .padding(.bottom, 14) } } } var storeLogoBadge: some View { ZStack { Circle() .fill(AppColors.surface) .frame(width: storeLogoSize, height: storeLogoSize) .overlay( Circle() .stroke(Color.white, lineWidth: 0.1) ) AsyncStoreImage(imageURL: resolvedURL(storeLogoURL)) .frame(width: storeLogoSize - 10, height: storeLogoSize - 10) .clipShape(Circle()) } .shadow(color: Color.black.opacity(0.10), radius: 8, y: 3) } var summaryCard: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text(storeName) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .padding(.top, 30) Text(storeSubtitle) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(1) } Spacer() ratingChip } HStack(spacing: 0) { statItem(title: "DISTÂNCIA", value: distanceValueLabel) Divider().frame(height: 34) if let deliveryTime = info?.deliveryTime { statItem(title: "TEMPO MIN.", value: deliveryTime+" min.") } else { statItem(title: "TEMPO", value: "--") } Divider().frame(height: 34) statItem(title: "PED. MIN.", value: deliveryValueLabel) } .padding(.vertical, 4) } .padding(16) if isStoreOpen == false { Text(closedStoreBannerText) .font(AppTypography.heading3) .foregroundStyle(Color.white) .frame(maxWidth: .infinity, minHeight: closedBannerHeight) .background(AppColors.brandDark) } } .frame(height: summaryCardHeight) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) } @ViewBuilder var sectionedProducts: some View { if isLoading { VStack(spacing: 10) { ProgressView() Text("Carregando cardápio...") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } .frame(maxWidth: .infinity, minHeight: 280, alignment: .center) } else if let errorMessage { VStack(alignment: .leading, spacing: 10) { Text(errorMessage) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Button("Tentar novamente") { Task { await loadStoreData() } } .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) } .padding(.top, 16) } else if categories.isEmpty { Text("Cardápio indisponível no momento.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(.top, 16) .padding(.horizontal, 16) .padding(.bottom, 120) } else { ForEach(categories, id: \.id) { category in Text(category.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) .id(sectionAnchorId(for: category.id)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 8) .background(AppColors.backgroundLight) .background( GeometryReader { geometry in Color.clear.preference( key: CategoryHeaderOffsetPreferenceKey.self, value: [category.id: geometry.frame(in: .global).minY] ) } ) VStack(spacing: 12) { ForEach(listItems(for: category)) { item in productCard(item, in: category) } } .padding(.horizontal, 16) .padding(.bottom, 8) } Color.clear.frame(height: 140) } } func categoryTabs(proxy: ScrollViewProxy, isPinned: Bool = false) -> some View { let safeTop: CGFloat = { guard isPinned else { return 0 } #if canImport(UIKit) return UIDevice.appSafeAreaTop #else return 0 #endif }() return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(categories, id: \.id) { category in let active = selectedCategoryId == category.id Button { selectedCategoryId = category.id isProgrammaticCategoryScroll = true withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { isProgrammaticCategoryScroll = false } } label: { Text(category.name) .font(AppTypography.heading3) .foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted) .padding(.horizontal, 16) .padding(.vertical, 9) .background(active ? AppColors.primary : AppColors.surface) .clipShape(Capsule()) } .buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.vertical, 10) } .padding(.top, safeTop) .background(AppColors.backgroundLight) .animation(.easeInOut(duration: 0.15), value: isPinned) } func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View { let product = item.product let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false } return HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text(item.title) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) if let description = item.description, description.isEmpty == false { Text(description) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(2) .multilineTextAlignment(.leading) } Text(listPriceLabel(for: product, in: category)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } Spacer() ZStack(alignment: .bottomTrailing) { AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) .frame(width: 92, height: 92) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { requestOpenPizzaSheet(categoryId: pizzaCategoryId) return } if hasSelectableAddons == false { let basePrice = product.price ?? 0 let item = CartItemState( id: "\(storeId)::\(product.id)::base", productId: product.id, storeId: storeId, name: product.name, imageURL: resolvedURL(product.image), quantity: 1, unitPrice: basePrice ) requestAddToCart(item) } else { requestOpenProductSheet(product) } } label: { ZStack(alignment: .leading) { Image(systemName: "plus") .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 30, height: 30) .background(AppColors.tertiary) .clipShape(Circle()) let qty = quantityInCart(for: item) if qty > 0 { Text("\(qty)") .font(.system(size: 10, weight: .bold)) .foregroundStyle(Color.white) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Color.red) .clipShape(Capsule()) .offset(x: -6, y: -10) .zIndex(1) } } .offset(x: 3, y: -3) .frame(width: 30, height: 30) .appContentShape(Circle()) } .buttonStyle(.plain) .frame(width: 30, height: 30) .disabled(isStoreOpen == false) .opacity(isStoreOpen ? 1 : 0.65) .offset(x: 7, y: 7) } } .padding(12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .onTapGesture { guard isStoreOpen else { SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) return } if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { requestOpenPizzaSheet(categoryId: pizzaCategoryId) return } guard hasSelectableAddons else { return } requestOpenProductSheet(product) } } }