This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,432 @@
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()
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 ?? ""
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
}
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,
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 {
selectedCustomerAddress = addresses.first
}
if let selected = selectedCustomerAddress {
appState.address.selectedId = selected.id
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
if 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
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<CreateOrderPayload, CheckoutPayloadValidationError> {
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<CreateOrderResult>, 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)
}
}