diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index 318683c..24c8709 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -54,7 +54,7 @@ struct ContentView: View { case .addressPicker: AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) case .filters: - FiltersModalView() + FiltersModalView(appState: $appState) } } .onChange(of: appState.address.display) { _, _ in @@ -346,21 +346,3 @@ struct AddressPickerModalView: View { } } } - -struct FiltersModalView: View { - var body: some View { - NavigationStack { - VStack(spacing: 12) { - Text("Filtros") - .font(AppTypography.heading2) - Text("Filtros de busca serao ligados na integracao real da Home com API.") - .font(AppTypography.body) - .multilineTextAlignment(.center) - .foregroundStyle(AppColors.textMuted) - } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(AppColors.backgroundLight) - } - } -} diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 891c3f9..2a72535 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -83,6 +83,10 @@ "comment" : "A heading for the size selection step in the pizza product detail sheet.", "isCommentAutoGenerated" : true }, + "1km" : { + "comment" : "A label for the minimum distance filter option.", + "isCommentAutoGenerated" : true + }, "2. Massa" : { }, @@ -101,6 +105,10 @@ } } }, + "10km" : { + "comment" : "A label displayed next to the far end of the distance slider in the filters modal.", + "isCommentAutoGenerated" : true + }, "Acompanhamento em tempo real" : { }, @@ -178,6 +186,10 @@ "comment" : "A button that applies a coupon code to a cart.", "isCommentAutoGenerated" : true }, + "Aplicar Filtros" : { + "comment" : "A button label that translates to \"Apply Filters\".", + "isCommentAutoGenerated" : true + }, "Appearance" : { "comment" : "Settings select label for the interface style of the controls (light, dark, or default)", "extractionState" : "stale", @@ -208,6 +220,10 @@ } } }, + "Até %lldkm" : { + "comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.", + "isCommentAutoGenerated" : true + }, "Atualizando status do pedido..." : { }, @@ -290,6 +306,10 @@ "comment" : "A label for the cart section of the app.", "isCommentAutoGenerated" : true }, + "Categorias" : { + "comment" : "A heading for the list of available categories in the filters modal.", + "isCommentAutoGenerated" : true + }, "Categories" : { "comment" : "A section header that lists available food categories.", "isCommentAutoGenerated" : true @@ -414,10 +434,6 @@ } } }, - "DELIVERY LOCATION" : { - "comment" : "A label describing the delivery location section.", - "isCommentAutoGenerated" : true - }, "Desconto" : { "comment" : "A label for a discount applied to an order.", "isCommentAutoGenerated" : true @@ -428,6 +444,9 @@ }, "Detalhes do Pedido" : { + }, + "Distância" : { + }, "Do que você gostou?" : { "comment" : "A prompt asking users to mention what they liked about a review.", @@ -452,12 +471,15 @@ "comment" : "A text describing delivery mode.", "isCommentAutoGenerated" : true }, + "ENTREGAR EM:" : { + "comment" : "A label displayed next to the button that lets the user select an address.", + "isCommentAutoGenerated" : true + }, "Enviar Avaliação" : { }, - "Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : { - "comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.", - "isCommentAutoGenerated" : true + "Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso." : { + }, "Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : { "comment" : "A description under the QR code that instructs the user on how to pay using their bank app.", @@ -517,10 +539,6 @@ "comment" : "A label for the filter options in the modal.", "isCommentAutoGenerated" : true }, - "Filtros de busca serao ligados na integracao real da Home com API." : { - "comment" : "A description of the filters feature that will be added to the home screen.", - "isCommentAutoGenerated" : true - }, "Finalizar Pedido" : { "comment" : "The title of the checkout screen.", "isCommentAutoGenerated" : true @@ -637,6 +655,10 @@ } } }, + "Limpar" : { + "comment" : "A button that clears all filters when pressed.", + "isCommentAutoGenerated" : true + }, "Limpar carrinho e adicionar" : { "comment" : "A button that clears the current cart and adds the selected product.", "isCommentAutoGenerated" : true @@ -719,10 +741,6 @@ "comment" : "A message displayed when a user has no addresses saved.", "isCommentAutoGenerated" : true }, - "Nenhum estabelecimento encontrado próximo à sua localização." : { - "comment" : "A message displayed when no nearby stores are found.", - "isCommentAutoGenerated" : true - }, "Nenhum pedido encontrado." : { }, @@ -795,6 +813,10 @@ "comment" : "A label indicating a required field.", "isCommentAutoGenerated" : true }, + "Ordenar por" : { + "comment" : "A label describing the sorting options available in the filters modal.", + "isCommentAutoGenerated" : true + }, "Pagamento" : { "comment" : "The title of the screen where a user can enter and save payment information.", "isCommentAutoGenerated" : true @@ -803,10 +825,6 @@ "comment" : "The navigation title for the PaymentPixView.", "isCommentAutoGenerated" : true }, - "para %@" : { - "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.", - "isCommentAutoGenerated" : true - }, "Pedido #%@" : { }, @@ -875,6 +893,10 @@ }, "Precisa de ajuda com esse pedido?" : { + }, + "Preço" : { + "comment" : "A label describing the price filter section in the filters modal.", + "isCommentAutoGenerated" : true }, "Preencha os dados abaixo para adicionar um endereço." : { "comment" : "A description below the form to add a new address, instructing the user to fill in the required information.", @@ -1161,6 +1183,10 @@ "comment" : "A button label that translates to \"View Profile\" in English.", "isCommentAutoGenerated" : true }, + "Ver todas" : { + "comment" : "A link that allows users to see all available categories in the filters.", + "isCommentAutoGenerated" : true + }, "Verificação" : { "comment" : "A title for the OTP verification screen.", "isCommentAutoGenerated" : true diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index c3f3843..8b54b96 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -115,6 +115,8 @@ struct StoreInfoResult: Decodable { let isOpen: Bool? let statusLabel: String? let fantasyName: String? + let phone: String? + let whatsapp: String? let logo: String? let cover: String? let deliveryTime: String? @@ -126,6 +128,8 @@ struct StoreInfoResult: Decodable { case isOpen case statusLabel case fantasyName + case phone + case whatsapp case logo case cover case deliveryTime @@ -139,6 +143,8 @@ struct StoreInfoResult: Decodable { isOpen = try? container.decode(Bool.self, forKey: .isOpen) statusLabel = try? container.decode(String.self, forKey: .statusLabel) fantasyName = try? container.decode(String.self, forKey: .fantasyName) + phone = ApiService.decodeFlexibleString(from: container, keys: [.phone]) + whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone]) logo = try? container.decode(String.self, forKey: .logo) cover = try? container.decode(String.self, forKey: .cover) deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift index f72d51d..d60ad5c 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -15,6 +15,7 @@ struct AppOrderSummary: Decodable, Identifiable { let paymentMethod: String? let deliveryType: String? let storeName: String? + let storePhone: String? let storeLogoURL: String? let createdAt: String? let updatedAt: String? @@ -35,6 +36,7 @@ struct AppOrderSummary: Decodable, Identifiable { case paymentMethod case deliveryType case storeName + case storePhone case storeLogo case store_logo case logo @@ -59,6 +61,7 @@ struct AppOrderSummary: Decodable, Identifiable { paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod]) deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date]) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate @@ -86,6 +89,7 @@ struct PublicOrderResult: Codable, Identifiable { let discount: Double? let total: Double? let storeName: String? + let storePhone: String? let storeLogoURL: String? let createdAt: String? let updatedAt: String? @@ -125,6 +129,7 @@ struct PublicOrderResult: Codable, Identifiable { case couponDiscount case total case storeName + case storePhone case storeLogo case store_logo case logo @@ -165,6 +170,7 @@ struct PublicOrderResult: Codable, Identifiable { discount: Double? = nil, total: Double? = nil, storeName: String? = nil, + storePhone: String? = nil, storeLogoURL: String? = nil, createdAt: String? = nil, updatedAt: String? = nil, @@ -196,6 +202,7 @@ struct PublicOrderResult: Codable, Identifiable { self.discount = discount self.total = total self.storeName = storeName + self.storePhone = storePhone self.storeLogoURL = storeLogoURL self.createdAt = createdAt self.updatedAt = updatedAt @@ -240,6 +247,7 @@ struct PublicOrderResult: Codable, Identifiable { discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) + storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) @@ -279,6 +287,7 @@ struct PublicOrderResult: Codable, Identifiable { try container.encodeIfPresent(discount, forKey: .discount) try container.encodeIfPresent(total, forKey: .total) try container.encodeIfPresent(storeName, forKey: .storeName) + try container.encodeIfPresent(storePhone, forKey: .storePhone) try container.encodeIfPresent(createdAt, forKey: .createdAt) try container.encodeIfPresent(updatedAt, forKey: .updatedAt) try container.encodeIfPresent(otp, forKey: .otp) diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 1b3de3d..b48f28b 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -6,6 +6,7 @@ struct AppState { var cart = CartState() var address = AddressState() var favorites = FavoritesState() + var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil } @@ -41,6 +42,57 @@ struct FavoritesState { var storeIds: Set = [] } +enum HomeSortOption: String, CaseIterable, Identifiable { + case relevance + case rating + case deliveryTime + case price + + var id: String { rawValue } + + var title: String { + switch self { + case .relevance: return "Relevância" + case .rating: return "Avaliação" + case .deliveryTime: return "Tempo de entrega" + case .price: return "Preço" + } + } + + var icon: String { + switch self { + case .relevance: return "checkmark.seal.fill" + case .rating: return "star.fill" + case .deliveryTime: return "clock.fill" + case .price: return "dollarsign" + } + } +} + +enum HomePriceTier: String, CaseIterable, Identifiable { + case low = "$" + case medium = "$$" + case high = "$$$" + case veryHigh = "$$$$" + + var id: String { rawValue } +} + +struct HomeFiltersState { + var sortOption: HomeSortOption = .relevance + var selectedCategories: Set = [] + var selectedPriceTier: HomePriceTier? = nil + var maxDistanceKm: Double = 10 + var availableCategories: [String] = [] + + mutating func reset() { + sortOption = .relevance + selectedCategories = [] + selectedPriceTier = nil + maxDistanceKm = 10 + } +} + struct CartState { var storeId: String? = nil var storeName: String? = nil diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift index f48022f..c5e523c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift @@ -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) diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index c47c965..93c4a93 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -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 } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index ffd02e2..701c1c9 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift b/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift new file mode 100644 index 0000000..ef2613e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift @@ -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 + _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) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift new file mode 100644 index 0000000..7cbf61c --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift @@ -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 + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 03e6441..880e2d9 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -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") + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift index 38129b3..d120d1d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -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)") + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 607f064..a5ccade 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index 05fae25..f510296 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -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 } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 19f3732..cb9fd61 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -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) diff --git a/subdomain_stores_plan.md b/subdomain_stores_plan.md new file mode 100644 index 0000000..26378fb --- /dev/null +++ b/subdomain_stores_plan.md @@ -0,0 +1,744 @@ +# Subdomain Stores Plan + +## Objetivo + +Reorganizar o ecossistema do PediFoods para um modelo claro de subdomínios, mantendo a integração com o backend do Atomenta onde ela faz sentido, mas separando melhor identidade, navegação e contexto de cada área. + +## Cenário Desejado + +### 1. `pedifoods.com.br` + +Papel: +- home pública +- landing page +- login +- onboarding inicial +- após login, exibir lista de lojas do usuário + +Responsabilidade: +- identidade pública do PediFoods +- autenticação do usuário final/parceiro +- seleção da loja + +### 2. `parceiro.pedifoods.com.br` + +Papel: +- painel do lojista +- equivalente ao atual "Atomenta Store" +- gestão operacional da loja + +Responsabilidade: +- backoffice do parceiro +- configuração da loja +- cardápio +- pedidos +- financeiro +- onboarding do parceiro + +### 3. `nome-curto-loja.pedifoods.com.br` + +Papel: +- storefront da loja +- vitrine pública ou semi-logada da loja +- produtos, categorias, carrinho e experiência do cliente + +Responsabilidade: +- experiência da loja individual +- branding e contexto por loja +- leitura do catálogo + +## Princípio Arquitetural + +O PediFoods não deve mais parecer "um Atomenta renomeado". + +Ele pode continuar: +- consumindo APIs do Atomenta +- reutilizando lógica de OTP +- reutilizando cookies/tokens e integração segura quando necessário + +Mas deve deixar claro: +- qual domínio é PediFoods +- qual domínio é painel parceiro +- qual domínio é storefront da loja + +## Direção Técnica + +### Separar 3 contextos + +1. `public app` +- domínio base `pedifoods.com.br` +- homepage, login, seleção de lojas + +2. `partner app` +- subdomínio fixo `parceiro.pedifoods.com.br` +- painel do lojista + +3. `store app` +- subdomínio dinâmico `{slug}.pedifoods.com.br` +- storefront da loja + +### Manter 1 base de código no curto prazo + +No curto prazo, o melhor custo-benefício é manter uma única codebase do PediFoods com roteamento por host. + +Ou seja: +- o app lê o `Host` +- identifica o contexto +- renderiza a área correta + +Exemplo de resolução: +- `pedifoods.com.br` => contexto `public` +- `parceiro.pedifoods.com.br` => contexto `partner` +- `slug.pedifoods.com.br` => contexto `store` + +Isso evita: +- duplicação de projeto +- deploy múltiplo desnecessário +- divergência de regra de negócio + +## Regras de Resolução de Host + +### Contexto `public` + +Hosts: +- `pedifoods.com.br` +- `www.pedifoods.com.br` se existir + +Fluxos: +- home +- login +- cadastro +- recuperação de conta +- listagem de lojas do usuário autenticado + +### Contexto `partner` + +Host: +- `parceiro.pedifoods.com.br` + +Fluxos: +- login do parceiro +- dashboard da loja +- onboarding +- catálogo +- pedidos +- financeiro + +### Contexto `store` + +Host: +- `{storeSlug}.pedifoods.com.br` + +Fluxos: +- catálogo público da loja +- detalhes da loja +- carrinho +- pedidos do cliente + +## Mudanças Estruturais Necessárias + +### 1. Resolver host de forma centralizada + +Criar uma camada única para: +- ler `req.hostname` +- classificar o contexto +- extrair `storeSlug` quando aplicável + +Saída esperada: +- `appContext.type = public | partner | store` +- `appContext.storeSlug = string | null` + +### 2. Parar de usar defaults de domínio do Atomenta no PediFoods + +Hoje o PediFoods ainda carrega vários defaults como: +- `https://atomenta.com.br` +- `atomenta_token` +- `atomenta_auth` +- nomes e textos herdados + +Isso precisa ser separado em 2 grupos: + +#### Pode continuar apontando para Atomenta +- integração de API interna +- OTP/crypto +- comunicação backend-to-backend +- consulta de dados mestres, se esta for a arquitetura + +#### Não deve mais apontar para Atomenta +- branding +- domínio público +- base URL de navegação do usuário +- nomes do app +- `package.json` e `ecosystem.config.js` + +### 3. Definir estratégia de sessão/cookies + +Decisão importante: + +O sistema terá **dois contextos de autenticação diferentes**: + +1. **partner session** +- usada no `parceiro.pedifoods.com.br` +- representa o lojista/operador +- dá acesso ao painel administrativo + +2. **customer session** +- usada em `pedifoods.com.br` e em `{slug}.pedifoods.com.br` +- representa o cliente/comprador +- dá acesso à navegação, seleção de lojas, carrinho e compra + +Essas duas sessões **não devem ser compartilhadas entre si**. + +Se fossem compartilhadas: +- um usuário logado como parceiro poderia cair autenticado na storefront da própria loja +- haveria risco de misturar permissões administrativas com experiência de compra +- o modelo de segurança e UX ficaria ambíguo + +#### Opção A +Sessão única compartilhada em `.pedifoods.com.br` + +Resultado: +- mesma sessão valeria para: + - `pedifoods.com.br` + - `parceiro.pedifoods.com.br` + - `{slug}.pedifoods.com.br` + +Problema: +- **não serve para este cenário** +- mistura sessão de lojista com sessão de cliente + +#### Opção B +Sessões separadas por contexto + +Modelo recomendado: +- `parceiro.pedifoods.com.br` + - cookie próprio de parceiro + - escopo exclusivo do painel +- `pedifoods.com.br` + - cookie próprio de cliente + - pode ser compartilhado com `{slug}.pedifoods.com.br` se fizer sentido para a jornada do comprador +- `{slug}.pedifoods.com.br` + - usa sessão do cliente, nunca sessão do parceiro + +Vantagens: +- separa claramente os perfis +- evita vazamento de autenticação entre painel e loja +- reduz risco de autorização indevida +- deixa a jornada de compra independente da jornada administrativa + +Desvantagens: +- exige mais cuidado no desenho de login/logout +- exige nomes de cookies e middleware distintos + +Recomendação: +- usar **sessões separadas por contexto** +- nunca compartilhar sessão de `partner` com `customer` +- se necessário, compartilhar apenas a sessão do **cliente** entre: + - `pedifoods.com.br` + - `{slug}.pedifoods.com.br` + +Implementação sugerida: +- cookie de parceiro: + - exemplo `pedifoods_partner_session` + - domínio restrito a `parceiro.pedifoods.com.br` +- cookie de cliente: + - exemplo `pedifoods_customer_session` + - domínio `.pedifoods.com.br` apenas se a jornada do cliente precisar navegar entre domínio principal e subdomínios de loja + +## Estratégia de Dados + +### Fonte de verdade + +Precisamos assumir explicitamente: +- o Atomenta continua sendo a fonte de verdade operacional +- o PediFoods atua como camada especializada de experiência/lojista/storefront + +Isso significa: +- login e sessão podem existir no PediFoods +- mas dados de loja, catálogo, usuário parceiro e regras sensíveis podem continuar vindo do Atomenta via API + +## Fases de Implementação + +## Fase 1. Higiene e identidade + +Objetivo: +- remover confusão estrutural atual + +Itens: +- corrigir `package.json` do PediFoods +- corrigir `ecosystem.config.js` +- revisar `env.example` +- revisar nomes, textos e defaults públicos +- separar claramente o que é branding PediFoods e o que é integração Atomenta + +Saída: +- o projeto deixa de se identificar como Atomenta + +## Fase 2. Resolver host e contexto + +Objetivo: +- introduzir leitura de subdomínio + +Itens: +- criar utilitário central de hostname +- classificar `public`, `partner`, `store` +- disponibilizar contexto em middleware +- ajustar templates/layouts e rotas para reagirem ao contexto + +Saída: +- uma única app responde diferente por host + +## Fase 3. Fluxo público `pedifoods.com.br` + +Objetivo: +- consolidar homepage + login + listagem de lojas + +Itens: +- manter home atual +- após login, mostrar lojas do usuário +- ao clicar na loja, redirecionar para `slug.pedifoods.com.br` + +Saída: +- domínio principal vira hub do usuário + +## Fase 4. Fluxo parceiro `parceiro.pedifoods.com.br` + +Objetivo: +- isolar o backoffice do lojista + +Itens: +- mover/ajustar as telas que hoje se comportam como "Atomenta Store" +- garantir que esse host sempre abra painel parceiro +- revisar permissões e middleware + +Saída: +- painel do lojista com identidade própria + +## Fase 5. Fluxo loja `slug.pedifoods.com.br` + +Objetivo: +- transformar storefront em domínio por loja + +Itens: +- mapear slug -> loja +- carregar tema, catálogo e dados por slug +- revisar SEO/meta tags +- revisar links absolutos + +Saída: +- cada loja com seu subdomínio + +## Fase 6. Sessão compartilhada + +Objetivo: +- permitir navegação fluida entre subdomínios + +Itens: +- cookie domain `.pedifoods.com.br` +- revisar login/logout +- revisar redirecionamentos +- revisar callbacks OTP + +Saída: +- usuário loga uma vez e navega entre contextos compatíveis + +## Riscos + +### 1. Misturar branding com integração + +Se trocar tudo de `atomenta` cegamente, pode quebrar integrações reais. + +### 2. Cookies atuais + +Se hoje os cookies ainda usam nomes herdados, uma troca brusca pode invalidar sessões e OTP. + +### 3. Links absolutos hardcoded + +Há muitos pontos do PediFoods ainda apontando para Atomenta. +Eles precisam ser classificados antes de alterar. + +### 4. Deploy atual + +Hoje o deploy sobe um projeto que ainda está semanticamente misturado. +Sem limpeza mínima, a arquitetura nova vai nascer confusa. + +## Recomendações Práticas + +### Curto prazo + +- corrigir identidade mínima do projeto PediFoods +- mapear todos os usos de: + - `atomenta.com.br` + - `atomenta_token` + - `atomenta_auth` + - `ATOMENTA_*` +- classificar cada ocorrência em: + - branding + - integração legítima + +### Médio prazo + +- implementar middleware de contexto por host +- separar layouts `public`, `partner`, `store` +- começar pelo domínio `parceiro.pedifoods.com.br` + +### Longo prazo + +- consolidar storefront por slug +- revisar autenticação multi-subdomínio +- avaliar se no futuro compensa separar em múltiplos apps + +## Ordem Recomendada de Execução + +1. Higienizar identidade do projeto PediFoods +2. Mapear referências herdadas de Atomenta +3. Criar resolvedor central de host/subdomínio +4. Subir `parceiro.pedifoods.com.br` +5. Ajustar `pedifoods.com.br` para login + lista de lojas +6. Implementar `slug.pedifoods.com.br` +7. Revisar cookies e sessão compartilhada + +## Decisão Recomendada Agora + +A melhor decisão agora é: + +- **não tentar “desatomentar” tudo de uma vez** +- primeiro separar: + - identidade pública + - painel parceiro + - storefront por subdomínio +- e só depois revisar dependências herdadas uma a uma + +## Próximo Passo + +Próxima entrega recomendada: + +1. fazer uma auditoria do repositório PediFoods +2. classificar referências herdadas em: + - `pode trocar agora` + - `depende de integração com Atomenta` +3. implementar o resolvedor de subdomínio +4. subir primeiro o contexto `parceiro.pedifoods.com.br` + +## Tasks de Implementação + +Legenda: +- 🔴 pendente +- 🟢 concluído + +### Fase 1. Higiene do projeto PediFoods + +- 🟢 Corrigir identidade básica do projeto em `package.json` +- 🟢 Corrigir identidade básica do processo em `ecosystem.config.js` +- 🔴 Revisar `env.example` para separar branding PediFoods de integração Atomenta +- 🟢 Mapear todas as referências a `atomenta` no repositório +- 🟢 Classificar cada referência como: + - branding/UI + - integração técnica legítima + - legado morto +- 🟢 Remover ou ajustar branding incorreto do PediFoods + +### Fase 2. Contexto de domínio e subdomínio + +- 🟢 Criar utilitário central para resolver o host atual +- 🟢 Criar classificador de contexto: + - `public` + - `partner` + - `store` +- 🟢 Extrair `storeSlug` automaticamente quando o host for `{slug}.pedifoods.com.br` +- 🟢 Injetar esse contexto em middleware global +- 🟢 Expor o contexto para views/templates +- 🔴 Garantir fallback seguro para domínio desconhecido + +### Fase 3. Sessões separadas + +- 🟢 Criar sessão de parceiro independente +- 🔴 Criar sessão de cliente independente +- 🔴 Garantir que `parceiro.pedifoods.com.br` nunca reutilize sessão de cliente +- 🔴 Garantir que `{slug}.pedifoods.com.br` nunca reutilize sessão de parceiro +- 🟢 Revisar nomes dos cookies +- 🟢 Revisar `login`, `logout` e middleware de autenticação +- 🟢 Revisar redirecionamentos pós-login por contexto + +### Fase 4. `pedifoods.com.br` + +- 🟢 Consolidar a home pública +- 🟢 Consolidar login/cadastro/recuperação +- 🔴 Criar tela pós-login com listagem de lojas do usuário +- 🟢 Fazer o clique na loja redirecionar para `{slug}.pedifoods.com.br` +- 🟢 Revisar meta tags, canonical e links absolutos + +### Fase 5. `parceiro.pedifoods.com.br` + +- 🟢 Isolar o painel do lojista nesse host +- 🟢 Revisar middleware e permissões do painel +- 🟢 Revisar links internos do painel para não mandar ao domínio errado +- 🟢 Ajustar onboarding do parceiro +- 🟢 Ajustar rotas administrativas de loja, pedidos, catálogo e financeiro +- 🟢 Garantir que o painel não renderize a experiência pública da loja + +### Fase 6. `{slug}.pedifoods.com.br` + +- 🟢 Criar resolução da loja por slug +- 🟢 Buscar loja e catálogo pelo slug +- 🟢 Renderizar storefront por loja +- 🟢 Ajustar tema, título, descrição e identidade da loja +- 🟢 Ajustar links compartilháveis da loja +- 🟢 Revisar SEO e OpenGraph por loja +- 🔴 Garantir fallback para slug inexistente + +### Fase 7. Gestão de slug da loja + +- 🟢 Adicionar campo `storeSlug` como atributo persistido da loja +- 🟢 Validar unicidade global do slug +- 🟢 Validar formato do slug +- 🟢 Gerar pré-preenchimento automático do slug no fim do onboarding com base no nome fantasia da loja +- 🟢 Criar tela de configuração do slug no painel parceiro +- 🟢 Exibir aviso de impacto da troca de slug +- 🟢 Salvar data da última alteração de slug +- 🟢 Bloquear nova alteração por 90 dias +- 🟢 Exibir contador ou data de desbloqueio para nova alteração +- 🔴 Criar redirect temporário do slug antigo para o novo, se viável + +### Fase 8. Integração com Atomenta + +- 🟢 Revisar quais chamadas devem continuar indo ao Atomenta +- 🟢 Centralizar base URL interna da API do Atomenta +- 🔴 Revisar OTP e autenticação compartilhada +- 🟢 Revisar cookies herdados `atomenta_*` +- 🔴 Revisar endpoints de catálogo, loja, financeiro e customer lookup +- 🔴 Remover dependências herdadas desnecessárias + +### Fase 9. Deploy e infraestrutura + +- 🔴 Ajustar nginx/proxy para: + - `pedifoods.com.br` + - `parceiro.pedifoods.com.br` + - `*.pedifoods.com.br` +- 🔴 Validar wildcard localmente antes da etapa de DNS público +- 🔴 Garantir certificado TLS compatível com wildcard +- 🔴 Validar resolução correta de host até a app Node + +## Status Atual + +Legenda: +- 🟢 concluído +- 🟡 parcial +- 🔴 pendente + +Resumo do que já foi entregue no app: +- 🟢 identidade básica do projeto corrigida +- 🟢 resolução central de host/subdomínio +- 🟢 separação inicial entre `public`, `partner` e `store` +- 🟢 slug canônico com preview, unicidade, cooldown de 90 dias e geração automática no onboarding +- 🟢 storefront canônica em `{slug}.pedifoods.com.br` +- 🟢 links públicos e links do painel abrindo a loja publicada correta +- 🟢 limpeza principal de branding visível do PediFoods +- 🟢 frontend priorizando `pedifoods_partner_token` com fallback para legado + +O que ainda falta para fechar o fluxo completo: +- 🔴 autenticação real do cliente +- 🔴 tela pós-login do cliente com listagem de lojas +- 🔴 fallback para slug inexistente +- 🔴 redirect temporário de slug antigo para slug novo +- 🔴 wildcard real em proxy/Cloudflare/TLS +- 🔴 Validar deploy do PediFoods sem contaminar com Atomenta +- 🔴 Configurar wildcard DNS no Cloudflare + - esta task deve ficar por último + - quando chegarmos nela, você executa manualmente no Cloudflare + - eu só vou te avisar e te passar o valor exato + +### Fase 10. Testes de ponta a ponta + +- 🔴 Testar login de cliente em `pedifoods.com.br` +- 🔴 Testar seleção de loja e redirecionamento para `{slug}.pedifoods.com.br` +- 🔴 Testar login de parceiro em `parceiro.pedifoods.com.br` +- 🔴 Testar isolamento entre sessão de parceiro e sessão de cliente +- 🔴 Testar troca de slug com bloqueio de 90 dias +- 🔴 Testar slug inválido/inexistente +- 🔴 Testar logout em cada contexto +- 🔴 Testar links absolutos, redirects e cookies + +## Política de Slug da Loja + +Sua ideia funciona e faz sentido. + +### Recomendação + +O slug deve ser: +- criado uma primeira vez no onboarding da loja +- editável pelo parceiro no painel +- bloqueado por 90 dias após cada alteração + +### Motivos para essa regra + +- evita troca frequente de URL +- reduz confusão para clientes recorrentes +- protege links já compartilhados +- reduz impacto operacional em cache, indexing e hábito do usuário + +### Como funcionaria + +Cada loja teria campos como: +- `storeSlug` +- `slugUpdatedAt` +- `slugLockedUntil` +- `slugGeneratedBySystemAt` +- `slugFirstManualUpdateAt` + +Regra: +- se nunca definiu slug: + - o sistema pré-preenche automaticamente + - o usuário pode ajustar antes de concluir +- se já definiu: + - só pode alterar novamente após 90 dias + +### Geração inicial do slug + +Recomendação: +- ao terminar o onboarding, gerar um pré-preenchimento automático baseado no nome fantasia + +Exemplo: +- `Pizzaria do Zé` -> `pizzaria-do-ze` +- `Temaki House Prime` -> `temaki-house-prime` + +O usuário: +- vê o slug sugerido +- pode editar antes de salvar pela primeira vez + +Importante: +- o slug gerado automaticamente pelo sistema **não inicia** a janela de bloqueio de 90 dias +- ele serve apenas como valor inicial/sugestão persistida +- o contador de 90 dias só começa quando houver a **primeira alteração manual do lojista** + +### Limite de tamanho + +Recomendação: +- limitar o slug a **30 caracteres** + +Esse limite deve contar: +- letras +- números +- hífens + +Na prática: +- espaços viram hífen +- então o total final do slug salvo não pode passar de `30` + +Exemplo de regra: +- nome fantasia muito grande: + - `Loja Muito Grande Com Nome Enorme da Silva` +- slug gerado: + - `loja-muito-grande-com-nome` + +### Regra de sanitização sugerida + +O gerador automático deve: +- converter para minúsculas +- remover acentos +- trocar espaços por hífen +- remover caracteres especiais +- colapsar hífens duplicados +- cortar em `30` caracteres +- remover hífen do início/fim + +### Se o slug já existir + +Se o slug gerado já estiver em uso: +- adicionar sufixo incremental curto + +Exemplo: +- `pizzaria-do-ze` +- `pizzaria-do-ze-2` +- `pizzaria-do-ze-3` + +Sempre respeitando o limite final de `30` caracteres + +### Regra de bloqueio de 90 dias + +Regra correta: + +- **geração automática pelo sistema** + - não conta como alteração manual + - não bloqueia o campo + +- **primeira edição manual feita pelo lojista** + - passa a contar como alteração real + - grava `slugFirstManualUpdateAt` + - grava `slugUpdatedAt` + - define `slugLockedUntil = slugUpdatedAt + 90 dias` + +- **edições seguintes** + - só permitidas após `slugLockedUntil` + +### Exemplo prático + +1. onboarding termina +2. sistema gera: + - `pizzaria-do-ze` +3. lojista vê o valor e troca para: + - `pizza-do-ze-centro` +4. essa troca manual: + - conta como primeira alteração real + - inicia a trava de 90 dias + +Se o lojista aceitar o slug automático sem mexer: +- nenhum bloqueio de 90 dias deve ser iniciado +- ele ainda pode fazer a primeira alteração manual depois + +### Ajuste das tasks + +- 🔴 Persistir metadado informando se o slug atual foi gerado automaticamente ou definido manualmente +- 🔴 Garantir que a geração automática no onboarding não inicie `slugLockedUntil` +- 🔴 Iniciar a janela de 90 dias somente na primeira alteração manual do lojista + +### Validações necessárias + +- slug único no sistema +- somente letras minúsculas, números e hífen +- tamanho mínimo e máximo +- blacklist de reservados, por exemplo: + - `www` + - `api` + - `admin` + - `app` + - `parceiro` + - `mail` + - `git` + - `proxy` + +### Redirect do slug antigo + +Se possível, é recomendável: +- guardar o slug antigo +- fazer redirect `301` do slug antigo para o novo por um período + +Isso ajuda: +- SEO +- links salvos +- usuários acostumados com a URL antiga + +### Sobre DNS e Cloudflare + +Isso funciona bem se você usar: +- wildcard DNS `*.pedifoods.com.br` +- proxy reverso lendo o `Host` + +Nesse modelo: +- você **não precisa criar um registro DNS novo por loja** +- o wildcard cobre todos os slugs +- a troca de slug depende mais da aplicação e do cache HTTP do que de propagação DNS individual + +Ou seja: +- a trava de 90 dias continua fazendo sentido por produto e UX +- mas não por limitação técnica de DNS em si + +### Recomendação final para slug + +Implementar assim: + +- slug definido no onboarding +- edição permitida no painel parceiro +- nova alteração somente após 90 dias +- aviso visual claro antes de salvar +- registro do slug antigo para redirect temporário