Merge pull request 'feature/step-001' (#2) from feature/step-001 into main
Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#2
This commit is contained in:
8
PediFoods.code-workspace
Normal file
8
PediFoods.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<dict/>
|
||||
</plist>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
||||
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
||||
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; };
|
||||
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
|
||||
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -42,7 +42,7 @@
|
||||
49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; };
|
||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
|
||||
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
|
||||
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -92,7 +92,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
49F90C2B2A52156200F06D93 /* Main.swift */,
|
||||
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
|
||||
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
|
||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
||||
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
||||
4900101C2BACEA710000DE33 /* Info.plist */,
|
||||
@@ -165,7 +165,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
|
||||
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
|
||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
@@ -214,6 +214,8 @@
|
||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
};
|
||||
@@ -226,6 +228,8 @@
|
||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
||||
let ttl: TimeInterval
|
||||
let placeholder: Placeholder
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@StateObject var loader = CachedRemoteImageLoader()
|
||||
#endif
|
||||
|
||||
init(
|
||||
imageURL: String?,
|
||||
@@ -60,14 +62,19 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
loader.load(imageURL: imageURL, ttl: ttl)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: imageURL) { _, newValue in
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
loader.load(imageURL: newValue, ttl: ttl)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@MainActor
|
||||
final class CachedRemoteImageLoader: ObservableObject {
|
||||
#if canImport(UIKit)
|
||||
@@ -174,3 +181,4 @@ final class CachedRemoteImageLoader: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -30,7 +30,7 @@ struct SnackbarOverlay: View {
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(message.style.backgroundColor)
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
center.handleTap()
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ContentView: View {
|
||||
#if os(iOS)
|
||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||
#endif
|
||||
@State var cartResetObserver: Any?
|
||||
#if os(Android)
|
||||
@State var snackbarCenter = SnackbarCenter.shared
|
||||
#else
|
||||
@@ -54,7 +55,7 @@ struct ContentView: View {
|
||||
case .addressPicker:
|
||||
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
||||
case .filters:
|
||||
FiltersModalView()
|
||||
FiltersModalView(appState: $appState)
|
||||
}
|
||||
}
|
||||
.onChange(of: appState.address.display) { _, _ in
|
||||
@@ -69,9 +70,6 @@ struct ContentView: View {
|
||||
.onChange(of: appState.address.longitude) { _, _ in
|
||||
dismissAddressPickerIfAddressExists()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
|
||||
appState.cart = CartState()
|
||||
}
|
||||
.task {
|
||||
await performInitialLaunchBootstrap()
|
||||
}
|
||||
@@ -83,11 +81,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
attachCartResetObserverIfNeeded()
|
||||
#if os(iOS)
|
||||
attachSessionExpiredObserverIfNeeded()
|
||||
#endif
|
||||
}
|
||||
.onDisappear {
|
||||
detachCartResetObserver()
|
||||
#if os(iOS)
|
||||
detachSessionExpiredObserver()
|
||||
#endif
|
||||
@@ -182,6 +182,23 @@ struct ContentView: View {
|
||||
isBootstrappingSession = false
|
||||
}
|
||||
|
||||
private func attachCartResetObserverIfNeeded() {
|
||||
guard cartResetObserver == nil else { return }
|
||||
cartResetObserver = NotificationCenter.default.addObserver(
|
||||
forName: .cartDidReset,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
appState.cart = CartState()
|
||||
}
|
||||
}
|
||||
|
||||
private func detachCartResetObserver() {
|
||||
guard let cartResetObserver else { return }
|
||||
NotificationCenter.default.removeObserver(cartResetObserver)
|
||||
self.cartResetObserver = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func hydrateAppState(with customer: CustomerProfile) {
|
||||
appState.profile.id = customer.id
|
||||
@@ -346,21 +363,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -76,18 +76,18 @@ final class OrderRealtimeTracker {
|
||||
@discardableResult
|
||||
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
|
||||
do {
|
||||
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
|
||||
logger.debug("OrderTracking poll request orderId=\(orderId)")
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
guard response.error == false, let order = response.result else {
|
||||
logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
|
||||
return nil
|
||||
}
|
||||
clearPendingCartIfNeeded(for: order)
|
||||
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
|
||||
logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
|
||||
onOrderUpdated?(order)
|
||||
return order
|
||||
} catch {
|
||||
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
103
pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift
Normal file
103
pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
#endif
|
||||
|
||||
#if canImport(CoreGraphics)
|
||||
import CoreGraphics
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
#if !canImport(UIKit)
|
||||
struct UIDevice {
|
||||
static let topNotch = 0.0
|
||||
static let bottomNotch = 0.0
|
||||
|
||||
var modelName: String {
|
||||
"android"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !(canImport(LCEssentials) && os(iOS))
|
||||
func printLog(title: String, msg: String) {
|
||||
print("[\(title)] \(msg)")
|
||||
}
|
||||
|
||||
func printError(title: String, msg: String) {
|
||||
print("[\(title)] \(msg)")
|
||||
}
|
||||
#endif
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func appInlineNavigationTitle() -> some View {
|
||||
#if os(macOS)
|
||||
self
|
||||
#else
|
||||
self.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appHiddenNavigationBar() -> some View {
|
||||
#if os(macOS)
|
||||
self
|
||||
#else
|
||||
self
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appTopBarTrailingToolbar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
#if os(macOS)
|
||||
self.toolbar {
|
||||
ToolbarItem {
|
||||
content()
|
||||
}
|
||||
}
|
||||
#else
|
||||
self.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appContentShape<S: Shape>(_ shape: S) -> some View {
|
||||
#if os(Android)
|
||||
self
|
||||
#else
|
||||
self.contentShape(shape)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appBottomSafeAreaInset<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
#if os(Android)
|
||||
self.overlay(alignment: .bottom) {
|
||||
content()
|
||||
}
|
||||
#else
|
||||
self.safeAreaInset(edge: .bottom) {
|
||||
content()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func appLayoutPriority(_ value: Double) -> some View {
|
||||
#if os(Android)
|
||||
self
|
||||
#else
|
||||
self.layoutPriority(value)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SkipFuse
|
||||
|
||||
/// The Observable ViewModel used by the application.
|
||||
@Observable public class ViewModel {
|
||||
var items: [Item] = loadItems() {
|
||||
didSet { saveItems() }
|
||||
}
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
func clear() {
|
||||
items.removeAll()
|
||||
}
|
||||
|
||||
func isUpdated(_ item: Item) -> Bool {
|
||||
item != items.first { i in
|
||||
i.id == item.id
|
||||
}
|
||||
}
|
||||
|
||||
func save(item: Item) {
|
||||
items = items.map { i in
|
||||
i.id == item.id ? item : i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An individual item held by the ViewModel
|
||||
struct Item : Identifiable, Hashable, Codable {
|
||||
let id: UUID
|
||||
var date: Date
|
||||
var favorite: Bool
|
||||
var title: String
|
||||
var notes: String
|
||||
|
||||
init(id: UUID = UUID(), date: Date = .now, favorite: Bool = false, title: String = "", notes: String = "") {
|
||||
self.id = id
|
||||
self.date = date
|
||||
self.favorite = favorite
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
}
|
||||
|
||||
var itemTitle: String {
|
||||
!title.isEmpty ? title : dateString
|
||||
}
|
||||
|
||||
var dateString: String {
|
||||
date.formatted(date: .complete, time: .omitted)
|
||||
}
|
||||
|
||||
var dateTimeString: String {
|
||||
date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
/// Utilities for defaulting and persising the items in the list
|
||||
extension ViewModel {
|
||||
private static let savePath = URL.applicationSupportDirectory.appendingPathComponent("appdata.json")
|
||||
|
||||
fileprivate static func loadItems() -> [Item] {
|
||||
do {
|
||||
let start = Date.now
|
||||
let data = try Data(contentsOf: savePath)
|
||||
defer {
|
||||
let end = Date.now
|
||||
logger.info("loaded \(data.count) bytes from \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds")
|
||||
}
|
||||
return try JSONDecoder().decode([Item].self, from: data)
|
||||
} catch {
|
||||
// perhaps the first launch, or the data could not be read
|
||||
logger.warning("failed to load data from \(Self.savePath), using defaultItems: \(error)")
|
||||
let defaultItems = (1...365).map { Date(timeIntervalSinceNow: Double($0 * 60 * 60 * 24 * -1)) }
|
||||
return defaultItems.map({ Item(date: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func saveItems() {
|
||||
do {
|
||||
let start = Date.now
|
||||
let data = try JSONEncoder().encode(items)
|
||||
try FileManager.default.createDirectory(at: URL.applicationSupportDirectory, withIntermediateDirectories: true)
|
||||
try data.write(to: Self.savePath)
|
||||
let end = Date.now
|
||||
logger.info("saved \(data.count) bytes to \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds")
|
||||
} catch {
|
||||
logger.error("error saving data: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ struct TermsOfUseView: View {
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Termos de Uso")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ struct PrivacyPolicyView: View {
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Privacidade")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -114,25 +114,17 @@ struct LoginView: View {
|
||||
|
||||
@MainActor
|
||||
private func applyHiddenStateWithoutAnimation() {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
heroVisible = false
|
||||
textVisible = false
|
||||
buttonVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showFinalStateWithoutAnimation() {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
heroVisible = true
|
||||
textVisible = true
|
||||
buttonVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runEntryAnimationIfNeeded(for token: Int) async {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -128,7 +128,7 @@ struct AddAddressFormView: View {
|
||||
.padding(.top, 0)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.appHiddenNavigationBar()
|
||||
.onAppear {
|
||||
populateFromExistingAddressIfNeeded()
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ struct AddressCard: View {
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.minimumScaleFactor(0.9)
|
||||
|
||||
if item.isPrimary {
|
||||
@@ -137,9 +136,7 @@ struct AddressCard: View {
|
||||
.font(AppTypography.overline)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct AddressesView: View {
|
||||
item: addressToListItem(address, isPrimary: isSelected),
|
||||
onEdit: { beginEditing(address) }
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if openSwipeRowId == rowId {
|
||||
openSwipeRowId = nil
|
||||
@@ -106,7 +106,7 @@ struct AddressesView: View {
|
||||
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.appHiddenNavigationBar()
|
||||
.sheet(isPresented: $openAddAddressForm) {
|
||||
NavigationStack {
|
||||
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -119,8 +119,8 @@ struct CheckoutView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Finalizar Pedido")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appInlineNavigationTitle()
|
||||
.appBottomSafeAreaInset {
|
||||
bottomBar
|
||||
}
|
||||
.task {
|
||||
@@ -433,7 +433,7 @@ struct CheckoutView: View {
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 14)
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture(perform: onTap)
|
||||
|
||||
content()
|
||||
@@ -659,7 +659,7 @@ struct PaymentPixView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pagamento via PIX")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.task {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
@@ -851,7 +851,7 @@ struct PaymentCardView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pagamento")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.task {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
@@ -879,8 +879,7 @@ struct PaymentCardView: View {
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
TextField(placeholder, text: text)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.appNoAutoCap()
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 46)
|
||||
.background(AppColors.backgroundLight)
|
||||
|
||||
235
pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
Normal file
235
pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FiltersModalView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var draftFilters: HomeFiltersState
|
||||
|
||||
init(appState: Binding<AppState>) {
|
||||
_appState = appState
|
||||
_draftFilters = State(initialValue: appState.wrappedValue.homeFilters)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
sortSection
|
||||
categoriesSection
|
||||
priceSection
|
||||
distanceSection
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
|
||||
applyButton
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.background(AppColors.backgroundLight)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
}
|
||||
|
||||
var header: some View {
|
||||
HStack {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Filtros")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Limpar") {
|
||||
draftFilters.reset()
|
||||
draftFilters.availableCategories = appState.homeFilters.availableCategories
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.secondary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 12)
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
}
|
||||
}
|
||||
|
||||
var sortSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Ordenar por")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(HomeSortOption.allCases) { option in
|
||||
Button {
|
||||
draftFilters.sortOption = option
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface)
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Image(systemName: option.icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted)
|
||||
)
|
||||
|
||||
Text(option.title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Circle()
|
||||
.stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(option == draftFilters.sortOption ? Color.black : Color.clear)
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 88)
|
||||
.background(Color.black.opacity(0.03))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var categoriesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Text("Categorias")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Text("Ver todas")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) {
|
||||
ForEach(draftFilters.availableCategories, id: \.self) { category in
|
||||
let isSelected = draftFilters.selectedCategories.contains(category)
|
||||
Button(category) {
|
||||
if isSelected {
|
||||
draftFilters.selectedCategories.remove(category)
|
||||
} else {
|
||||
draftFilters.selectedCategories.insert(category)
|
||||
}
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
|
||||
.overlay(
|
||||
Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var priceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
Text("Preço")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(HomePriceTier.allCases) { tier in
|
||||
let isSelected = draftFilters.selectedPriceTier == tier
|
||||
Button(tier.rawValue) {
|
||||
draftFilters.selectedPriceTier = isSelected ? nil : tier
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 68)
|
||||
.background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var distanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Divider().overlay(Color.black.opacity(0.08))
|
||||
|
||||
HStack {
|
||||
Text("Distância")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Text("Até \(Int(draftFilters.maxDistanceKm))km")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1)
|
||||
.tint(AppColors.tertiary)
|
||||
|
||||
HStack {
|
||||
Text("1km")
|
||||
Spacer()
|
||||
Text("10km")
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
var applyButton: some View {
|
||||
Button {
|
||||
appState.homeFilters.sortOption = draftFilters.sortOption
|
||||
appState.homeFilters.selectedCategories = draftFilters.selectedCategories
|
||||
appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier
|
||||
appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm
|
||||
appState.homeFilters.availableCategories = draftFilters.availableCategories
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Aplicar Filtros")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 62)
|
||||
.background(AppColors.tertiary.opacity(0.7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
216
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
Normal file
216
pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
|
||||
extension HomeView {
|
||||
var filteredStores: [StoreSummary] {
|
||||
let normalizedQuery = normalizeSearch(searchText)
|
||||
var list = stores
|
||||
|
||||
if appState.homeFilters.selectedCategories.isEmpty == false {
|
||||
let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch))
|
||||
list = list.filter { store in
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
return allowed.contains(category)
|
||||
}
|
||||
}
|
||||
|
||||
if let tier = appState.homeFilters.selectedPriceTier {
|
||||
list = list.filter { store in
|
||||
guard let fee = store.deliveryFee else { return false }
|
||||
return matchesPriceTier(fee: fee, tier: tier)
|
||||
}
|
||||
}
|
||||
|
||||
let maxDistance = appState.homeFilters.maxDistanceKm
|
||||
list = list.filter { store in
|
||||
guard let distance = store.distance else { return true }
|
||||
return distance <= maxDistance
|
||||
}
|
||||
|
||||
if normalizedQuery.isEmpty == false {
|
||||
list = list.filter { store in
|
||||
matchesSearch(store: store, query: normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return sortStores(list, query: normalizedQuery)
|
||||
}
|
||||
|
||||
var featuredStoresCards: [FeaturedStoreCardModel] {
|
||||
Array(filteredStores.prefix(5)).map(mapStoreToCard)
|
||||
}
|
||||
|
||||
var filteredStoreCards: [FeaturedStoreCardModel] {
|
||||
let featuredIds = Set(filteredStores.prefix(5).map(\.id))
|
||||
let remaining = filteredStores.filter { featuredIds.contains($0.id) == false }
|
||||
return Array(remaining.prefix(20)).map(mapStoreToCard)
|
||||
}
|
||||
|
||||
var emptyResultMessage: String {
|
||||
if normalizeSearch(searchText).isEmpty == false {
|
||||
return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"."
|
||||
}
|
||||
return "Nenhum estabelecimento encontrado com os filtros selecionados."
|
||||
}
|
||||
|
||||
func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
|
||||
let coverURL = resolveStoreMediaURL(store.cover)
|
||||
let logoURL = resolveStoreMediaURL(store.logo)
|
||||
return FeaturedStoreCardModel(
|
||||
id: store.id,
|
||||
name: store.name,
|
||||
rating: store.rating ?? 0,
|
||||
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
|
||||
distance: formatDistance(store.distance),
|
||||
category: store.category ?? "Loja",
|
||||
promoText: nil,
|
||||
isFavorite: appState.favorites.storeIds.contains(store.id),
|
||||
iconName: "storefront",
|
||||
imageURL: coverURL ?? logoURL,
|
||||
logoURL: logoURL,
|
||||
coverURL: coverURL,
|
||||
isOpen: store.isOpen ?? true,
|
||||
statusLabel: store.statusLabel
|
||||
)
|
||||
}
|
||||
|
||||
func resolveStoreMediaURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
var profilePictureURL: String? {
|
||||
resolveStoreMediaURL(appState.profile.profilePicture)
|
||||
}
|
||||
|
||||
func formatDistance(_ distance: Double?) -> String {
|
||||
guard let distance else { return "Distância indisponível" }
|
||||
if distance >= 1 {
|
||||
return String(format: "%.1f km", distance)
|
||||
}
|
||||
return "\(Int(distance * 1000)) m"
|
||||
}
|
||||
|
||||
func scheduleSearchIndexUpdate() {
|
||||
searchDebounceToken += 1
|
||||
let token = searchDebounceToken
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 220_000_000)
|
||||
guard token == searchDebounceToken else { return }
|
||||
await loadProductIndexForSearchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadProductIndexForSearchIfNeeded() async {
|
||||
let query = normalizeSearch(searchText)
|
||||
guard query.isEmpty == false else { return }
|
||||
|
||||
let candidates = filteredStores
|
||||
.filter { productSearchIndexByStoreId[$0.id] == nil }
|
||||
.prefix(10)
|
||||
|
||||
guard candidates.isEmpty == false else { return }
|
||||
|
||||
await withTaskGroup(of: (String, [String]?).self) { group in
|
||||
for store in candidates {
|
||||
group.addTask {
|
||||
do {
|
||||
let response = try await ApiService().storeCatalog(storeId: store.id)
|
||||
let products = response.result?.flatMap(\.products) ?? []
|
||||
let names = products.map(\.name)
|
||||
return (store.id, names)
|
||||
} catch {
|
||||
return (store.id, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
let names = result.1 ?? []
|
||||
productSearchIndexByStoreId[result.0] = names
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSearch(_ value: String) -> String {
|
||||
value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
func matchesSearch(store: StoreSummary, query: String) -> Bool {
|
||||
let storeName = normalizeSearch(store.name)
|
||||
if storeName.contains(query) {
|
||||
return true
|
||||
}
|
||||
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
if category.contains(query) {
|
||||
return true
|
||||
}
|
||||
|
||||
let products = productSearchIndexByStoreId[store.id] ?? []
|
||||
return products.contains { normalizeSearch($0).contains(query) }
|
||||
}
|
||||
|
||||
func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] {
|
||||
switch appState.homeFilters.sortOption {
|
||||
case .relevance:
|
||||
return list.sorted { lhs, rhs in
|
||||
let lhsScore = relevanceScore(for: lhs, query: query)
|
||||
let rhsScore = relevanceScore(for: rhs, query: query)
|
||||
if lhsScore != rhsScore {
|
||||
return lhsScore > rhsScore
|
||||
}
|
||||
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
|
||||
}
|
||||
case .rating:
|
||||
return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) }
|
||||
case .deliveryTime:
|
||||
return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) }
|
||||
case .price:
|
||||
return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) }
|
||||
}
|
||||
}
|
||||
|
||||
func relevanceScore(for store: StoreSummary, query: String) -> Double {
|
||||
guard query.isEmpty == false else {
|
||||
let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0)
|
||||
return positive + (store.rating ?? 0) * 10
|
||||
}
|
||||
|
||||
let name = normalizeSearch(store.name)
|
||||
let category = normalizeSearch(store.category ?? "")
|
||||
let products = productSearchIndexByStoreId[store.id] ?? []
|
||||
|
||||
var score = 0.0
|
||||
if name.hasPrefix(query) { score += 200 }
|
||||
if name.contains(query) { score += 120 }
|
||||
if category.contains(query) { score += 70 }
|
||||
if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 }
|
||||
score += (store.rating ?? 0) * 10
|
||||
score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02
|
||||
return score
|
||||
}
|
||||
|
||||
func estimatedDeliveryMinutes(_ value: String?) -> Int {
|
||||
guard let value else { return Int.max }
|
||||
let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined()
|
||||
let parts = digits
|
||||
.split(separator: " ")
|
||||
.compactMap { Int($0) }
|
||||
if let min = parts.min() {
|
||||
return min
|
||||
}
|
||||
return Int.max
|
||||
}
|
||||
|
||||
func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool {
|
||||
switch tier {
|
||||
case .low: return fee <= 5
|
||||
case .medium: return fee > 5 && fee <= 10
|
||||
case .high: return fee > 10 && fee <= 20
|
||||
case .veryHigh: return fee > 20
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import LCEssentials
|
||||
@@ -14,12 +13,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 +42,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 +62,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 +142,7 @@ struct HomeView: View {
|
||||
Task {
|
||||
await bootstrapStoresFlow(
|
||||
forceLocationRefresh: true,
|
||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
||||
category: selectedCategoryQueryValue,
|
||||
refreshCategories: true
|
||||
)
|
||||
}
|
||||
@@ -162,14 +151,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 +264,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 +307,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 +330,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 +361,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 +375,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 +411,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 +438,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
|
||||
@@ -511,15 +450,49 @@ struct HomeView: View {
|
||||
let display = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
|
||||
let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
|
||||
return "\(selected)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
|
||||
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
|
||||
let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil"
|
||||
let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil"
|
||||
let latKey = lat.map(formatCoordinateCache) ?? "nil"
|
||||
let lngKey = lng.map(formatCoordinateCache) ?? "nil"
|
||||
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
|
||||
}
|
||||
|
||||
private func formatCoordinateScope(_ value: Double) -> String {
|
||||
String((value * 100_000).rounded() / 100_000)
|
||||
}
|
||||
|
||||
private func formatCoordinateCache(_ value: Double) -> String {
|
||||
String((value * 10_000).rounded() / 10_000)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ struct OrderDetailsView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Detalhes do Pedido")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appInlineNavigationTitle()
|
||||
.appBottomSafeAreaInset {
|
||||
VStack {
|
||||
reorderButton
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -12,14 +12,17 @@ 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
|
||||
@State var didSaveReviewForCurrentOrder = false
|
||||
@State var reviewSavedObserver: Any?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -41,7 +44,7 @@ struct OrderTrackingView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||
Button("Fechar", role: .cancel) {}
|
||||
} message: {
|
||||
@@ -51,26 +54,49 @@ 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
|
||||
}
|
||||
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
||||
}
|
||||
.onAppear {
|
||||
attachReviewSavedObserverIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
tracker.stop()
|
||||
detachReviewSavedObserver()
|
||||
}
|
||||
.navigationDestination(item: $reviewDraft) { draft in
|
||||
MyReviewsView(initialOrder: draft)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { payload in
|
||||
}
|
||||
|
||||
private func attachReviewSavedObserverIfNeeded() {
|
||||
guard reviewSavedObserver == nil else { return }
|
||||
reviewSavedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .orderReviewDidSave,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { payload in
|
||||
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
|
||||
let currentOrderId = (order?.id ?? orderId).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let currentOrderId = (order?.id ?? orderId)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
|
||||
didSaveReviewForCurrentOrder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detachReviewSavedObserver() {
|
||||
guard let reviewSavedObserver else { return }
|
||||
NotificationCenter.default.removeObserver(reviewSavedObserver)
|
||||
self.reviewSavedObserver = nil
|
||||
}
|
||||
|
||||
private var topHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
@@ -224,7 +250,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)
|
||||
@@ -747,23 +775,82 @@ struct OrderTrackingView: View {
|
||||
|
||||
@MainActor
|
||||
private func loadInitialOrder() async {
|
||||
logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
|
||||
logger.info("OrderTracking initial fetch orderId=\(orderId)")
|
||||
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error {
|
||||
errorMessage = response.message ?? "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
|
||||
} 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)")
|
||||
logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
|
||||
await refreshStoreContactPhone(for: result)
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ struct OrdersView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meus Pedidos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.task {
|
||||
await loadOrdersIfNeeded()
|
||||
await refreshStoreRatings()
|
||||
@@ -137,7 +137,7 @@ struct OrdersView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
.buttonStyle(.plain)
|
||||
.layoutPriority(0)
|
||||
.appLayoutPriority(0)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
@@ -169,7 +169,7 @@ struct OrdersView: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.layoutPriority(2)
|
||||
.appLayoutPriority(2)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
@@ -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
|
||||
@@ -716,16 +719,16 @@ struct OrderEntryDestinationView: View {
|
||||
|
||||
@MainActor
|
||||
func fetchOrderForRouting() async -> PublicOrderResult? {
|
||||
logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)")
|
||||
logger.info("OrderEntry fetch route orderId=\(orderId)")
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error == false, let result = response.result {
|
||||
logger.info("OrderEntry fetch route success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
|
||||
logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
|
||||
return result
|
||||
}
|
||||
logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")")
|
||||
} catch {
|
||||
logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -78,14 +78,12 @@ struct PizzaFlavorAddonsSheet: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Adicionais")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Concluir") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func increment(_ addonId: String) {
|
||||
quantities[addonId, default: 0] += 1
|
||||
|
||||
@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(Array(sizeItems.indices), id: \.self) { index in
|
||||
ForEach(0..<sizeItems.count, id: \.self) { index in
|
||||
sizeRow(sizeItems[index])
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ extension PizzaProductDetailSheet {
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(Array(flavorItems.indices), id: \.self) { index in
|
||||
ForEach(0..<flavorItems.count, id: \.self) { index in
|
||||
let flavor = flavorItems[index]
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
@@ -118,7 +118,7 @@ extension PizzaProductDetailSheet {
|
||||
.disabled(disableSwitch)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelected else { return }
|
||||
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
||||
@@ -172,7 +172,7 @@ extension PizzaProductDetailSheet {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ struct PizzaProductDetailSheet: View {
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
||||
@@ -292,13 +292,11 @@ struct PizzaProductDetailSheet: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Monte sua pizza")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Fechar") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
applyAutoSelections()
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
|
||||
@@ -164,7 +164,7 @@ struct ProductDetailSheet: View {
|
||||
.padding(20)
|
||||
.padding(.bottom, 80)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 0 { quantity -= 1 } }) {
|
||||
@@ -222,13 +222,11 @@ struct ProductDetailSheet: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Detalhes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Fechar") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
quantity = existing > 0 ? existing : 1
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
import LCEssentials
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
@@ -89,7 +91,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight.opacity(0.8))
|
||||
.frame(height: tabBarClearance)
|
||||
|
||||
@@ -67,7 +67,7 @@ struct MyReviewsView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Minhas Avaliações")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.navigationDestination(item: $selectedDraft) { draft in
|
||||
OrderReviewView(draft: draft) {
|
||||
Task { await loadReviewsFromBackend(forceRefresh: true) }
|
||||
@@ -451,7 +451,7 @@ struct OrderReviewView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Avaliar Pedido")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.onAppear {
|
||||
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
|
||||
existingReview = cachedReview
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
import LCEssentials
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
@@ -48,6 +50,15 @@ struct StoreDetailView: View {
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
#if os(Android)
|
||||
LazyVStack(spacing: 0) {
|
||||
topSection
|
||||
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||
|
||||
sectionedProducts
|
||||
}
|
||||
#else
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
topSection
|
||||
|
||||
@@ -55,6 +66,7 @@ struct StoreDetailView: View {
|
||||
|
||||
sectionedProducts
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.refreshable {
|
||||
await loadStoreData(forceRefresh: true)
|
||||
@@ -74,8 +86,7 @@ struct StoreDetailView: View {
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.appHiddenNavigationBar()
|
||||
.task {
|
||||
guard didLoad == false else { return }
|
||||
didLoad = true
|
||||
@@ -263,6 +274,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)
|
||||
@@ -469,7 +482,7 @@ struct StoreDetailView: View {
|
||||
}
|
||||
.offset(x: 3, y: -3)
|
||||
.frame(width: 30, height: 30)
|
||||
.contentShape(Circle())
|
||||
.appContentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 30, height: 30)
|
||||
@@ -481,7 +494,7 @@ struct StoreDetailView: View {
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.onTapGesture {
|
||||
guard isStoreOpen else {
|
||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#if os(Android)
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
@Binding var appState: AppState
|
||||
|
||||
@State var name: String = ""
|
||||
@State var email: String = ""
|
||||
@State var phone: String = ""
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
avatarSection
|
||||
formSection
|
||||
saveButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meu Perfil")
|
||||
.appInlineNavigationTitle()
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 110, height: 110)
|
||||
.overlay {
|
||||
Text(initials)
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
|
||||
Button("Remover") {
|
||||
profilePicture = ""
|
||||
}
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(profilePicture.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
|
||||
|
||||
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
|
||||
.appNoAutoCap()
|
||||
|
||||
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(displayPhoneDigits(newValue))
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
||||
Task { await saveProfile() }
|
||||
}
|
||||
.font(AppTypography.button)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSaving || canSave == false)
|
||||
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
let letters = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return letters.isEmpty ? "PF" : letters.uppercased()
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
return cleanName.isEmpty == false
|
||||
&& cleanEmail.isEmpty == false
|
||||
&& cleanEmail.contains("@")
|
||||
&& normalizedPhone.isEmpty == false
|
||||
}
|
||||
|
||||
private func hydrateFromAppState() {
|
||||
name = appState.profile.name
|
||||
email = appState.profile.email
|
||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||
profilePicture = appState.profile.profilePicture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveProfile() async {
|
||||
guard canSave else { return }
|
||||
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().updateCustomerProfile(
|
||||
name: cleanName,
|
||||
email: cleanEmail,
|
||||
phoneNumber: normalizedPhone,
|
||||
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
|
||||
)
|
||||
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let customer = response.result
|
||||
appState.profile.id = customer?.id ?? appState.profile.id
|
||||
appState.profile.name = customer?.name ?? cleanName
|
||||
appState.profile.email = customer?.email ?? cleanEmail
|
||||
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
|
||||
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
|
||||
)
|
||||
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Perfil atualizado com sucesso.",
|
||||
style: .success,
|
||||
icon: "checkmark.circle.fill",
|
||||
duration: 2.0
|
||||
)
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatPhoneForDisplay(_ raw: String) -> String {
|
||||
let digits = displayPhoneDigits(raw)
|
||||
if digits.isEmpty { return "" }
|
||||
return formatPhoneBR(digits)
|
||||
}
|
||||
|
||||
private func displayPhoneDigits(_ raw: String) -> String {
|
||||
var digits = raw.filter(\.isNumber)
|
||||
if digits.hasPrefix("55"), digits.count > 11 {
|
||||
digits = String(digits.dropFirst(2))
|
||||
}
|
||||
return String(digits.prefix(11))
|
||||
}
|
||||
|
||||
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
TextField(placeholder, text: text)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 50)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if !os(Android)
|
||||
import SwiftUI
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
import PhotosUI
|
||||
@@ -33,7 +34,7 @@ struct UserProfileView: View {
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meu Perfil")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appInlineNavigationTitle()
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
@@ -267,3 +268,4 @@ struct UserProfileView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
320
skip_for_android_plan.md
Normal file
320
skip_for_android_plan.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Plano de Ação - Skip para Android
|
||||
|
||||
## Objetivo
|
||||
|
||||
Preparar o projeto `PediFoods` para o primeiro ciclo real de testes no Android, preservando integralmente o funcionamento atual do iOS.
|
||||
|
||||
Regra principal deste plano:
|
||||
|
||||
1. Todo ajuste necessário para Android deve ser isolado e não pode causar regressão no iOS.
|
||||
2. Sempre que possível, a separação deve ser feita com condicionais por plataforma, abstrações compatíveis ou implementações específicas para Android.
|
||||
3. Cada task concluída e validada deve gerar commit próprio.
|
||||
|
||||
## Premissas confirmadas
|
||||
|
||||
1. O projeto já possui base Skip dual-platform.
|
||||
2. O iOS está funcional e deve ser preservado como referência de comportamento.
|
||||
3. O Android já possui estrutura inicial pronta:
|
||||
- `pedi-foods/Android/*`
|
||||
- `pedi-foods/Android/app/src/main/kotlin/Main.kt`
|
||||
- `pedi-foods/Android/app/src/main/AndroidManifest.xml`
|
||||
- `pedi-foods/Sources/PediFoods/Skip/skip.yml`
|
||||
4. O objetivo imediato não é publicar Android, e sim conseguir compilar, instalar, abrir e validar os fluxos principais.
|
||||
|
||||
## Diagnóstico atual
|
||||
|
||||
### 1. Estrutura Android existente
|
||||
|
||||
O projeto já possui:
|
||||
|
||||
1. Manifest com permissões de internet e localização.
|
||||
2. `Main.kt` com tratamento inicial de permissão de localização no Android.
|
||||
3. Script dedicado para exportar e rodar no Android:
|
||||
- `pedi-foods/scripts/android-run.sh`
|
||||
4. Recursos Android básicos como ícones launcher.
|
||||
|
||||
### 2. Riscos identificados
|
||||
|
||||
Os principais riscos para o primeiro teste Android são:
|
||||
|
||||
1. Imports e dependências iOS-only ainda expostos em arquivos compartilhados.
|
||||
2. Build local atualmente bloqueado por problema de artefato/toolchain Skip.
|
||||
3. Recursos e comportamentos com paridade incompleta no Android.
|
||||
4. Cobertura de testes ainda insuficiente para confiar só em automação.
|
||||
|
||||
### 3. Bloqueios técnicos já identificados
|
||||
|
||||
#### 3.1. Código compartilhado com dependência iOS-only
|
||||
|
||||
Foram encontrados pontos com alto risco para Android:
|
||||
|
||||
1. `pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift`
|
||||
- possui `import LCEssentials` sem blindagem de plataforma.
|
||||
2. `pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift`
|
||||
- possui `import LCEssentials` sem blindagem de plataforma.
|
||||
3. Outros arquivos com dependências iOS-only exigem revisão cuidadosa:
|
||||
- `pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift`
|
||||
- `pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift`
|
||||
- `pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift`
|
||||
- `pedi-foods/Sources/PediFoods/Services/ApiClient.swift`
|
||||
|
||||
#### 3.2. Ambiente/build Skip inconsistente
|
||||
|
||||
Na validação local, o comando de build falhou antes da compilação funcional:
|
||||
|
||||
1. `swift build` retornou erro de artefato ausente do Skip.
|
||||
2. O erro aponta para caminho antigo de projeto, indicando problema de cache, artefato ou configuração local do ambiente.
|
||||
|
||||
Sem resolver isso, não é possível validar corretamente o Android.
|
||||
|
||||
#### 3.3. Paridade Android ainda parcial
|
||||
|
||||
Alguns comportamentos parecem estar deliberadamente simplificados para Android:
|
||||
|
||||
1. Snackbar no Android está atualmente neutro/stubado.
|
||||
2. Splash nativo Android ainda não aparenta estar finalizado.
|
||||
3. Recursos visuais e alguns fluxos podem abrir, mas não com equivalência total ao iOS.
|
||||
|
||||
Isso não impede o primeiro boot, mas entra como ajuste após a compilação inicial.
|
||||
|
||||
## Estratégia de execução
|
||||
|
||||
A execução deve seguir uma ordem rígida para evitar retrabalho:
|
||||
|
||||
1. Primeiro remover bloqueios de compilação cross-platform.
|
||||
2. Depois estabilizar o ambiente Skip/Android local.
|
||||
3. Em seguida gerar build Android real.
|
||||
4. Depois validar execução no device/emulador.
|
||||
5. Só então ajustar bugs e diferenças de comportamento específicas do Android.
|
||||
6. Ao fim de cada etapa funcional concluída, gerar commit.
|
||||
|
||||
## Plano de ação detalhado
|
||||
|
||||
### Fase 1 - Blindagem do código compartilhado para Android
|
||||
|
||||
Objetivo:
|
||||
Garantir que o código compartilhado consiga ser compilado para Android sem quebrar o iOS.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Revisar todos os arquivos compartilhados com:
|
||||
- `import LCEssentials`
|
||||
- `import UIKit`
|
||||
- `import PhotosUI`
|
||||
- qualquer API não portável pelo Skip
|
||||
2. Encapsular uso iOS-only com:
|
||||
- `#if os(iOS)`
|
||||
- `#if canImport(...)`
|
||||
- abstrações seguras por plataforma
|
||||
3. Confirmar que nenhum ajuste Android altere o comportamento já funcional do iOS.
|
||||
4. Validar se os fallbacks Android são suficientes para compilar.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. O código compartilhado deixa de possuir bloqueios óbvios de compilação Android.
|
||||
2. As dependências iOS-only ficam totalmente isoladas.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit específico da blindagem cross-platform.
|
||||
|
||||
### Fase 2 - Saneamento do ambiente Skip/Android
|
||||
|
||||
Objetivo:
|
||||
Restabelecer o pipeline de build para que a validação Android seja real e repetível.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Identificar a origem do erro de artefato do Skip.
|
||||
2. Verificar caches, artefatos e referências para caminho antigo do projeto.
|
||||
3. Corrigir ambiente local sem destruir configuração válida do iOS.
|
||||
4. Validar novamente:
|
||||
- `swift build`
|
||||
- fluxo de export Android via Skip
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. O build compartilhado executa sem erro de artefato.
|
||||
2. O projeto consegue iniciar a etapa de export Android.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit específico do ajuste de ambiente/configuração, se houver mudança versionada no repositório.
|
||||
2. Se a correção for apenas local e não versionável, registrar isso no andamento e seguir sem commit desta subparte.
|
||||
|
||||
### Fase 3 - Primeiro build Android real
|
||||
|
||||
Objetivo:
|
||||
Gerar APK/build Android funcional para teste.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Rodar export/build Android pelo fluxo oficial do projeto.
|
||||
2. Validar geração dos artefatos Android esperados.
|
||||
3. Confirmar compatibilidade com ABI do emulador/device.
|
||||
4. Corrigir falhas de build especificamente Android sem alterar o caminho iOS.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. APK ou build instalável gerado com sucesso.
|
||||
2. Nenhum erro de compilação Android pendente.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit específico do primeiro build Android estabilizado.
|
||||
|
||||
### Fase 4 - Primeiro teste funcional no Android
|
||||
|
||||
Objetivo:
|
||||
Instalar, abrir e validar o app no Android pela primeira vez.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Instalar no emulador ou device.
|
||||
2. Validar abertura do app.
|
||||
3. Confirmar:
|
||||
- splash inicial
|
||||
- fluxo de login
|
||||
- navegação principal por tabs
|
||||
- home
|
||||
- detalhe da loja
|
||||
- perfil
|
||||
- endereços
|
||||
4. Observar crashes, telas em branco, problemas de navegação ou layout quebrado.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. O app abre no Android.
|
||||
2. Os fluxos principais navegam sem crash.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit específico do primeiro ciclo funcional de execução Android.
|
||||
|
||||
### Fase 5 - Correções de runtime específicas do Android
|
||||
|
||||
Objetivo:
|
||||
Ajustar o que só aparece após o app abrir no Android.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Validar permissão de localização.
|
||||
2. Validar persistência/local cache usados pelo app no Android.
|
||||
3. Validar imagens e carregamento remoto.
|
||||
4. Validar teclado, foco e `adjustResize`.
|
||||
5. Validar sheets, modais, overlays e navegação.
|
||||
6. Corrigir bugs Android-only com isolamento por plataforma.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. Os fluxos principais deixam de ter falhas críticas no Android.
|
||||
2. Nenhuma correção Android impacta o iOS.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commits separados por grupo funcional concluído e validado.
|
||||
|
||||
### Fase 6 - Paridade mínima de UX Android
|
||||
|
||||
Objetivo:
|
||||
Atingir um nível mínimo aceitável de experiência para teste Android sem buscar perfeição visual prematura.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Ajustar snackbar Android para comportamento visível e funcional.
|
||||
2. Revisar splash Android nativo, se necessário.
|
||||
3. Corrigir diferenças de layout mais críticas.
|
||||
4. Revisar componentes com comportamento visual diferente entre plataformas.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. O Android fica funcional e utilizável para teste contínuo.
|
||||
2. As diferenças visuais restantes não impedem validação do produto.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit específico das melhorias de UX Android aprovadas.
|
||||
|
||||
### Fase 7 - Regressão rápida no iOS
|
||||
|
||||
Objetivo:
|
||||
Garantir que os ajustes Android não introduziram regressões no iOS.
|
||||
|
||||
Ações:
|
||||
|
||||
1. Rodar build iOS novamente.
|
||||
2. Validar navegação principal no iOS.
|
||||
3. Confirmar que os fluxos já funcionais continuam intactos.
|
||||
4. Revisar especialmente os pontos alterados com condicionais de plataforma.
|
||||
|
||||
Critério de conclusão:
|
||||
|
||||
1. iOS continua funcional após os ajustes Android.
|
||||
2. Nenhuma regressão relevante é detectada.
|
||||
|
||||
Commit ao concluir:
|
||||
|
||||
1. Criar commit final de consolidação apenas se houver mudanças adicionais nesta etapa.
|
||||
|
||||
## Regras operacionais durante a execução
|
||||
|
||||
1. Nenhum arquivo pode ultrapassar 500 linhas.
|
||||
2. Ajustes Android devem ser isolados e não devem reescrever a lógica funcional do iOS.
|
||||
3. Toda correção deve passar por validação antes de seguir para a próxima task.
|
||||
4. Cada task funcional concluída deve resultar em commit.
|
||||
5. Se uma task encontrar erro, ela retorna ao ciclo:
|
||||
- identificar causa raiz
|
||||
- corrigir
|
||||
- testar novamente
|
||||
- só então marcar como concluída
|
||||
|
||||
## Convenção de commits
|
||||
|
||||
Padrão recomendado:
|
||||
|
||||
1. `fix(android): isolate ios-only dependencies for skip build`
|
||||
2. `fix(build): stabilize skip android export pipeline`
|
||||
3. `feat(android): first successful android build`
|
||||
4. `fix(android): resolve runtime issues on first boot`
|
||||
5. `feat(android): improve minimum ux parity`
|
||||
|
||||
## Sequência de tasks para acompanhamento
|
||||
|
||||
Legenda:
|
||||
|
||||
- 🔴 Pendente
|
||||
- 🟢 Concluído
|
||||
|
||||
1. 🟢 Revisar e blindar imports/dependências iOS-only no código compartilhado.
|
||||
2. 🟢 Corrigir o problema de artefato/caminho do ambiente Skip local.
|
||||
3. 🟢 Validar `swift build` sem erro de ambiente.
|
||||
4. 🟢 Validar export/build Android via Skip.
|
||||
5. 🟢 Gerar APK/build Android instalável.
|
||||
6. 🔴 Instalar e abrir o app no Android.
|
||||
7. 🔴 Validar login no Android.
|
||||
8. 🔴 Validar home e navegação principal no Android.
|
||||
9. 🔴 Validar detalhe de loja e carrinho no Android.
|
||||
10. 🔴 Validar perfil e endereços no Android.
|
||||
11. 🔴 Validar permissão e uso de localização no Android.
|
||||
12. 🔴 Corrigir bugs críticos de runtime Android.
|
||||
13. 🔴 Ajustar snackbar Android.
|
||||
14. 🔴 Revisar splash Android, se necessário.
|
||||
15. 🔴 Rodar regressão rápida no iOS após os ajustes Android.
|
||||
16. 🟢 Criar commit após cada task concluída e funcional.
|
||||
|
||||
## Andamento
|
||||
|
||||
1. Task 1 concluída e validada via build compartilhado.
|
||||
2. Task 2 concluída com ajuste local no cache/estado de build do Skip.
|
||||
- Correção local não versionada.
|
||||
3. Task 3 concluída com `swift build` finalizado com sucesso.
|
||||
4. Task 4 concluída com `skip export --debug --no-ios --android --arch aarch64 --plain --verbose`.
|
||||
- O `skip export` fechou com sucesso completo.
|
||||
- O pipeline passou pela recompilação Swift compartilhada, geração Android Skip, `assembleDebug`, `bundleDebug` e export final.
|
||||
- Artefatos gerados com sucesso em `pedi-foods/.build/skip-export/`:
|
||||
- `PediFoods-debug.apk`
|
||||
- `PediFoods-debug.aab`
|
||||
- `PediFoods-project.zip`
|
||||
5. Task 5 concluída com geração validada de artefatos Android instaláveis.
|
||||
- APK exportado com sucesso.
|
||||
- AAB exportado com sucesso.
|
||||
744
subdomain_stores_plan.md
Normal file
744
subdomain_stores_plan.md
Normal 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
|
||||
Reference in New Issue
Block a user