backup
This commit is contained in:
@@ -62,7 +62,7 @@ struct LoginEmailView: View {
|
||||
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
|
||||
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
|
||||
|
||||
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
|
||||
Text("Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -41,7 +41,7 @@ struct OtpView: View {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 14)
|
||||
|
||||
Text("para \(email)")
|
||||
Text(otpDeliveryMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
@@ -208,6 +208,29 @@ struct OtpView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var otpDeliveryMessage: String {
|
||||
"para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)"
|
||||
}
|
||||
|
||||
private var maskedPhoneForDisplay: String {
|
||||
let digits = phoneNumber.filter(\.isNumber)
|
||||
guard digits.isEmpty == false else { return "XXXX" }
|
||||
|
||||
let visibleSuffix = String(digits.suffix(min(4, digits.count)))
|
||||
return "XXXX\(visibleSuffix)"
|
||||
}
|
||||
|
||||
private var maskedEmailForDisplay: String {
|
||||
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty == false else { return "XXXX" }
|
||||
|
||||
let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
|
||||
guard parts.count == 2 else { return "XXXX" }
|
||||
|
||||
let domain = String(parts[1])
|
||||
return "XXXX@\(domain)"
|
||||
}
|
||||
|
||||
private func validateOtp() {
|
||||
let code = otp.filter(\.isNumber)
|
||||
guard code.count == 8 else { return }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
235
pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
Normal file
235
pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
216
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
Normal file
216
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user