[public-store-distance] Consume the public distance contract, stop labelling it as unavailable
Guest browsing showed 'Distância indisponível' on every store card and a '--' tile on store detail, because the public locator response carries no distance and StoreSummary.init(publicItem:) hardcoded nil. Backend contract (docs/plans/public-store-distance-consumer.md): distance is a number, never null, 0 means unavailable. Implemented app-side ahead of the backend - every change is forward-compatible, and the part that removes the broken label works with no backend at all. - PublicStoreListItem / PublicStoreDetail: + distance (optional, so today's responses without the field still decode). - StoreSummary.init(publicItem:): normalize the 0 sentinel to nil, so one representation of 'unknown' reaches the label and the max-distance filter. - fetchStoreDetail: send state/city from GuestLocationStore by default - the server needs them to resolve the city centroid. - formatDistance: empty string for nil/0/negative. It previously returned 'Distância indisponível' for nil, and - found by the new test - '0 m' for 0 and '-1000 m' for a negative. - StoreCard: drop the distance segment and its '·' separator together, otherwise the row ended in a dangling separator. - StoreDetailView: drop the DISTÂNCIA tile and its divider instead of '--'. Also fixes the same label in the authenticated flow when the user declined location and has no address coordinates. Tests: PublicStoreDistanceTests (decode with/without the field, passthrough, 0 normalization) + the empty case in HomeViewFilteringTests.
This commit is contained in:
@@ -53,12 +53,17 @@ struct FeaturedStoreCard: View {
|
||||
Text("(\(store.reviews))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
// Empty distance means "unavailable" - drop the separator
|
||||
// with it, otherwise the row ends in a dangling "·".
|
||||
// See docs/plans/public-store-distance-consumer.md.
|
||||
if store.distance.isEmpty == false {
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
|
||||
|
||||
@@ -28,6 +28,12 @@ struct PublicStoreListItem: Decodable, Sendable, Identifiable {
|
||||
let deliveryTime: String?
|
||||
let deliveryFee: Double?
|
||||
let minOrder: Double?
|
||||
/// Kilometres from the picked city's centroid to the store - always
|
||||
/// approximate, since the origin is the city rather than the visitor.
|
||||
/// The server sends `0` for "unavailable" and never null; optional here
|
||||
/// only so the app keeps decoding responses from before the field
|
||||
/// shipped. See docs/plans/public-store-distance-consumer.md.
|
||||
let distance: Double?
|
||||
}
|
||||
|
||||
struct PublicStoreDetail: Decodable, Sendable {
|
||||
@@ -56,6 +62,9 @@ struct PublicStoreDetail: Decodable, Sendable {
|
||||
let deliveryPrice: Double?
|
||||
let minOrder: Double?
|
||||
let acceptPix: Bool?
|
||||
/// Same contract as `PublicStoreListItem.distance`, but only populated
|
||||
/// when the detail request carries `state`/`city`.
|
||||
let distance: Double?
|
||||
}
|
||||
|
||||
// Maps the public (anonymous) store-detail projection onto the same models
|
||||
|
||||
@@ -34,13 +34,31 @@ final class PublicLocationService: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// No guest token — this route is fully public/unauthenticated per the doc.
|
||||
func fetchStoreDetail(identifier: String) async throws -> PublicStoreDetail {
|
||||
///
|
||||
/// `state`/`city` are optional on the server and are what let it resolve
|
||||
/// the city centroid the store `distance` is measured from; without them
|
||||
/// the response carries `distance: 0` (see
|
||||
/// docs/plans/public-store-distance-consumer.md). Defaults to whatever the
|
||||
/// visitor picked in "ENTREGAR EM:".
|
||||
func fetchStoreDetail(
|
||||
identifier: String,
|
||||
state: String? = GuestLocationStore.shared.selectedState,
|
||||
city: String? = GuestLocationStore.shared.selectedCity
|
||||
) async throws -> PublicStoreDetail {
|
||||
let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier
|
||||
var query: [URLQueryItem] = []
|
||||
if let state, state.isEmpty == false {
|
||||
query.append(URLQueryItem(name: "state", value: state))
|
||||
}
|
||||
if let city, city.isEmpty == false {
|
||||
query.append(URLQueryItem(name: "city", value: city))
|
||||
}
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/store/\(encodedIdentifier)",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false,
|
||||
queryItems: query,
|
||||
baseURLOverride: ApiConfig.pediFoodsBFFURL
|
||||
)
|
||||
let envelope: ApiEnvelope<PublicStoreDetail> = try await client.send(req)
|
||||
|
||||
@@ -177,7 +177,7 @@ extension HomeView {
|
||||
|
||||
extension StoreSummary {
|
||||
/// Maps the public-locator DTO onto the same model HomeView already
|
||||
/// renders — distance/positiveReviews don't exist in that response.
|
||||
/// renders — positiveReviews doesn't exist in that response.
|
||||
init(publicItem: PublicStoreListItem) {
|
||||
self.id = publicItem.id
|
||||
self.name = publicItem.name ?? "Loja"
|
||||
@@ -189,7 +189,10 @@ extension StoreSummary {
|
||||
self.positiveReviews = nil
|
||||
self.deliveryTime = publicItem.deliveryTime
|
||||
self.deliveryFee = publicItem.deliveryFee
|
||||
self.distance = nil
|
||||
// The contract uses `0` for "no distance available"; normalize it to
|
||||
// nil here so every downstream consumer (card label, max-distance
|
||||
// filter) sees one representation of "unknown".
|
||||
self.distance = (publicItem.distance ?? 0) > 0 ? publicItem.distance : nil
|
||||
self.isOpen = publicItem.isOpen
|
||||
self.statusLabel = publicItem.statusLabel
|
||||
}
|
||||
|
||||
@@ -82,8 +82,13 @@ extension HomeView {
|
||||
resolveStoreMediaURL(appState.profile.profilePicture)
|
||||
}
|
||||
|
||||
/// Empty string means "no distance to show" - the caller omits the whole
|
||||
/// segment rather than labelling it. Per the API contract
|
||||
/// (docs/plans/public-store-distance-consumer.md) the server never sends
|
||||
/// null and uses `0` for "unavailable", so both that and a missing value
|
||||
/// collapse to the same empty result.
|
||||
func formatDistance(_ distance: Double?) -> String {
|
||||
guard let distance else { return "Distância indisponível" }
|
||||
guard let distance, distance > 0 else { return "" }
|
||||
if distance >= 1 {
|
||||
return String(format: "%.1f km", distance)
|
||||
}
|
||||
|
||||
@@ -109,8 +109,13 @@ extension StoreDetailView {
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||
Divider().frame(height: 34)
|
||||
// No distance available: drop the tile and its divider
|
||||
// instead of showing a placeholder - see
|
||||
// docs/plans/public-store-distance-consumer.md.
|
||||
if hasStoreDistance {
|
||||
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||
Divider().frame(height: 34)
|
||||
}
|
||||
if let deliveryTime = info?.deliveryTime {
|
||||
statItem(title: "TEMPO MIN.", value: deliveryTime+" min.")
|
||||
} else {
|
||||
|
||||
@@ -57,12 +57,14 @@ extension StoreDetailView {
|
||||
return "R$ --"
|
||||
}
|
||||
|
||||
/// Already formatted upstream by `HomeView.formatDistance`, which returns
|
||||
/// an empty string when there's no distance to show.
|
||||
var distanceValueLabel: String {
|
||||
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty {
|
||||
return "--"
|
||||
}
|
||||
return raw
|
||||
(storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var hasStoreDistance: Bool {
|
||||
distanceValueLabel.isEmpty == false
|
||||
}
|
||||
|
||||
var isStoreOpen: Bool {
|
||||
|
||||
Reference in New Issue
Block a user