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"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
||||||
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
||||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase 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; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
49F90C2B2A52156200F06D93 /* Main.swift */,
|
49F90C2B2A52156200F06D93 /* Main.swift */,
|
||||||
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
|
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
|
||||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
||||||
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
||||||
4900101C2BACEA710000DE33 /* Info.plist */,
|
4900101C2BACEA710000DE33 /* Info.plist */,
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
|
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
|
||||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
||||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
@@ -214,6 +214,8 @@
|
|||||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
};
|
};
|
||||||
@@ -226,6 +228,8 @@
|
|||||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@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 ttl: TimeInterval
|
||||||
let placeholder: Placeholder
|
let placeholder: Placeholder
|
||||||
|
|
||||||
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
@StateObject var loader = CachedRemoteImageLoader()
|
@StateObject var loader = CachedRemoteImageLoader()
|
||||||
|
#endif
|
||||||
|
|
||||||
init(
|
init(
|
||||||
imageURL: String?,
|
imageURL: String?,
|
||||||
@@ -60,14 +62,19 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
loader.load(imageURL: imageURL, ttl: ttl)
|
loader.load(imageURL: imageURL, ttl: ttl)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.onChange(of: imageURL) { _, newValue in
|
.onChange(of: imageURL) { _, newValue in
|
||||||
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
loader.load(imageURL: newValue, ttl: ttl)
|
loader.load(imageURL: newValue, ttl: ttl)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CachedRemoteImageLoader: ObservableObject {
|
final class CachedRemoteImageLoader: ObservableObject {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
@@ -174,3 +181,4 @@ final class CachedRemoteImageLoader: ObservableObject {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct SnackbarOverlay: View {
|
|||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(message.style.backgroundColor)
|
.background(message.style.backgroundColor)
|
||||||
.contentShape(Rectangle())
|
.appContentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
center.handleTap()
|
center.handleTap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
#endif
|
#endif
|
||||||
|
@State var cartResetObserver: Any?
|
||||||
#if os(Android)
|
#if os(Android)
|
||||||
@State var snackbarCenter = SnackbarCenter.shared
|
@State var snackbarCenter = SnackbarCenter.shared
|
||||||
#else
|
#else
|
||||||
@@ -54,7 +55,7 @@ struct ContentView: View {
|
|||||||
case .addressPicker:
|
case .addressPicker:
|
||||||
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
||||||
case .filters:
|
case .filters:
|
||||||
FiltersModalView()
|
FiltersModalView(appState: $appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: appState.address.display) { _, _ in
|
.onChange(of: appState.address.display) { _, _ in
|
||||||
@@ -69,9 +70,6 @@ struct ContentView: View {
|
|||||||
.onChange(of: appState.address.longitude) { _, _ in
|
.onChange(of: appState.address.longitude) { _, _ in
|
||||||
dismissAddressPickerIfAddressExists()
|
dismissAddressPickerIfAddressExists()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
|
|
||||||
appState.cart = CartState()
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
await performInitialLaunchBootstrap()
|
await performInitialLaunchBootstrap()
|
||||||
}
|
}
|
||||||
@@ -83,11 +81,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
attachCartResetObserverIfNeeded()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
detachCartResetObserver()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
#endif
|
#endif
|
||||||
@@ -182,6 +182,23 @@ struct ContentView: View {
|
|||||||
isBootstrappingSession = false
|
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
|
@MainActor
|
||||||
private func hydrateAppState(with customer: CustomerProfile) {
|
private func hydrateAppState(with customer: CustomerProfile) {
|
||||||
appState.profile.id = customer.id
|
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.",
|
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"1km" : {
|
||||||
|
"comment" : "A label for the minimum distance filter option.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"2. Massa" : {
|
"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" : {
|
"Acompanhamento em tempo real" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -178,6 +186,10 @@
|
|||||||
"comment" : "A button that applies a coupon code to a cart.",
|
"comment" : "A button that applies a coupon code to a cart.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Aplicar Filtros" : {
|
||||||
|
"comment" : "A button label that translates to \"Apply Filters\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Appearance" : {
|
"Appearance" : {
|
||||||
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
|
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
|
||||||
"extractionState" : "stale",
|
"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..." : {
|
"Atualizando status do pedido..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -290,6 +306,10 @@
|
|||||||
"comment" : "A label for the cart section of the app.",
|
"comment" : "A label for the cart section of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Categorias" : {
|
||||||
|
"comment" : "A heading for the list of available categories in the filters modal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Categories" : {
|
"Categories" : {
|
||||||
"comment" : "A section header that lists available food categories.",
|
"comment" : "A section header that lists available food categories.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -414,10 +434,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"DELIVERY LOCATION" : {
|
|
||||||
"comment" : "A label describing the delivery location section.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Desconto" : {
|
"Desconto" : {
|
||||||
"comment" : "A label for a discount applied to an order.",
|
"comment" : "A label for a discount applied to an order.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -428,6 +444,9 @@
|
|||||||
},
|
},
|
||||||
"Detalhes do Pedido" : {
|
"Detalhes do Pedido" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Distância" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Do que você gostou?" : {
|
"Do que você gostou?" : {
|
||||||
"comment" : "A prompt asking users to mention what they liked about a review.",
|
"comment" : "A prompt asking users to mention what they liked about a review.",
|
||||||
@@ -452,12 +471,15 @@
|
|||||||
"comment" : "A text describing delivery mode.",
|
"comment" : "A text describing delivery mode.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"ENTREGAR EM:" : {
|
||||||
|
"comment" : "A label displayed next to the button that lets the user select an address.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enviar Avaliação" : {
|
"Enviar Avaliação" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
|
"Enviaremos um código de verificação por SMS \ne 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
|
|
||||||
},
|
},
|
||||||
"Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : {
|
"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.",
|
"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.",
|
"comment" : "A label for the filter options in the modal.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Finalizar Pedido" : {
|
||||||
"comment" : "The title of the checkout screen.",
|
"comment" : "The title of the checkout screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -637,6 +655,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Limpar" : {
|
||||||
|
"comment" : "A button that clears all filters when pressed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Limpar carrinho e adicionar" : {
|
"Limpar carrinho e adicionar" : {
|
||||||
"comment" : "A button that clears the current cart and adds the selected product.",
|
"comment" : "A button that clears the current cart and adds the selected product.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -719,10 +741,6 @@
|
|||||||
"comment" : "A message displayed when a user has no addresses saved.",
|
"comment" : "A message displayed when a user has no addresses saved.",
|
||||||
"isCommentAutoGenerated" : true
|
"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." : {
|
"Nenhum pedido encontrado." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -795,6 +813,10 @@
|
|||||||
"comment" : "A label indicating a required field.",
|
"comment" : "A label indicating a required field.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Ordenar por" : {
|
||||||
|
"comment" : "A label describing the sorting options available in the filters modal.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Pagamento" : {
|
"Pagamento" : {
|
||||||
"comment" : "The title of the screen where a user can enter and save payment information.",
|
"comment" : "The title of the screen where a user can enter and save payment information.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -803,10 +825,6 @@
|
|||||||
"comment" : "The navigation title for the PaymentPixView.",
|
"comment" : "The navigation title for the PaymentPixView.",
|
||||||
"isCommentAutoGenerated" : true
|
"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 #%@" : {
|
"Pedido #%@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -875,6 +893,10 @@
|
|||||||
},
|
},
|
||||||
"Precisa de ajuda com esse pedido?" : {
|
"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." : {
|
"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.",
|
"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.",
|
"comment" : "A button label that translates to \"View Profile\" in English.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Ver todas" : {
|
||||||
|
"comment" : "A link that allows users to see all available categories in the filters.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Verificação" : {
|
"Verificação" : {
|
||||||
"comment" : "A title for the OTP verification screen.",
|
"comment" : "A title for the OTP verification screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ struct StoreInfoResult: Decodable {
|
|||||||
let isOpen: Bool?
|
let isOpen: Bool?
|
||||||
let statusLabel: String?
|
let statusLabel: String?
|
||||||
let fantasyName: String?
|
let fantasyName: String?
|
||||||
|
let phone: String?
|
||||||
|
let whatsapp: String?
|
||||||
let logo: String?
|
let logo: String?
|
||||||
let cover: String?
|
let cover: String?
|
||||||
let deliveryTime: String?
|
let deliveryTime: String?
|
||||||
@@ -126,6 +128,8 @@ struct StoreInfoResult: Decodable {
|
|||||||
case isOpen
|
case isOpen
|
||||||
case statusLabel
|
case statusLabel
|
||||||
case fantasyName
|
case fantasyName
|
||||||
|
case phone
|
||||||
|
case whatsapp
|
||||||
case logo
|
case logo
|
||||||
case cover
|
case cover
|
||||||
case deliveryTime
|
case deliveryTime
|
||||||
@@ -139,6 +143,8 @@ struct StoreInfoResult: Decodable {
|
|||||||
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
||||||
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
||||||
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
|
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)
|
logo = try? container.decode(String.self, forKey: .logo)
|
||||||
cover = try? container.decode(String.self, forKey: .cover)
|
cover = try? container.decode(String.self, forKey: .cover)
|
||||||
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct AppOrderSummary: Decodable, Identifiable {
|
|||||||
let paymentMethod: String?
|
let paymentMethod: String?
|
||||||
let deliveryType: String?
|
let deliveryType: String?
|
||||||
let storeName: String?
|
let storeName: String?
|
||||||
|
let storePhone: String?
|
||||||
let storeLogoURL: String?
|
let storeLogoURL: String?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
let updatedAt: String?
|
let updatedAt: String?
|
||||||
@@ -35,6 +36,7 @@ struct AppOrderSummary: Decodable, Identifiable {
|
|||||||
case paymentMethod
|
case paymentMethod
|
||||||
case deliveryType
|
case deliveryType
|
||||||
case storeName
|
case storeName
|
||||||
|
case storePhone
|
||||||
case storeLogo
|
case storeLogo
|
||||||
case store_logo
|
case store_logo
|
||||||
case logo
|
case logo
|
||||||
@@ -59,6 +61,7 @@ struct AppOrderSummary: Decodable, Identifiable {
|
|||||||
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||||
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
|
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
|
||||||
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||||
|
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
|
||||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
||||||
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
||||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
|
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
|
||||||
@@ -86,6 +89,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
let discount: Double?
|
let discount: Double?
|
||||||
let total: Double?
|
let total: Double?
|
||||||
let storeName: String?
|
let storeName: String?
|
||||||
|
let storePhone: String?
|
||||||
let storeLogoURL: String?
|
let storeLogoURL: String?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
let updatedAt: String?
|
let updatedAt: String?
|
||||||
@@ -125,6 +129,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
case couponDiscount
|
case couponDiscount
|
||||||
case total
|
case total
|
||||||
case storeName
|
case storeName
|
||||||
|
case storePhone
|
||||||
case storeLogo
|
case storeLogo
|
||||||
case store_logo
|
case store_logo
|
||||||
case logo
|
case logo
|
||||||
@@ -165,6 +170,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
discount: Double? = nil,
|
discount: Double? = nil,
|
||||||
total: Double? = nil,
|
total: Double? = nil,
|
||||||
storeName: String? = nil,
|
storeName: String? = nil,
|
||||||
|
storePhone: String? = nil,
|
||||||
storeLogoURL: String? = nil,
|
storeLogoURL: String? = nil,
|
||||||
createdAt: String? = nil,
|
createdAt: String? = nil,
|
||||||
updatedAt: String? = nil,
|
updatedAt: String? = nil,
|
||||||
@@ -196,6 +202,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
self.discount = discount
|
self.discount = discount
|
||||||
self.total = total
|
self.total = total
|
||||||
self.storeName = storeName
|
self.storeName = storeName
|
||||||
|
self.storePhone = storePhone
|
||||||
self.storeLogoURL = storeLogoURL
|
self.storeLogoURL = storeLogoURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
@@ -240,6 +247,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
|
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
|
||||||
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
||||||
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||||
|
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
|
||||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
||||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
|
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
|
||||||
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
||||||
@@ -279,6 +287,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
try container.encodeIfPresent(discount, forKey: .discount)
|
try container.encodeIfPresent(discount, forKey: .discount)
|
||||||
try container.encodeIfPresent(total, forKey: .total)
|
try container.encodeIfPresent(total, forKey: .total)
|
||||||
try container.encodeIfPresent(storeName, forKey: .storeName)
|
try container.encodeIfPresent(storeName, forKey: .storeName)
|
||||||
|
try container.encodeIfPresent(storePhone, forKey: .storePhone)
|
||||||
try container.encodeIfPresent(createdAt, forKey: .createdAt)
|
try container.encodeIfPresent(createdAt, forKey: .createdAt)
|
||||||
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||||
try container.encodeIfPresent(otp, forKey: .otp)
|
try container.encodeIfPresent(otp, forKey: .otp)
|
||||||
|
|||||||
@@ -76,18 +76,18 @@ final class OrderRealtimeTracker {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
|
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
|
||||||
do {
|
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)
|
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||||
guard response.error == false, let order = response.result else {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
clearPendingCartIfNeeded(for: order)
|
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)
|
onOrderUpdated?(order)
|
||||||
return order
|
return order
|
||||||
} catch {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct AppState {
|
|||||||
var cart = CartState()
|
var cart = CartState()
|
||||||
var address = AddressState()
|
var address = AddressState()
|
||||||
var favorites = FavoritesState()
|
var favorites = FavoritesState()
|
||||||
|
var homeFilters = HomeFiltersState()
|
||||||
var activeModal: AppModal? = nil
|
var activeModal: AppModal? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,57 @@ struct FavoritesState {
|
|||||||
var storeIds: Set<String> = []
|
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 {
|
struct CartState {
|
||||||
var storeId: String? = nil
|
var storeId: String? = nil
|
||||||
var storeName: 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())
|
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||||
.navigationTitle("Termos de Uso")
|
.navigationTitle("Termos de Uso")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ struct PrivacyPolicyView: View {
|
|||||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||||
.navigationTitle("Privacidade")
|
.navigationTitle("Privacidade")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct LoginEmailView: View {
|
|||||||
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
|
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
|
||||||
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.gray)
|
.foregroundStyle(Color.gray)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -114,24 +114,16 @@ struct LoginView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyHiddenStateWithoutAnimation() {
|
private func applyHiddenStateWithoutAnimation() {
|
||||||
var transaction = Transaction()
|
heroVisible = false
|
||||||
transaction.disablesAnimations = true
|
textVisible = false
|
||||||
withTransaction(transaction) {
|
buttonVisible = false
|
||||||
heroVisible = false
|
|
||||||
textVisible = false
|
|
||||||
buttonVisible = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func showFinalStateWithoutAnimation() {
|
private func showFinalStateWithoutAnimation() {
|
||||||
var transaction = Transaction()
|
heroVisible = true
|
||||||
transaction.disablesAnimations = true
|
textVisible = true
|
||||||
withTransaction(transaction) {
|
buttonVisible = true
|
||||||
heroVisible = true
|
|
||||||
textVisible = true
|
|
||||||
buttonVisible = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct OtpView: View {
|
|||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
|
|
||||||
Text("para \(email)")
|
Text(otpDeliveryMessage)
|
||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -208,6 +208,29 @@ struct OtpView: View {
|
|||||||
#endif
|
#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() {
|
private func validateOtp() {
|
||||||
let code = otp.filter(\.isNumber)
|
let code = otp.filter(\.isNumber)
|
||||||
guard code.count == 8 else { return }
|
guard code.count == 8 else { return }
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ struct AddAddressFormView: View {
|
|||||||
.padding(.top, 0)
|
.padding(.top, 0)
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.appHiddenNavigationBar()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
populateFromExistingAddressIfNeeded()
|
populateFromExistingAddressIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ struct AddressCard: View {
|
|||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
|
||||||
.minimumScaleFactor(0.9)
|
.minimumScaleFactor(0.9)
|
||||||
|
|
||||||
if item.isPrimary {
|
if item.isPrimary {
|
||||||
@@ -137,9 +136,7 @@ struct AddressCard: View {
|
|||||||
.font(AppTypography.overline)
|
.font(AppTypography.overline)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
|
||||||
.padding(.horizontal, 9)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(AppColors.tertiary)
|
.background(AppColors.tertiary)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ struct AddressesView: View {
|
|||||||
item: addressToListItem(address, isPrimary: isSelected),
|
item: addressToListItem(address, isPrimary: isSelected),
|
||||||
onEdit: { beginEditing(address) }
|
onEdit: { beginEditing(address) }
|
||||||
)
|
)
|
||||||
.contentShape(Rectangle())
|
.appContentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if openSwipeRowId == rowId {
|
if openSwipeRowId == rowId {
|
||||||
openSwipeRowId = nil
|
openSwipeRowId = nil
|
||||||
@@ -106,7 +106,7 @@ struct AddressesView: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.appHiddenNavigationBar()
|
||||||
.sheet(isPresented: $openAddAddressForm) {
|
.sheet(isPresented: $openAddAddressForm) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
|
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ struct CartView: View {
|
|||||||
@State var openCheckout = false
|
@State var openCheckout = false
|
||||||
@State var couponCode = ""
|
@State var couponCode = ""
|
||||||
@State var appliedCouponCode: String? = nil
|
@State var appliedCouponCode: String? = nil
|
||||||
@State var discountValue: Double = 0
|
|
||||||
@State var deliveryFee: Double? = nil
|
@State var deliveryFee: Double? = nil
|
||||||
@State var selectedCustomerAddress: CustomerAddress? = nil
|
@State var selectedCustomerAddress: CustomerAddress? = nil
|
||||||
@State var isLoadingDeliveryFee = false
|
@State var isLoadingDeliveryFee = false
|
||||||
@@ -38,8 +37,8 @@ struct CartView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
couponSection
|
// couponSection
|
||||||
.padding(.horizontal, 20)
|
// .padding(.horizontal, 20)
|
||||||
|
|
||||||
summarySection
|
summarySection
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -62,7 +61,25 @@ struct CartView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var totalValue: Double {
|
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 {
|
private var deliveryFeeWatchKey: String {
|
||||||
@@ -123,7 +140,7 @@ struct CartView: View {
|
|||||||
|
|
||||||
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
||||||
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
|
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()
|
Divider()
|
||||||
|
|
||||||
@@ -237,18 +254,15 @@ struct CartView: View {
|
|||||||
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
guard normalized.isEmpty == false else {
|
guard normalized.isEmpty == false else {
|
||||||
appliedCouponCode = nil
|
appliedCouponCode = nil
|
||||||
discountValue = 0
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if normalized == "DESCONTO10" {
|
if normalized == "DESCONTO10" {
|
||||||
appliedCouponCode = normalized
|
appliedCouponCode = normalized
|
||||||
discountValue = min(subtotalValue, subtotalValue * 0.1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
appliedCouponCode = nil
|
appliedCouponCode = nil
|
||||||
discountValue = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatCurrency(_ value: Double) -> String {
|
private func formatCurrency(_ value: Double) -> String {
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ struct CheckoutView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Finalizar Pedido")
|
.navigationTitle("Finalizar Pedido")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.safeAreaInset(edge: .bottom) {
|
.appBottomSafeAreaInset {
|
||||||
bottomBar
|
bottomBar
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
@@ -433,7 +433,7 @@ struct CheckoutView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
.contentShape(Rectangle())
|
.appContentShape(Rectangle())
|
||||||
.onTapGesture(perform: onTap)
|
.onTapGesture(perform: onTap)
|
||||||
|
|
||||||
content()
|
content()
|
||||||
@@ -659,7 +659,7 @@ struct PaymentPixView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Pagamento via PIX")
|
.navigationTitle("Pagamento via PIX")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.task {
|
.task {
|
||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
latestOrder = updated
|
latestOrder = updated
|
||||||
@@ -851,7 +851,7 @@ struct PaymentCardView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Pagamento")
|
.navigationTitle("Pagamento")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.task {
|
.task {
|
||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
latestOrder = updated
|
latestOrder = updated
|
||||||
@@ -879,8 +879,7 @@ struct PaymentCardView: View {
|
|||||||
.font(AppTypography.caption)
|
.font(AppTypography.caption)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
TextField(placeholder, text: text)
|
TextField(placeholder, text: text)
|
||||||
.textInputAutocapitalization(.never)
|
.appNoAutoCap()
|
||||||
.disableAutocorrection(true)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.frame(height: 46)
|
.frame(height: 46)
|
||||||
.background(AppColors.backgroundLight)
|
.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
|
import SwiftUI
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import LCEssentials
|
import LCEssentials
|
||||||
@@ -14,12 +13,12 @@ struct HomeView: View {
|
|||||||
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
|
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
|
||||||
]
|
]
|
||||||
@State var scrollOffset: CGFloat = 0
|
@State var scrollOffset: CGFloat = 0
|
||||||
@State var collapseBaseOffset: CGFloat = 0
|
|
||||||
@State var collapseDragStartOffset: CGFloat? = nil
|
|
||||||
@State var hasRequestedLocation = false
|
@State var hasRequestedLocation = false
|
||||||
@State var isLoadingStores = false
|
@State var isLoadingStores = false
|
||||||
@State var storesError: String? = nil
|
@State var storesError: String? = nil
|
||||||
@State var stores: [StoreSummary] = []
|
@State var stores: [StoreSummary] = []
|
||||||
|
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||||
|
@State var searchDebounceToken = 0
|
||||||
|
|
||||||
private let specials: [SpecialOfferCardModel] = [
|
private let specials: [SpecialOfferCardModel] = [
|
||||||
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
// .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 {
|
.refreshable {
|
||||||
await bootstrapStoresFlow(
|
await bootstrapStoresFlow(
|
||||||
forceLocationRefresh: true,
|
forceNetworkRefresh: true,
|
||||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
category: selectedCategoryQueryValue,
|
||||||
refreshCategories: true
|
refreshCategories: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(scrollOffsetObserver)
|
.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)
|
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
@@ -79,18 +62,24 @@ struct HomeView: View {
|
|||||||
await bootstrapStoresFlow(refreshCategories: true)
|
await bootstrapStoresFlow(refreshCategories: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collapseBaseOffset = scrollOffset
|
|
||||||
}
|
}
|
||||||
.onChange(of: addressCacheScope) { _, _ in
|
.onChange(of: addressCacheScope) { _, _ in
|
||||||
guard hasRequestedLocation else { return }
|
guard hasRequestedLocation else { return }
|
||||||
Task {
|
Task {
|
||||||
await bootstrapStoresFlow(
|
await bootstrapStoresFlow(
|
||||||
forceLocationRefresh: true,
|
forceLocationRefresh: true,
|
||||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
category: selectedCategoryQueryValue,
|
||||||
refreshCategories: true
|
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 {
|
private var contentStack: some View {
|
||||||
@@ -153,7 +142,7 @@ struct HomeView: View {
|
|||||||
Task {
|
Task {
|
||||||
await bootstrapStoresFlow(
|
await bootstrapStoresFlow(
|
||||||
forceLocationRefresh: true,
|
forceLocationRefresh: true,
|
||||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
category: selectedCategoryQueryValue,
|
||||||
refreshCategories: true
|
refreshCategories: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -162,14 +151,14 @@ struct HomeView: View {
|
|||||||
.foregroundStyle(AppColors.primary)
|
.foregroundStyle(AppColors.primary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
} else if nearbyStoreCards.isEmpty {
|
} else if filteredStoreCards.isEmpty {
|
||||||
Text("Nenhum estabelecimento encontrado próximo à sua localização.")
|
Text(emptyResultMessage)
|
||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ForEach(nearbyStoreCards) { store in
|
ForEach(filteredStoreCards) { store in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
StoreDetailView(
|
StoreDetailView(
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
@@ -275,6 +264,9 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
|
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
|
||||||
|
appState.homeFilters.availableCategories = categories
|
||||||
|
.filter { $0.id.lowercased() != "all" }
|
||||||
|
.map(\.title)
|
||||||
appState.activeModal = .filters
|
appState.activeModal = .filters
|
||||||
}
|
}
|
||||||
.offset(y: collapseProgress * -120)
|
.offset(y: collapseProgress * -120)
|
||||||
@@ -315,7 +307,7 @@ struct HomeView: View {
|
|||||||
guard category.id != selectedCategory else { return }
|
guard category.id != selectedCategory else { return }
|
||||||
selectedCategory = category.id
|
selectedCategory = category.id
|
||||||
Task {
|
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
|
@MainActor
|
||||||
private func bootstrapStoresFlow(
|
private func bootstrapStoresFlow(
|
||||||
forceLocationRefresh: Bool = false,
|
forceLocationRefresh: Bool = false,
|
||||||
|
forceNetworkRefresh: Bool = false,
|
||||||
category: String? = nil,
|
category: String? = nil,
|
||||||
refreshCategories: Bool = false
|
refreshCategories: Bool = false
|
||||||
) async {
|
) async {
|
||||||
|
if isLoadingStores { return }
|
||||||
isLoadingStores = true
|
isLoadingStores = true
|
||||||
storesError = nil
|
storesError = nil
|
||||||
|
|
||||||
@@ -432,6 +361,7 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if forceLocationRefresh == false,
|
if forceLocationRefresh == false,
|
||||||
|
forceNetworkRefresh == false,
|
||||||
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
|
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
|
||||||
isLoadingStores = false
|
isLoadingStores = false
|
||||||
stores = cachedStores
|
stores = cachedStores
|
||||||
@@ -445,7 +375,10 @@ struct HomeView: View {
|
|||||||
#endif
|
#endif
|
||||||
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
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 {
|
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||||
selectedCategory = "all"
|
selectedCategory = "all"
|
||||||
}
|
}
|
||||||
@@ -478,13 +411,20 @@ struct HomeView: View {
|
|||||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
||||||
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
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 {
|
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||||
selectedCategory = "all"
|
selectedCategory = "all"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
storesError = nil
|
storesError = nil
|
||||||
} catch {
|
} catch {
|
||||||
|
if isCancelledRequest(error) {
|
||||||
|
isLoadingStores = false
|
||||||
|
return
|
||||||
|
}
|
||||||
isLoadingStores = false
|
isLoadingStores = false
|
||||||
stores = []
|
stores = []
|
||||||
storesError = storesUserMessage(error)
|
storesError = storesUserMessage(error)
|
||||||
@@ -498,7 +438,6 @@ struct HomeView: View {
|
|||||||
// Use only upward displacement for collapse and ignore top bounce.
|
// Use only upward displacement for collapse and ignore top bounce.
|
||||||
let normalized = max(0, y)
|
let normalized = max(0, y)
|
||||||
scrollOffset = normalized
|
scrollOffset = normalized
|
||||||
collapseBaseOffset = normalized
|
|
||||||
}
|
}
|
||||||
.frame(width: 0, height: 0)
|
.frame(width: 0, height: 0)
|
||||||
#else
|
#else
|
||||||
@@ -511,15 +450,49 @@ struct HomeView: View {
|
|||||||
let display = appState.address.display
|
let display = appState.address.display
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
|
||||||
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
|
||||||
return "\(selected)|\(display)|\(lat)|\(lng)"
|
return "\(selected)|\(display)|\(lat)|\(lng)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
|
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
|
||||||
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
|
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
|
||||||
let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil"
|
let latKey = lat.map(formatCoordinateCache) ?? "nil"
|
||||||
let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil"
|
let lngKey = lng.map(formatCoordinateCache) ?? "nil"
|
||||||
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
|
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)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Detalhes do Pedido")
|
.navigationTitle("Detalhes do Pedido")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.safeAreaInset(edge: .bottom) {
|
.appBottomSafeAreaInset {
|
||||||
VStack {
|
VStack {
|
||||||
reorderButton
|
reorderButton
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ private struct TrackingStep: Identifiable {
|
|||||||
struct OrderTrackingView: View {
|
struct OrderTrackingView: View {
|
||||||
let orderId: String
|
let orderId: String
|
||||||
let initialShortId: String?
|
let initialShortId: String?
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
|
|
||||||
@State var isLoading = true
|
@State var isLoading = true
|
||||||
@State var errorMessage: String? = nil
|
@State var errorMessage: String? = nil
|
||||||
@State var order: PublicOrderResult? = nil
|
@State var order: PublicOrderResult? = nil
|
||||||
|
@State var storeContactPhone: String? = nil
|
||||||
@State var tracker = OrderRealtimeTracker()
|
@State var tracker = OrderRealtimeTracker()
|
||||||
@State var showCancellationReason = false
|
@State var showCancellationReason = false
|
||||||
@State var reviewDraft: ReviewDraft? = nil
|
@State var reviewDraft: ReviewDraft? = nil
|
||||||
@State var didSaveReviewForCurrentOrder = false
|
@State var didSaveReviewForCurrentOrder = false
|
||||||
|
@State var reviewSavedObserver: Any?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -41,7 +44,7 @@ struct OrderTrackingView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||||
Button("Fechar", role: .cancel) {}
|
Button("Fechar", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
@@ -51,26 +54,49 @@ struct OrderTrackingView: View {
|
|||||||
await loadInitialOrder()
|
await loadInitialOrder()
|
||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
order = updated
|
order = updated
|
||||||
|
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
|
||||||
|
storeContactPhone = inlinePhone
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
attachReviewSavedObserverIfNeeded()
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
tracker.stop()
|
tracker.stop()
|
||||||
|
detachReviewSavedObserver()
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $reviewDraft) { draft in
|
.navigationDestination(item: $reviewDraft) { draft in
|
||||||
MyReviewsView(initialOrder: draft)
|
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 }
|
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 {
|
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
|
||||||
didSaveReviewForCurrentOrder = true
|
didSaveReviewForCurrentOrder = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func detachReviewSavedObserver() {
|
||||||
|
guard let reviewSavedObserver else { return }
|
||||||
|
NotificationCenter.default.removeObserver(reviewSavedObserver)
|
||||||
|
self.reviewSavedObserver = nil
|
||||||
|
}
|
||||||
|
|
||||||
private var topHeader: some View {
|
private var topHeader: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -224,7 +250,9 @@ struct OrderTrackingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var contactButton: some View {
|
private var contactButton: some View {
|
||||||
Button("CONTATO") {}
|
Button("CONTATO") {
|
||||||
|
openStoreWhatsApp()
|
||||||
|
}
|
||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.frame(maxWidth: .infinity, minHeight: 56)
|
.frame(maxWidth: .infinity, minHeight: 56)
|
||||||
@@ -747,23 +775,82 @@ struct OrderTrackingView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadInitialOrder() async {
|
private func loadInitialOrder() async {
|
||||||
logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
|
logger.info("OrderTracking initial fetch orderId=\(orderId)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||||
if response.error {
|
if response.error {
|
||||||
errorMessage = response.message ?? "Não foi possível carregar o pedido."
|
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 {
|
} else if let result = response.result {
|
||||||
order = result
|
order = result
|
||||||
|
storeContactPhone = result.storePhone
|
||||||
errorMessage = nil
|
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 {
|
} catch {
|
||||||
errorMessage = "Não foi possível carregar o pedido."
|
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
|
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)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Meus Pedidos")
|
.navigationTitle("Meus Pedidos")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.task {
|
.task {
|
||||||
await loadOrdersIfNeeded()
|
await loadOrdersIfNeeded()
|
||||||
await refreshStoreRatings()
|
await refreshStoreRatings()
|
||||||
@@ -137,7 +137,7 @@ struct OrdersView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.5)
|
.minimumScaleFactor(0.5)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.layoutPriority(0)
|
.appLayoutPriority(0)
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ struct OrdersView: View {
|
|||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.layoutPriority(2)
|
.appLayoutPriority(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(18)
|
.padding(18)
|
||||||
@@ -550,6 +550,7 @@ extension AppOrderSummary {
|
|||||||
paymentMethod: tracked.paymentMethod,
|
paymentMethod: tracked.paymentMethod,
|
||||||
deliveryType: tracked.deliveryType,
|
deliveryType: tracked.deliveryType,
|
||||||
storeName: tracked.storeName,
|
storeName: tracked.storeName,
|
||||||
|
storePhone: tracked.storePhone,
|
||||||
storeLogoURL: tracked.storeLogoURL,
|
storeLogoURL: tracked.storeLogoURL,
|
||||||
createdAt: tracked.createdAt,
|
createdAt: tracked.createdAt,
|
||||||
updatedAt: tracked.updatedAt
|
updatedAt: tracked.updatedAt
|
||||||
@@ -571,6 +572,7 @@ extension AppOrderSummary {
|
|||||||
paymentMethod: String?,
|
paymentMethod: String?,
|
||||||
deliveryType: String?,
|
deliveryType: String?,
|
||||||
storeName: String?,
|
storeName: String?,
|
||||||
|
storePhone: String?,
|
||||||
storeLogoURL: String?,
|
storeLogoURL: String?,
|
||||||
createdAt: String?,
|
createdAt: String?,
|
||||||
updatedAt: String?
|
updatedAt: String?
|
||||||
@@ -589,6 +591,7 @@ extension AppOrderSummary {
|
|||||||
self.paymentMethod = paymentMethod
|
self.paymentMethod = paymentMethod
|
||||||
self.deliveryType = deliveryType
|
self.deliveryType = deliveryType
|
||||||
self.storeName = storeName
|
self.storeName = storeName
|
||||||
|
self.storePhone = storePhone
|
||||||
self.storeLogoURL = storeLogoURL
|
self.storeLogoURL = storeLogoURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
@@ -716,16 +719,16 @@ struct OrderEntryDestinationView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func fetchOrderForRouting() async -> PublicOrderResult? {
|
func fetchOrderForRouting() async -> PublicOrderResult? {
|
||||||
logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)")
|
logger.info("OrderEntry fetch route orderId=\(orderId)")
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||||
if response.error == false, let result = response.result {
|
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
|
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 {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,12 +78,10 @@ struct PizzaFlavorAddonsSheet: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||||
.navigationTitle("Adicionais")
|
.navigationTitle("Adicionais")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.toolbar {
|
.appTopBarTrailingToolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Button("Concluir") { dismiss() }
|
||||||
Button("Concluir") { dismiss() }
|
.foregroundStyle(AppColors.primary)
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
|
|||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
ForEach(Array(sizeItems.indices), id: \.self) { index in
|
ForEach(0..<sizeItems.count, id: \.self) { index in
|
||||||
sizeRow(sizeItems[index])
|
sizeRow(sizeItems[index])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ extension PizzaProductDetailSheet {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
|
||||||
ForEach(Array(flavorItems.indices), id: \.self) { index in
|
ForEach(0..<flavorItems.count, id: \.self) { index in
|
||||||
let flavor = flavorItems[index]
|
let flavor = flavorItems[index]
|
||||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||||
@@ -118,7 +118,7 @@ extension PizzaProductDetailSheet {
|
|||||||
.disabled(disableSwitch)
|
.disabled(disableSwitch)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.contentShape(Rectangle())
|
.appContentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard isSelected else { return }
|
guard isSelected else { return }
|
||||||
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
||||||
@@ -172,7 +172,7 @@ extension PizzaProductDetailSheet {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.appContentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ struct PizzaProductDetailSheet: View {
|
|||||||
.padding(20)
|
.padding(20)
|
||||||
.padding(.bottom, 90)
|
.padding(.bottom, 90)
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.appBottomSafeAreaInset {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
||||||
@@ -292,12 +292,10 @@ struct PizzaProductDetailSheet: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||||
.navigationTitle("Monte sua pizza")
|
.navigationTitle("Monte sua pizza")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.toolbar {
|
.appTopBarTrailingToolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Button("Fechar") { dismiss() }
|
||||||
Button("Fechar") { dismiss() }
|
.foregroundStyle(AppColors.primary)
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
applyAutoSelections()
|
applyAutoSelections()
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ struct ProductDetailSheet: View {
|
|||||||
.padding(20)
|
.padding(20)
|
||||||
.padding(.bottom, 80)
|
.padding(.bottom, 80)
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.appBottomSafeAreaInset {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button(action: { if quantity > 0 { quantity -= 1 } }) {
|
Button(action: { if quantity > 0 { quantity -= 1 } }) {
|
||||||
@@ -222,12 +222,10 @@ struct ProductDetailSheet: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||||
.navigationTitle("Detalhes")
|
.navigationTitle("Detalhes")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.toolbar {
|
.appTopBarTrailingToolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
Button("Fechar") { dismiss() }
|
||||||
Button("Fechar") { dismiss() }
|
.foregroundStyle(AppColors.primary)
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
let existing = currentQuantityForItemId(cartItemId)
|
let existing = currentQuantityForItemId(cartItemId)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
import LCEssentials
|
import LCEssentials
|
||||||
|
#endif
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
@@ -89,7 +91,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.safeAreaInset(edge: .bottom) {
|
.appBottomSafeAreaInset {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppColors.backgroundLight.opacity(0.8))
|
.fill(AppColors.backgroundLight.opacity(0.8))
|
||||||
.frame(height: tabBarClearance)
|
.frame(height: tabBarClearance)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct MyReviewsView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Minhas Avaliações")
|
.navigationTitle("Minhas Avaliações")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.navigationDestination(item: $selectedDraft) { draft in
|
.navigationDestination(item: $selectedDraft) { draft in
|
||||||
OrderReviewView(draft: draft) {
|
OrderReviewView(draft: draft) {
|
||||||
Task { await loadReviewsFromBackend(forceRefresh: true) }
|
Task { await loadReviewsFromBackend(forceRefresh: true) }
|
||||||
@@ -451,7 +451,7 @@ struct OrderReviewView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Avaliar Pedido")
|
.navigationTitle("Avaliar Pedido")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
|
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
|
||||||
existingReview = cachedReview
|
existingReview = cachedReview
|
||||||
|
|||||||
@@ -50,12 +50,17 @@ extension StoreDetailView {
|
|||||||
if let minOrder = info?.minOrder {
|
if let minOrder = info?.minOrder {
|
||||||
return formatCurrency(minOrder)
|
return formatCurrency(minOrder)
|
||||||
}
|
}
|
||||||
if let storeDistance, storeDistance.isEmpty == false {
|
|
||||||
return storeDistance
|
|
||||||
}
|
|
||||||
return "R$ --"
|
return "R$ --"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var distanceValueLabel: String {
|
||||||
|
let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if raw.isEmpty {
|
||||||
|
return "--"
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
var isStoreOpen: Bool {
|
var isStoreOpen: Bool {
|
||||||
info?.isOpen ?? true
|
info?.isOpen ?? true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
import LCEssentials
|
import LCEssentials
|
||||||
|
#endif
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
@@ -48,6 +50,15 @@ struct StoreDetailView: View {
|
|||||||
AppColors.backgroundLight.ignoresSafeArea()
|
AppColors.backgroundLight.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
|
#if os(Android)
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
topSection
|
||||||
|
|
||||||
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
|
|
||||||
|
sectionedProducts
|
||||||
|
}
|
||||||
|
#else
|
||||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
@@ -55,6 +66,7 @@ struct StoreDetailView: View {
|
|||||||
|
|
||||||
sectionedProducts
|
sectionedProducts
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await loadStoreData(forceRefresh: true)
|
await loadStoreData(forceRefresh: true)
|
||||||
@@ -74,8 +86,7 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.appHiddenNavigationBar()
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
|
||||||
.task {
|
.task {
|
||||||
guard didLoad == false else { return }
|
guard didLoad == false else { return }
|
||||||
didLoad = true
|
didLoad = true
|
||||||
@@ -263,6 +274,8 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
|
||||||
|
Divider().frame(height: 34)
|
||||||
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
|
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
|
||||||
Divider().frame(height: 34)
|
Divider().frame(height: 34)
|
||||||
statItem(title: "ENTREGA", value: deliveryValueLabel)
|
statItem(title: "ENTREGA", value: deliveryValueLabel)
|
||||||
@@ -469,7 +482,7 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
.offset(x: 3, y: -3)
|
.offset(x: 3, y: -3)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.contentShape(Circle())
|
.appContentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
@@ -481,7 +494,7 @@ struct StoreDetailView: View {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.background(AppColors.surface)
|
.background(AppColors.surface)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
.contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard isStoreOpen else {
|
guard isStoreOpen else {
|
||||||
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
|
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
|
import SwiftUI
|
||||||
#if canImport(PhotosUI) && os(iOS)
|
#if canImport(PhotosUI) && os(iOS)
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
@@ -33,7 +34,7 @@ struct UserProfileView: View {
|
|||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Meu Perfil")
|
.navigationTitle("Meu Perfil")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.appInlineNavigationTitle()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
hydrateFromAppState()
|
hydrateFromAppState()
|
||||||
}
|
}
|
||||||
@@ -267,3 +268,4 @@ struct UserProfileView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#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