This commit is contained in:
Daniel Arantes Loverde
2026-03-19 08:44:31 -03:00
parent 5fa98ce358
commit c49725a5f7
16 changed files with 1496 additions and 147 deletions

View File

@@ -6,7 +6,6 @@ struct CartView: View {
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@State var discountValue: Double = 0
@State var deliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var isLoadingDeliveryFee = false
@@ -38,8 +37,8 @@ struct CartView: View {
}
.padding(.horizontal, 20)
couponSection
.padding(.horizontal, 20)
// couponSection
// .padding(.horizontal, 20)
summarySection
.padding(.horizontal, 20)
@@ -62,7 +61,25 @@ struct CartView: View {
}
private var totalValue: Double {
max(0, subtotalValue + (deliveryFee ?? 0) - discountValue)
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 {
@@ -123,7 +140,7 @@ struct CartView: View {
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted)
Divider()
@@ -237,18 +254,15 @@ struct CartView: View {
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard normalized.isEmpty == false else {
appliedCouponCode = nil
discountValue = 0
return
}
if normalized == "DESCONTO10" {
appliedCouponCode = normalized
discountValue = min(subtotalValue, subtotalValue * 0.1)
return
}
appliedCouponCode = nil
discountValue = 0
}
private func formatCurrency(_ value: Double) -> String {

View File

@@ -0,0 +1,235 @@
import SwiftUI
struct FiltersModalView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var draftFilters: HomeFiltersState
init(appState: Binding<AppState>) {
_appState = appState
_draftFilters = State(initialValue: appState.wrappedValue.homeFilters)
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 28) {
sortSection
categoriesSection
priceSection
distanceSection
}
.padding(.horizontal, 24)
.padding(.top, 22)
.padding(.bottom, 120)
}
applyButton
.padding(.horizontal, 24)
.padding(.vertical, 18)
.background(AppColors.backgroundLight)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
}
var header: some View {
HStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
Spacer()
Text("Filtros")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Button("Limpar") {
draftFilters.reset()
draftFilters.availableCategories = appState.homeFilters.availableCategories
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.secondary)
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.top, 14)
.padding(.bottom, 12)
.overlay(alignment: .bottom) {
Divider().overlay(Color.black.opacity(0.08))
}
}
var sortSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Ordenar por")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
VStack(spacing: 10) {
ForEach(HomeSortOption.allCases) { option in
Button {
draftFilters.sortOption = option
} label: {
HStack(spacing: 14) {
Circle()
.fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface)
.frame(width: 44, height: 44)
.overlay(
Image(systemName: option.icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted)
)
Text(option.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Circle()
.stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2)
.frame(width: 28, height: 28)
.overlay(
Circle()
.fill(option == draftFilters.sortOption ? Color.black : Color.clear)
.frame(width: 14, height: 14)
)
}
.padding(.horizontal, 16)
.frame(height: 88)
.background(Color.black.opacity(0.03))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
}
}
}
}
var categoriesSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
HStack(alignment: .center) {
Text("Categorias")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text("Ver todas")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) {
ForEach(draftFilters.availableCategories, id: \.self) { category in
let isSelected = draftFilters.selectedCategories.contains(category)
Button(category) {
if isSelected {
draftFilters.selectedCategories.remove(category)
} else {
draftFilters.selectedCategories.insert(category)
}
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
.overlay(
Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1)
)
.clipShape(Capsule())
.buttonStyle(.plain)
}
}
}
}
var priceSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
Text("Preço")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
HStack(spacing: 12) {
ForEach(HomePriceTier.allCases) { tier in
let isSelected = draftFilters.selectedPriceTier == tier
Button(tier.rawValue) {
draftFilters.selectedPriceTier = isSelected ? nil : tier
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 68)
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.buttonStyle(.plain)
}
}
}
}
var distanceSection: some View {
VStack(alignment: .leading, spacing: 14) {
Divider().overlay(Color.black.opacity(0.08))
HStack {
Text("Distância")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text("Até \(Int(draftFilters.maxDistanceKm))km")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
}
Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1)
.tint(AppColors.tertiary)
HStack {
Text("1km")
Spacer()
Text("10km")
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
}
}
var applyButton: some View {
Button {
appState.homeFilters.sortOption = draftFilters.sortOption
appState.homeFilters.selectedCategories = draftFilters.selectedCategories
appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier
appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm
appState.homeFilters.availableCategories = draftFilters.availableCategories
dismiss()
} label: {
Text("Aplicar Filtros")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity)
.frame(height: 62)
.background(AppColors.tertiary.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,216 @@
import Foundation
extension HomeView {
var filteredStores: [StoreSummary] {
let normalizedQuery = normalizeSearch(searchText)
var list = stores
if appState.homeFilters.selectedCategories.isEmpty == false {
let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch))
list = list.filter { store in
let category = normalizeSearch(store.category ?? "")
return allowed.contains(category)
}
}
if let tier = appState.homeFilters.selectedPriceTier {
list = list.filter { store in
guard let fee = store.deliveryFee else { return false }
return matchesPriceTier(fee: fee, tier: tier)
}
}
let maxDistance = appState.homeFilters.maxDistanceKm
list = list.filter { store in
guard let distance = store.distance else { return true }
return distance <= maxDistance
}
if normalizedQuery.isEmpty == false {
list = list.filter { store in
matchesSearch(store: store, query: normalizedQuery)
}
}
return sortStores(list, query: normalizedQuery)
}
var featuredStoresCards: [FeaturedStoreCardModel] {
Array(filteredStores.prefix(5)).map(mapStoreToCard)
}
var filteredStoreCards: [FeaturedStoreCardModel] {
let featuredIds = Set(filteredStores.prefix(5).map(\.id))
let remaining = filteredStores.filter { featuredIds.contains($0.id) == false }
return Array(remaining.prefix(20)).map(mapStoreToCard)
}
var emptyResultMessage: String {
if normalizeSearch(searchText).isEmpty == false {
return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"."
}
return "Nenhum estabelecimento encontrado com os filtros selecionados."
}
func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
let coverURL = resolveStoreMediaURL(store.cover)
let logoURL = resolveStoreMediaURL(store.logo)
return FeaturedStoreCardModel(
id: store.id,
name: store.name,
rating: store.rating ?? 0,
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
distance: formatDistance(store.distance),
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront",
imageURL: coverURL ?? logoURL,
logoURL: logoURL,
coverURL: coverURL,
isOpen: store.isOpen ?? true,
statusLabel: store.statusLabel
)
}
func resolveStoreMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
var profilePictureURL: String? {
resolveStoreMediaURL(appState.profile.profilePicture)
}
func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
return String(format: "%.1f km", distance)
}
return "\(Int(distance * 1000)) m"
}
func scheduleSearchIndexUpdate() {
searchDebounceToken += 1
let token = searchDebounceToken
Task {
try? await Task.sleep(nanoseconds: 220_000_000)
guard token == searchDebounceToken else { return }
await loadProductIndexForSearchIfNeeded()
}
}
@MainActor
func loadProductIndexForSearchIfNeeded() async {
let query = normalizeSearch(searchText)
guard query.isEmpty == false else { return }
let candidates = filteredStores
.filter { productSearchIndexByStoreId[$0.id] == nil }
.prefix(10)
guard candidates.isEmpty == false else { return }
await withTaskGroup(of: (String, [String]?).self) { group in
for store in candidates {
group.addTask {
do {
let response = try await ApiService().storeCatalog(storeId: store.id)
let products = response.result?.flatMap(\.products) ?? []
let names = products.map(\.name)
return (store.id, names)
} catch {
return (store.id, nil)
}
}
}
for await result in group {
let names = result.1 ?? []
productSearchIndexByStoreId[result.0] = names
}
}
}
func normalizeSearch(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
}
func matchesSearch(store: StoreSummary, query: String) -> Bool {
let storeName = normalizeSearch(store.name)
if storeName.contains(query) {
return true
}
let category = normalizeSearch(store.category ?? "")
if category.contains(query) {
return true
}
let products = productSearchIndexByStoreId[store.id] ?? []
return products.contains { normalizeSearch($0).contains(query) }
}
func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] {
switch appState.homeFilters.sortOption {
case .relevance:
return list.sorted { lhs, rhs in
let lhsScore = relevanceScore(for: lhs, query: query)
let rhsScore = relevanceScore(for: rhs, query: query)
if lhsScore != rhsScore {
return lhsScore > rhsScore
}
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
}
case .rating:
return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) }
case .deliveryTime:
return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) }
case .price:
return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) }
}
}
func relevanceScore(for store: StoreSummary, query: String) -> Double {
guard query.isEmpty == false else {
let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0)
return positive + (store.rating ?? 0) * 10
}
let name = normalizeSearch(store.name)
let category = normalizeSearch(store.category ?? "")
let products = productSearchIndexByStoreId[store.id] ?? []
var score = 0.0
if name.hasPrefix(query) { score += 200 }
if name.contains(query) { score += 120 }
if category.contains(query) { score += 70 }
if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 }
score += (store.rating ?? 0) * 10
score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02
return score
}
func estimatedDeliveryMinutes(_ value: String?) -> Int {
guard let value else { return Int.max }
let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined()
let parts = digits
.split(separator: " ")
.compactMap { Int($0) }
if let min = parts.min() {
return min
}
return Int.max
}
func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool {
switch tier {
case .low: return fee <= 5
case .medium: return fee > 5 && fee <= 10
case .high: return fee > 10 && fee <= 20
case .veryHigh: return fee > 20
}
}
}

View File

@@ -14,12 +14,12 @@ struct HomeView: View {
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
@State var scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0
@State var collapseDragStartOffset: CGFloat? = nil
@State var hasRequestedLocation = false
@State var isLoadingStores = false
@State var storesError: String? = nil
@State var stores: [StoreSummary] = []
@State var productSearchIndexByStoreId: [String: [String]] = [:]
@State var searchDebounceToken = 0
private let specials: [SpecialOfferCardModel] = [
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
@@ -43,28 +43,12 @@ struct HomeView: View {
}
.refreshable {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory,
forceNetworkRefresh: true,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}
.background(scrollOffsetObserver)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let range = headerExpandedHeight - headerCollapsedHeight
if collapseDragStartOffset == nil {
collapseDragStartOffset = collapseBaseOffset
}
let start = collapseDragStartOffset ?? collapseBaseOffset
let candidate = start - value.translation.height
scrollOffset = clamp(value: candidate, lower: 0, upper: range)
}
.onEnded { _ in
collapseBaseOffset = scrollOffset
collapseDragStartOffset = nil
}
)
header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top)
@@ -79,18 +63,24 @@ struct HomeView: View {
await bootstrapStoresFlow(refreshCategories: true)
}
}
collapseBaseOffset = scrollOffset
}
.onChange(of: addressCacheScope) { _, _ in
guard hasRequestedLocation else { return }
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}
}
.onChange(of: searchText) { _, _ in
scheduleSearchIndexUpdate()
}
.onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() }
.onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() }
}
private var contentStack: some View {
@@ -153,7 +143,7 @@ struct HomeView: View {
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory,
category: selectedCategoryQueryValue,
refreshCategories: true
)
}
@@ -162,14 +152,14 @@ struct HomeView: View {
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
} else if nearbyStoreCards.isEmpty {
Text("Nenhum estabelecimento encontrado próximo à sua localização.")
} else if filteredStoreCards.isEmpty {
Text(emptyResultMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 20)
} else {
VStack(spacing: 16) {
ForEach(nearbyStoreCards) { store in
ForEach(filteredStoreCards) { store in
NavigationLink {
StoreDetailView(
storeId: store.id,
@@ -275,6 +265,9 @@ struct HomeView: View {
}
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.homeFilters.availableCategories = categories
.filter { $0.id.lowercased() != "all" }
.map(\.title)
appState.activeModal = .filters
}
.offset(y: collapseProgress * -120)
@@ -315,7 +308,7 @@ struct HomeView: View {
guard category.id != selectedCategory else { return }
selectedCategory = category.id
Task {
await bootstrapStoresFlow(category: category.id == "all" ? nil : category.id)
await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title)
}
}
}
@@ -338,77 +331,14 @@ struct HomeView: View {
}
}
private var storesByPositiveReviews: [StoreSummary] {
stores.sorted { lhs, rhs in
let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0
let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0
if lhsPositive != rhsPositive {
return lhsPositive > rhsPositive
}
let lhsRating = lhs.rating ?? 0
let rhsRating = rhs.rating ?? 0
if lhsRating != rhsRating {
return lhsRating > rhsRating
}
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
}
}
private var featuredStoresCards: [FeaturedStoreCardModel] {
Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard)
}
private var nearbyStoreCards: [FeaturedStoreCardModel] {
let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id))
let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false }
return Array(remaining.prefix(20)).map(mapStoreToCard)
}
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
let coverURL = resolveStoreMediaURL(store.cover)
let logoURL = resolveStoreMediaURL(store.logo)
return FeaturedStoreCardModel(
id: store.id,
name: store.name,
rating: store.rating ?? 0,
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
distance: formatDistance(store.distance),
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront",
imageURL: coverURL ?? logoURL,
logoURL: logoURL,
coverURL: coverURL,
isOpen: store.isOpen ?? true,
statusLabel: store.statusLabel
)
}
private func resolveStoreMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
private var profilePictureURL: String? {
resolveStoreMediaURL(appState.profile.profilePicture)
}
private func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
return String(format: "%.1f km", distance)
}
return "\(Int(distance * 1000)) m"
}
@MainActor
private func bootstrapStoresFlow(
forceLocationRefresh: Bool = false,
forceNetworkRefresh: Bool = false,
category: String? = nil,
refreshCategories: Bool = false
) async {
if isLoadingStores { return }
isLoadingStores = true
storesError = nil
@@ -432,6 +362,7 @@ struct HomeView: View {
)
if forceLocationRefresh == false,
forceNetworkRefresh == false,
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
isLoadingStores = false
stores = cachedStores
@@ -445,7 +376,10 @@ struct HomeView: View {
#endif
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
await loadHomeCategories(
withFallbackStores: cachedStores,
forceRefresh: forceLocationRefresh || forceNetworkRefresh
)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
@@ -478,13 +412,20 @@ struct HomeView: View {
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
await loadHomeCategories(
withFallbackStores: results,
forceRefresh: forceLocationRefresh || forceNetworkRefresh
)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
} catch {
if isCancelledRequest(error) {
isLoadingStores = false
return
}
isLoadingStores = false
stores = []
storesError = storesUserMessage(error)
@@ -498,7 +439,6 @@ struct HomeView: View {
// Use only upward displacement for collapse and ignore top bounce.
let normalized = max(0, y)
scrollOffset = normalized
collapseBaseOffset = normalized
}
.frame(width: 0, height: 0)
#else
@@ -522,4 +462,30 @@ struct HomeView: View {
let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil"
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
}
private var selectedCategoryQueryValue: String? {
guard selectedCategory.lowercased() != "all" else { return nil }
guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil }
return selected.title
}
private func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError {
switch networkError {
case .cancelled:
return true
case .transportError(let message):
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
default:
break
}
}
return error.localizedDescription.lowercased().contains("cancel")
}
}

View File

@@ -12,10 +12,12 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
@Environment(\.openURL) var openURL
@State var isLoading = true
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
@State var storeContactPhone: String? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@@ -51,6 +53,9 @@ struct OrderTrackingView: View {
await loadInitialOrder()
tracker.onOrderUpdated = { updated in
order = updated
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
storeContactPhone = inlinePhone
}
isLoading = false
errorMessage = nil
}
@@ -224,7 +229,9 @@ struct OrderTrackingView: View {
}
private var contactButton: some View {
Button("CONTATO") {}
Button("CONTATO") {
openStoreWhatsApp()
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
@@ -756,8 +763,10 @@ struct OrderTrackingView: View {
logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
} else if let result = response.result {
order = result
storeContactPhone = result.storePhone
errorMessage = nil
logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
await refreshStoreContactPhone(for: result)
}
} catch {
errorMessage = "Não foi possível carregar o pedido."
@@ -766,4 +775,61 @@ struct OrderTrackingView: View {
isLoading = false
}
@MainActor
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
return
}
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
return
}
}
} catch {
// Fallback handled below.
}
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
}
private func openStoreWhatsApp() {
guard let phoneRaw = storeContactPhone,
let url = makeWhatsAppURL(from: phoneRaw) else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
openURL(url)
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") {
digits = String(digits.drop(while: { $0 == "0" }))
}
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
}

View File

@@ -550,6 +550,7 @@ extension AppOrderSummary {
paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType,
storeName: tracked.storeName,
storePhone: tracked.storePhone,
storeLogoURL: tracked.storeLogoURL,
createdAt: tracked.createdAt,
updatedAt: tracked.updatedAt
@@ -571,6 +572,7 @@ extension AppOrderSummary {
paymentMethod: String?,
deliveryType: String?,
storeName: String?,
storePhone: String?,
storeLogoURL: String?,
createdAt: String?,
updatedAt: String?
@@ -589,6 +591,7 @@ extension AppOrderSummary {
self.paymentMethod = paymentMethod
self.deliveryType = deliveryType
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt

View File

@@ -50,12 +50,17 @@ extension StoreDetailView {
if let minOrder = info?.minOrder {
return formatCurrency(minOrder)
}
if let storeDistance, storeDistance.isEmpty == false {
return storeDistance
}
return "R$ --"
}
var distanceValueLabel: String {
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty {
return "--"
}
return raw
}
var isStoreOpen: Bool {
info?.isOpen ?? true
}

View File

@@ -263,6 +263,8 @@ struct StoreDetailView: View {
}
HStack(spacing: 0) {
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
Divider().frame(height: 34)
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
Divider().frame(height: 34)
statItem(title: "ENTREGA", value: deliveryValueLabel)