import SwiftUI extension CheckoutView { enum CheckoutPayloadValidationError: LocalizedError { case emptyCart case missingCustomerName case missingCustomerEmail case missingCustomerPhone case missingAddressStreet case missingAddressNumber case missingAddressNeighborhood var errorDescription: String? { switch self { case .emptyCart: return "Carrinho vazio." case .missingCustomerName: return "Nome do cliente não informado." case .missingCustomerEmail: return "Email do cliente não informado." case .missingCustomerPhone: return "Telefone do cliente não informado." case .missingAddressStreet: return "Rua do endereço não informada." case .missingAddressNumber: return "Número do endereço não informado." case .missingAddressNeighborhood: return "Bairro do endereço não informado." } } } var checkoutAddressWatchKey: String { 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 "\(selectedId)|\(display)|\(lat)|\(lng)" } func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } @MainActor func updateMinOrderSnackbar() { guard isBelowMinOrder else { SnackbarCenter.shared.dismissPersistent() return } let missing = formatCurrency(minOrderValue - totalValue) SnackbarCenter.shared.show( title: "Pedido mínimo de \(formatCurrency(minOrderValue)). Faltam \(missing) para finalizar.", style: .warning, icon: "exclamationmark.circle.fill", isPersistent: true ) } @MainActor func loadStoreInfoIfNeeded() async { guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } do { let response = try await ApiService().storeInfo(storeId: storeId) if response.error { errorMessage = response.message ?? "Não foi possível carregar opções de checkout." return } storeInfo = response.result if showPaymentModeToggle == false { useInAppPayment = true } errorMessage = nil } catch { errorMessage = "Não foi possível carregar opções de checkout." } } @MainActor func refreshSelectedCustomerAddress() async { do { let response = try await ApiService().profile(forceRefresh: true) if let customer = response.result { appState.profile.id = customer.id appState.profile.name = customer.name appState.profile.email = customer.email if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false { appState.profile.phone = phoneNumber } appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) } let addresses = response.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 ordered first, an id-less // address falls through to addresses.first and never actually // "changes" even though delivery to it is allowed. 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 delivery validation below runs against // the wrong coordinates and can wrongly report the address // as not served, reverting the user's pick. 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) } } catch { selectedCustomerAddress = nil } } @MainActor func validateDeliveryAddressIfNeeded() async { guard isDeliveryMode else { addressValidationBlocked = false addressValidationMessage = nil baseDeliveryFee = nil return } guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } baseDeliveryFee = nil // 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 before validating. if appState.address.latitude == nil || appState.address.longitude == 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 ) { 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: appState.address.latitude, lng: appState.address.longitude ) ) isValidatingAddress = true defer { isValidatingAddress = false } do { let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) if response.error { addressValidationBlocked = true addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega." baseDeliveryFee = nil return } let result = response.result let allowed = result?.deliveryAllowed ?? false addressValidationBlocked = allowed == false addressValidationMessage = result?.reasonMessage if allowed { lastAcceptedAddressState = appState.address } else { showAddressNotServedAlert = true baseDeliveryFee = nil } if allowed { if let fee = result?.deliveryFee { baseDeliveryFee = fee } else { addressValidationBlocked = true addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço." baseDeliveryFee = nil } } } catch { addressValidationBlocked = true addressValidationMessage = "Não foi possível validar o endereço de entrega." baseDeliveryFee = nil } } func normalizeSelectedOptions() { if availableDeliveryTypes.contains(deliveryType) == false, let first = availableDeliveryTypes.first { deliveryType = first } if useInAppPayment { if availableInAppPaymentMethods.contains(paymentMethod) == false, let first = availableInAppPaymentMethods.first { paymentMethod = first } } else { if availableStoreMachineMethods.contains(paymentMethod) == false, let first = availableStoreMachineMethods.first { paymentMethod = first } } if lastAcceptedAddressState == nil { lastAcceptedAddressState = appState.address } } func restoreLastAcceptedAddress() { guard let snapshot = lastAcceptedAddressState else { return } isRestoringAddress = true appState.address = snapshot SessionStateStore.saveAddress(snapshot) Task { @MainActor in await refreshSelectedCustomerAddress() addressValidationBlocked = false addressValidationMessage = nil isRestoringAddress = false } } @MainActor func handleConfirmPaymentTap() async { guard canConfirmPayment else { return } guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0) return } await refreshSelectedCustomerAddress() let effectivePaymentMethod = paymentMethod if useInAppPayment && availableInAppPaymentMethods.contains(effectivePaymentMethod) == false { SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0) return } // Crédito pelo app → abre seleção de cartão antes de criar pedido if useInAppPayment && effectivePaymentMethod == .creditCard { showCardSelectionSheet = true return } let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil) guard case .success(let payload) = payloadBuildResult else { let message: String if case .failure(let reason) = payloadBuildResult { message = reason.localizedDescription } else { message = "Dados do pedido incompletos." } SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0) return } isSubmittingOrder = true defer { isSubmittingOrder = false } do { let response = try await ApiService().createOrder(storeId: storeId, payload: payload) let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) if useInAppPayment == false || isInAppMethod == false { if response.error == false, let result = response.result { let orderId = result.id ?? UUID().uuidString appState.cart.clear() SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) } else { SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } return } handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod) } catch { SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } func buildCreateOrderPayload( paymentMethod: CheckoutPaymentMethod, savedCardId: String? = nil, creditCard: CreditCardOrderPayload? = nil, creditCardHolderInfo: SaveCardHolderInfoPayload? = nil, clientCpfCnpj: String? = nil ) -> Result { guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines) let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines) guard profileName.isEmpty == false else { return .failure(.missingCustomerName) } guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) } guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) } let addressPayload: CreateOrderAddressPayload? if isDeliveryMode { let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard street.isEmpty == false else { return .failure(.missingAddressStreet) } guard number.isEmpty == false else { return .failure(.missingAddressNumber) } guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) } addressPayload = CreateOrderAddressPayload( street: street, number: number, neighborhood: neighborhood, city: selectedCustomerAddress?.city, state: selectedCustomerAddress?.state, zip: selectedCustomerAddress?.zipCode, complement: selectedCustomerAddress?.complement ) } else { addressPayload = nil } return .success( CreateOrderPayload( customer: CreateOrderCustomerPayload( name: profileName, phone: profilePhone, email: profileEmail, asaasId: nil ), items: appState.cart.toOrderItemsPayload(), total: totalValue, paymentMethod: paymentMethod.rawValue, deliveryType: deliveryType.rawValue, address: addressPayload, savedCardId: savedCardId, clientCpfCnpj: clientCpfCnpj, creditCard: creditCard, creditCardHolderInfo: creditCardHolderInfo ) ) } @MainActor func confirmOrderWithSavedCard(cardId: String, storeId: String) async { let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId) guard case .success(let payload) = payloadResult else { return } isSubmittingOrder = true defer { isSubmittingOrder = false } do { let response = try await ApiService().createOrder(storeId: storeId, payload: payload) handleOrderResponse(response, effectivePaymentMethod: .creditCard) } catch { SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } func handleOrderResponse(_ response: ApiEnvelope, effectivePaymentMethod: CheckoutPaymentMethod) { if response.error { SnackbarCenter.shared.show( title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0 ) return } guard let result = response.result else { SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) return } let orderSnapshot = result.asPublicOrderResult() SessionStateStore.saveTrackedOrder(orderSnapshot) let orderId = result.id ?? UUID().uuidString if orderSnapshot.isPaymentConfirmed { appState.cart.clear() SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) return } SessionStateStore.savePendingCartOrderId(orderId) let pixFromPayment = result.payment?.pix let pixFromPayload = result.paymentPayload let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" let storeId = appState.cart.storeId ?? "" pixPaymentContext = PixPaymentContext( id: orderId, orderId: orderId, shortId: result.shortId, storeId: storeId, copyPaste: copyPaste, qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate, total: totalValue, profileName: appState.profile.name, profileEmail: appState.profile.email, profilePhone: appState.profile.phone, addressZip: selectedCustomerAddress?.zipCode, addressNumber: selectedCustomerAddress?.number, deliveryType: deliveryType.rawValue, itemsJSON: itemsData ) return } orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) } }