import Foundation import SwiftUI struct CartView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab @Binding var root: RootFlow @State var openCheckout = false @State var couponCode = "" @State var appliedCouponCode: String? = nil @State var deliveryFee: Double? = nil @State var selectedCustomerAddress: CustomerAddress? = nil @State var isLoadingDeliveryFee = false var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 16) { Text("Meu Carrinho") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 20) if appState.cart.items.isEmpty { VStack(spacing: 10) { Text("Seu carrinho está vazio") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text("Adicione produtos para continuar.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } .frame(maxWidth: .infinity, minHeight: 280, alignment: .center) } else { VStack(spacing: 12) { ForEach(appState.cart.items) { item in cartItemRow(item) } } .padding(.horizontal, 20) // couponSection // .padding(.horizontal, 20) summarySection .padding(.horizontal, 20) } } .padding(.bottom, 120) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) .navigationDestination(isPresented: $openCheckout) { CheckoutView(appState: $appState, selectedTab: $selectedTab) } .task(id: deliveryFeeWatchKey) { await refreshDeliveryFee() } } private var subtotalValue: Double { appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } } private var totalValue: Double { max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue) } private var effectiveDiscountValue: Double { let normalizedCoupon = (appliedCouponCode ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() if normalizedCoupon == "DESCONTO10" { return min(subtotalValue, subtotalValue * 0.1) } return 0 } private var discountLabelValue: String { if effectiveDiscountValue <= 0.0001 { return formatCurrency(0) } return "-\(formatCurrency(effectiveDiscountValue))" } private var deliveryFeeWatchKey: String { let storeId = appState.cart.storeId ?? "nil" let selectedId = appState.address.selectedId ?? "nil" let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)" } private var couponSection: some View { VStack(alignment: .leading, spacing: 10) { Text("Cupom de Desconto") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) HStack(spacing: 10) { HStack(spacing: 8) { Image(systemName: "ticket") .foregroundStyle(AppColors.textMuted) TextField("Inserir cupom", text: $couponCode) .appNoAutoCap() } .padding(.horizontal, 12) .frame(height: 50) .background(AppColors.surface) .overlay( RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .stroke(AppColors.brandSoft, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button("Aplicar") { applyCoupon() } .font(AppTypography.heading3) .foregroundStyle(AppColors.textInverse) .frame(width: 120, height: 50) .background(AppColors.brandDark) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .buttonStyle(.plain) } if let appliedCouponCode { Text("Cupom aplicado: \(appliedCouponCode)") .font(.caption) .foregroundStyle(AppColors.primary) } } } private var summarySection: some View { VStack(alignment: .leading, spacing: 12) { Text("Resumo de Valores") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue)) summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel) summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted) Divider() summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) Button { guard appState.session.isAuthenticated else { root = .auth return } openCheckout = true } label: { HStack(spacing: 10) { Text("Ir para o Pagamento") .font(AppTypography.heading2) Image(systemName: "arrow.right") .font(.system(size: 18, weight: .bold)) } .foregroundStyle(AppColors.textInverse) .frame(maxWidth: .infinity, minHeight: 54) .background(AppColors.primary) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain) } .padding(16) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) } private var deliveryFeeLabel: String { if isLoadingDeliveryFee { return "Calculando..." } if let deliveryFee { return formatCurrency(deliveryFee) } return "Indisponível" } private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View { HStack { Text(title) .font(highlighted ? AppTypography.heading2 : AppTypography.body) .foregroundStyle(AppColors.textMuted) Spacer() Text(value) .font(highlighted ? AppTypography.heading1 : AppTypography.heading3) .foregroundStyle(valueColor ?? AppColors.textPrimary) } } private func cartItemRow(_ item: CartItemState) -> some View { HStack(spacing: 14) { AsyncStoreImage(imageURL: item.imageURL) .frame(width: 78, height: 78) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) VStack(alignment: .leading, spacing: 6) { Text(item.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .lineLimit(1) if let details = item.details, details.isEmpty == false { Text(details) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(2) } Text(formatCurrency(item.unitPrice)) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } Spacer() HStack(spacing: 10) { Button(action: { appState.cart.decrement(itemId: item.id) }) { Image(systemName: "minus") .font(.system(size: 13, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 32, height: 32) .background(AppColors.backgroundLight) .clipShape(Circle()) } .buttonStyle(.plain) Text("\(item.quantity)") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .frame(minWidth: 18) Button(action: { appState.cart.increment(itemId: item.id) }) { Image(systemName: "plus") .font(.system(size: 13, weight: .bold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 32, height: 32) .background(AppColors.tertiary) .clipShape(Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 8) .padding(.vertical, 6) .background(AppColors.backgroundLight) .clipShape(Capsule()) } .padding(.horizontal, 12) .padding(.vertical, 12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private func applyCoupon() { let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard normalized.isEmpty == false else { appliedCouponCode = nil return } if normalized == "DESCONTO10" { appliedCouponCode = normalized return } appliedCouponCode = nil } private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } @MainActor private func refreshDeliveryFee() async { guard appState.cart.items.isEmpty == false else { deliveryFee = nil selectedCustomerAddress = nil return } guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { deliveryFee = nil selectedCustomerAddress = nil return } isLoadingDeliveryFee = true defer { isLoadingDeliveryFee = false } do { let profileResponse = try await ApiService().profile(forceRefresh: true) let addresses = profileResponse.result?.addressBook ?? [] if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) } else { selectedCustomerAddress = nil } // id can be nil for some address book entries — lat/lng is set // immediately and reliably at selection time (AddressesView. // selectAddress), so it's a stronger signal than the label match // below, which silently collides whenever two addresses share an // empty/duplicate label. Without this, an id-less address falls // through to addresses.first and never actually "changes". if selectedCustomerAddress == nil, let lat = appState.address.latitude, let lng = appState.address.longitude { selectedCustomerAddress = addresses.first { address in guard let addrLat = address.latLong?.first, let addrLng = address.latLong?.dropFirst().first else { return false } return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001 } } if selectedCustomerAddress == nil { let display = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() if display.isEmpty == false, display != "defina seu endereco" { selectedCustomerAddress = addresses.first { ($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display } } } if selectedCustomerAddress == nil { selectedCustomerAddress = addresses.first } if let selected = selectedCustomerAddress { // appState.address reflects the address the user just picked — // authoritative. Only fill in gaps from the address book here, // never overwrite a live selection with a (possibly stale) // cached record, or the fee/validation payload below can end // up built against the wrong coordinates. if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true { appState.address.selectedId = selected.id } if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" { let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if label.isEmpty == false { appState.address.display = label } } if appState.address.latitude == nil || appState.address.longitude == nil, let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { appState.address.latitude = lat appState.address.longitude = lng } SessionStateStore.saveAddress(appState.address) } var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first // Some saved addresses have no lat/long (CEP lookup at creation // time didn't return coordinates). Without coordinates the // backend can't tell this address apart from any other, so the // fee silently never changes. Geocode locally as a fallback. if payloadLat == nil || payloadLng == nil { if let coordinate = await LocationService.geocodeAddress( street: selectedCustomerAddress?.address, number: selectedCustomerAddress?.number, neighborhood: selectedCustomerAddress?.neighborhood, city: selectedCustomerAddress?.city, state: selectedCustomerAddress?.state, zip: selectedCustomerAddress?.zipCode ) { payloadLat = coordinate.0 payloadLng = coordinate.1 appState.address.latitude = coordinate.0 appState.address.longitude = coordinate.1 SessionStateStore.saveAddress(appState.address) } } let payload = ValidateDeliveryAddressPayload( address: ValidateDeliveryAddressDataPayload( street: selectedCustomerAddress?.address, number: selectedCustomerAddress?.number, neighborhood: selectedCustomerAddress?.neighborhood, city: selectedCustomerAddress?.city, state: selectedCustomerAddress?.state, zip: selectedCustomerAddress?.zipCode, lat: payloadLat, lng: payloadLng ) ) let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) guard validationResponse.error == false, validationResponse.result?.deliveryAllowed == true, let fee = validationResponse.result?.deliveryFee else { deliveryFee = nil return } deliveryFee = fee } catch { deliveryFee = nil } } }