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

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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<String> = []
}
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<String> = []
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

View File

@@ -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)

View File

@@ -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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

744
subdomain_stores_plan.md Normal file
View File

@@ -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