Merge pull request 'Fix cart/checkout delivery fee and address sync' (#23) from fix/cart/delivery-fee-address-sync into main
This commit is contained in:
@@ -75,6 +75,39 @@ final class LocationService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward-geocodes a street address into coordinates. Used as a fallback
|
||||||
|
/// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at
|
||||||
|
/// creation time didn't return coordinates) — without this, delivery fee
|
||||||
|
/// validation silently can't distinguish that address from any other.
|
||||||
|
static func geocodeAddress(
|
||||||
|
street: String?,
|
||||||
|
number: String?,
|
||||||
|
neighborhood: String?,
|
||||||
|
city: String?,
|
||||||
|
state: String?,
|
||||||
|
zip: String?
|
||||||
|
) async -> (Double, Double)? {
|
||||||
|
#if os(iOS)
|
||||||
|
let parts = [street, number, neighborhood, city, state, zip]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { $0.isEmpty == false }
|
||||||
|
guard parts.isEmpty == false else { return nil }
|
||||||
|
let fullAddress = parts.joined(separator: ", ")
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in
|
||||||
|
guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else {
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: (coordinate.latitude, coordinate.longitude))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
|
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
var hasResumed = false
|
var hasResumed = false
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ struct CartView: View {
|
|||||||
defer { isLoadingDeliveryFee = false }
|
defer { isLoadingDeliveryFee = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let profileResponse = try await ApiService().profile()
|
let profileResponse = try await ApiService().profile(forceRefresh: true)
|
||||||
let addresses = profileResponse.result?.addressBook ?? []
|
let addresses = profileResponse.result?.addressBook ?? []
|
||||||
|
|
||||||
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||||
@@ -296,6 +296,21 @@ struct CartView: View {
|
|||||||
selectedCustomerAddress = nil
|
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 {
|
if selectedCustomerAddress == nil {
|
||||||
let display = appState.address.display
|
let display = appState.address.display
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -312,20 +327,51 @@ struct CartView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let selected = selectedCustomerAddress {
|
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
|
appState.address.selectedId = selected.id
|
||||||
|
}
|
||||||
|
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
|
||||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if label.isEmpty == false {
|
if label.isEmpty == false {
|
||||||
appState.address.display = label
|
appState.address.display = label
|
||||||
}
|
}
|
||||||
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
}
|
||||||
|
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.latitude = lat
|
||||||
appState.address.longitude = lng
|
appState.address.longitude = lng
|
||||||
}
|
}
|
||||||
SessionStateStore.saveAddress(appState.address)
|
SessionStateStore.saveAddress(appState.address)
|
||||||
}
|
}
|
||||||
|
|
||||||
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
|
var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first
|
||||||
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
|
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(
|
let payload = ValidateDeliveryAddressPayload(
|
||||||
address: ValidateDeliveryAddressDataPayload(
|
address: ValidateDeliveryAddressDataPayload(
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ extension CheckoutView {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func refreshSelectedCustomerAddress() async {
|
func refreshSelectedCustomerAddress() async {
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().profile()
|
let response = try await ApiService().profile(forceRefresh: true)
|
||||||
if let customer = response.result {
|
if let customer = response.result {
|
||||||
appState.profile.id = customer.id
|
appState.profile.id = customer.id
|
||||||
appState.profile.name = customer.name
|
appState.profile.name = customer.name
|
||||||
@@ -92,6 +92,22 @@ extension CheckoutView {
|
|||||||
selectedCustomerAddress = nil
|
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 {
|
if selectedCustomerAddress == nil {
|
||||||
let display = appState.address.display
|
let display = appState.address.display
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -103,26 +119,28 @@ extension CheckoutView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if selectedCustomerAddress == nil {
|
||||||
selectedCustomerAddress = addresses.first
|
selectedCustomerAddress = addresses.first
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selected = selectedCustomerAddress {
|
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
|
appState.address.selectedId = selected.id
|
||||||
|
}
|
||||||
|
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
|
||||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if label.isEmpty == false {
|
if label.isEmpty == false {
|
||||||
appState.address.display = label
|
appState.address.display = label
|
||||||
}
|
}
|
||||||
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
}
|
||||||
|
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.latitude = lat
|
||||||
appState.address.longitude = lng
|
appState.address.longitude = lng
|
||||||
}
|
}
|
||||||
@@ -146,6 +164,25 @@ extension CheckoutView {
|
|||||||
|
|
||||||
baseDeliveryFee = nil
|
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(
|
let payload = ValidateDeliveryAddressPayload(
|
||||||
address: ValidateDeliveryAddressDataPayload(
|
address: ValidateDeliveryAddressDataPayload(
|
||||||
street: selectedCustomerAddress?.address,
|
street: selectedCustomerAddress?.address,
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ struct CheckoutView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(isDeliveryMode ? "Casa" : (appState.cart.storeName ?? "Loja"))
|
Text(isDeliveryMode ? customerAddressName : (appState.cart.storeName ?? "Loja"))
|
||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel)
|
Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel)
|
||||||
@@ -557,6 +557,23 @@ struct CheckoutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var customerAddressName: String {
|
||||||
|
// appState.address.display is set directly, synchronously, the
|
||||||
|
// moment the user picks an address (AddressesView.selectAddress) —
|
||||||
|
// it's live. selectedCustomerAddress is resolved later via an async
|
||||||
|
// matching cascade against the address book and can briefly (or,
|
||||||
|
// if matching goes wrong, persistently) lag behind or resolve to
|
||||||
|
// the wrong entry. Prefer the live value; the async one is only a
|
||||||
|
// fallback for when nothing's been picked yet this session.
|
||||||
|
let displayLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if displayLabel.isEmpty == false, displayLabel != "Defina seu endereco" { return displayLabel }
|
||||||
|
|
||||||
|
let label = (selectedCustomerAddress?.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if label.isEmpty == false { return label }
|
||||||
|
|
||||||
|
return "Endereço"
|
||||||
|
}
|
||||||
|
|
||||||
private var customerAddressLabel: String {
|
private var customerAddressLabel: String {
|
||||||
if let address = selectedCustomerAddress {
|
if let address = selectedCustomerAddress {
|
||||||
let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
Reference in New Issue
Block a user