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:
2026-03-26 14:11:39 -03:00
40 changed files with 2282 additions and 339 deletions

8
PediFoods.code-workspace Normal file
View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View File

@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
<dict/>
</plist>

View File

@@ -12,8 +12,8 @@
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; };
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -42,7 +42,7 @@
49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; };
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -92,7 +92,7 @@
isa = PBXGroup;
children = (
49F90C2B2A52156200F06D93 /* Main.swift */,
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
49F90C312A52156300F06D93 /* Entitlements.plist */,
4900101C2BACEA710000DE33 /* Info.plist */,
@@ -165,7 +165,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
);
@@ -214,6 +214,8 @@
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};
@@ -226,6 +228,8 @@
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};

View File

@@ -11,7 +11,9 @@ struct CachedRemoteImage<Placeholder: View>: View {
let ttl: TimeInterval
let placeholder: Placeholder
#if canImport(UIKit) || canImport(AppKit)
@StateObject var loader = CachedRemoteImageLoader()
#endif
init(
imageURL: String?,
@@ -60,14 +62,19 @@ struct CachedRemoteImage<Placeholder: View>: View {
#endif
}
.onAppear {
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: imageURL, ttl: ttl)
#endif
}
.onChange(of: imageURL) { _, newValue in
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: newValue, ttl: ttl)
#endif
}
}
}
#if canImport(UIKit) || canImport(AppKit)
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
#if canImport(UIKit)
@@ -174,3 +181,4 @@ final class CachedRemoteImageLoader: ObservableObject {
}
#endif
}
#endif

View File

@@ -30,7 +30,7 @@ struct SnackbarOverlay: View {
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
.contentShape(Rectangle())
.appContentShape(Rectangle())
.onTapGesture {
center.handleTap()
}

View File

@@ -14,6 +14,7 @@ struct ContentView: View {
#if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol?
#endif
@State var cartResetObserver: Any?
#if os(Android)
@State var snackbarCenter = SnackbarCenter.shared
#else
@@ -54,7 +55,7 @@ struct ContentView: View {
case .addressPicker:
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
case .filters:
FiltersModalView()
FiltersModalView(appState: $appState)
}
}
.onChange(of: appState.address.display) { _, _ in
@@ -69,9 +70,6 @@ struct ContentView: View {
.onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
appState.cart = CartState()
}
.task {
await performInitialLaunchBootstrap()
}
@@ -83,11 +81,13 @@ struct ContentView: View {
}
}
.onAppear {
attachCartResetObserverIfNeeded()
#if os(iOS)
attachSessionExpiredObserverIfNeeded()
#endif
}
.onDisappear {
detachCartResetObserver()
#if os(iOS)
detachSessionExpiredObserver()
#endif
@@ -182,6 +182,23 @@ struct ContentView: View {
isBootstrappingSession = false
}
private func attachCartResetObserverIfNeeded() {
guard cartResetObserver == nil else { return }
cartResetObserver = NotificationCenter.default.addObserver(
forName: .cartDidReset,
object: nil,
queue: nil
) { _ in
appState.cart = CartState()
}
}
private func detachCartResetObserver() {
guard let cartResetObserver else { return }
NotificationCenter.default.removeObserver(cartResetObserver)
self.cartResetObserver = nil
}
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id
@@ -346,21 +363,3 @@ struct AddressPickerModalView: View {
}
}
}
struct FiltersModalView: View {
var body: some View {
NavigationStack {
VStack(spacing: 12) {
Text("Filtros")
.font(AppTypography.heading2)
Text("Filtros de busca serao ligados na integracao real da Home com API.")
.font(AppTypography.body)
.multilineTextAlignment(.center)
.foregroundStyle(AppColors.textMuted)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}
}

View File

@@ -83,6 +83,10 @@
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
"isCommentAutoGenerated" : true
},
"1km" : {
"comment" : "A label for the minimum distance filter option.",
"isCommentAutoGenerated" : true
},
"2. Massa" : {
},
@@ -101,6 +105,10 @@
}
}
},
"10km" : {
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
"isCommentAutoGenerated" : true
},
"Acompanhamento em tempo real" : {
},
@@ -178,6 +186,10 @@
"comment" : "A button that applies a coupon code to a cart.",
"isCommentAutoGenerated" : true
},
"Aplicar Filtros" : {
"comment" : "A button label that translates to \"Apply Filters\".",
"isCommentAutoGenerated" : true
},
"Appearance" : {
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
"extractionState" : "stale",
@@ -208,6 +220,10 @@
}
}
},
"Até %lldkm" : {
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
"isCommentAutoGenerated" : true
},
"Atualizando status do pedido..." : {
},
@@ -290,6 +306,10 @@
"comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true
},
"Categorias" : {
"comment" : "A heading for the list of available categories in the filters modal.",
"isCommentAutoGenerated" : true
},
"Categories" : {
"comment" : "A section header that lists available food categories.",
"isCommentAutoGenerated" : true
@@ -414,10 +434,6 @@
}
}
},
"DELIVERY LOCATION" : {
"comment" : "A label describing the delivery location section.",
"isCommentAutoGenerated" : true
},
"Desconto" : {
"comment" : "A label for a discount applied to an order.",
"isCommentAutoGenerated" : true
@@ -428,6 +444,9 @@
},
"Detalhes do Pedido" : {
},
"Distância" : {
},
"Do que você gostou?" : {
"comment" : "A prompt asking users to mention what they liked about a review.",
@@ -452,12 +471,15 @@
"comment" : "A text describing delivery mode.",
"isCommentAutoGenerated" : true
},
"ENTREGAR EM:" : {
"comment" : "A label displayed next to the button that lets the user select an address.",
"isCommentAutoGenerated" : true
},
"Enviar Avaliação" : {
},
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
"isCommentAutoGenerated" : true
"Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso." : {
},
"Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : {
"comment" : "A description under the QR code that instructs the user on how to pay using their bank app.",
@@ -517,10 +539,6 @@
"comment" : "A label for the filter options in the modal.",
"isCommentAutoGenerated" : true
},
"Filtros de busca serao ligados na integracao real da Home com API." : {
"comment" : "A description of the filters feature that will be added to the home screen.",
"isCommentAutoGenerated" : true
},
"Finalizar Pedido" : {
"comment" : "The title of the checkout screen.",
"isCommentAutoGenerated" : true
@@ -637,6 +655,10 @@
}
}
},
"Limpar" : {
"comment" : "A button that clears all filters when pressed.",
"isCommentAutoGenerated" : true
},
"Limpar carrinho e adicionar" : {
"comment" : "A button that clears the current cart and adds the selected product.",
"isCommentAutoGenerated" : true
@@ -719,10 +741,6 @@
"comment" : "A message displayed when a user has no addresses saved.",
"isCommentAutoGenerated" : true
},
"Nenhum estabelecimento encontrado próximo à sua localização." : {
"comment" : "A message displayed when no nearby stores are found.",
"isCommentAutoGenerated" : true
},
"Nenhum pedido encontrado." : {
},
@@ -795,6 +813,10 @@
"comment" : "A label indicating a required field.",
"isCommentAutoGenerated" : true
},
"Ordenar por" : {
"comment" : "A label describing the sorting options available in the filters modal.",
"isCommentAutoGenerated" : true
},
"Pagamento" : {
"comment" : "The title of the screen where a user can enter and save payment information.",
"isCommentAutoGenerated" : true
@@ -803,10 +825,6 @@
"comment" : "The navigation title for the PaymentPixView.",
"isCommentAutoGenerated" : true
},
"para %@" : {
"comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
"isCommentAutoGenerated" : true
},
"Pedido #%@" : {
},
@@ -875,6 +893,10 @@
},
"Precisa de ajuda com esse pedido?" : {
},
"Preço" : {
"comment" : "A label describing the price filter section in the filters modal.",
"isCommentAutoGenerated" : true
},
"Preencha os dados abaixo para adicionar um endereço." : {
"comment" : "A description below the form to add a new address, instructing the user to fill in the required information.",
@@ -1161,6 +1183,10 @@
"comment" : "A button label that translates to \"View Profile\" in English.",
"isCommentAutoGenerated" : true
},
"Ver todas" : {
"comment" : "A link that allows users to see all available categories in the filters.",
"isCommentAutoGenerated" : true
},
"Verificação" : {
"comment" : "A title for the OTP verification screen.",
"isCommentAutoGenerated" : true

View File

@@ -115,6 +115,8 @@ struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
let phone: String?
let whatsapp: String?
let logo: String?
let cover: String?
let deliveryTime: String?
@@ -126,6 +128,8 @@ struct StoreInfoResult: Decodable {
case isOpen
case statusLabel
case fantasyName
case phone
case whatsapp
case logo
case cover
case deliveryTime
@@ -139,6 +143,8 @@ struct StoreInfoResult: Decodable {
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
phone = ApiService.decodeFlexibleString(from: container, keys: [.phone])
whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone])
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)

View File

@@ -15,6 +15,7 @@ struct AppOrderSummary: Decodable, Identifiable {
let paymentMethod: String?
let deliveryType: String?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
@@ -35,6 +36,7 @@ struct AppOrderSummary: Decodable, Identifiable {
case paymentMethod
case deliveryType
case storeName
case storePhone
case storeLogo
case store_logo
case logo
@@ -59,6 +61,7 @@ struct AppOrderSummary: Decodable, Identifiable {
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
@@ -86,6 +89,7 @@ struct PublicOrderResult: Codable, Identifiable {
let discount: Double?
let total: Double?
let storeName: String?
let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
@@ -125,6 +129,7 @@ struct PublicOrderResult: Codable, Identifiable {
case couponDiscount
case total
case storeName
case storePhone
case storeLogo
case store_logo
case logo
@@ -165,6 +170,7 @@ struct PublicOrderResult: Codable, Identifiable {
discount: Double? = nil,
total: Double? = nil,
storeName: String? = nil,
storePhone: String? = nil,
storeLogoURL: String? = nil,
createdAt: String? = nil,
updatedAt: String? = nil,
@@ -196,6 +202,7 @@ struct PublicOrderResult: Codable, Identifiable {
self.discount = discount
self.total = total
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -240,6 +247,7 @@ struct PublicOrderResult: Codable, Identifiable {
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
@@ -279,6 +287,7 @@ struct PublicOrderResult: Codable, Identifiable {
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName)
try container.encodeIfPresent(storePhone, forKey: .storePhone)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(otp, forKey: .otp)

View File

@@ -76,18 +76,18 @@ final class OrderRealtimeTracker {
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
logger.debug("OrderTracking poll request orderId=\(orderId)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else {
logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
return nil
}
clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
onOrderUpdated?(order)
return order
} catch {
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
return nil
}
}

View File

@@ -6,6 +6,7 @@ struct AppState {
var cart = CartState()
var address = AddressState()
var favorites = FavoritesState()
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
}
@@ -41,6 +42,57 @@ struct FavoritesState {
var storeIds: Set<String> = []
}
enum HomeSortOption: String, CaseIterable, Identifiable {
case relevance
case rating
case deliveryTime
case price
var id: String { rawValue }
var title: String {
switch self {
case .relevance: return "Relevância"
case .rating: return "Avaliação"
case .deliveryTime: return "Tempo de entrega"
case .price: return "Preço"
}
}
var icon: String {
switch self {
case .relevance: return "checkmark.seal.fill"
case .rating: return "star.fill"
case .deliveryTime: return "clock.fill"
case .price: return "dollarsign"
}
}
}
enum HomePriceTier: String, CaseIterable, Identifiable {
case low = "$"
case medium = "$$"
case high = "$$$"
case veryHigh = "$$$$"
var id: String { rawValue }
}
struct HomeFiltersState {
var sortOption: HomeSortOption = .relevance
var selectedCategories: Set<String> = []
var selectedPriceTier: HomePriceTier? = nil
var maxDistanceKm: Double = 10
var availableCategories: [String] = []
mutating func reset() {
sortOption = .relevance
selectedCategories = []
selectedPriceTier = nil
maxDistanceKm = 10
}
}
struct CartState {
var storeId: String? = nil
var storeName: String? = nil

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ struct TermsOfUseView: View {
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
#endif
}
}
@@ -41,7 +41,7 @@ struct PrivacyPolicyView: View {
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
#endif
}
}

View File

@@ -62,7 +62,7 @@ struct LoginEmailView: View {
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
Text("Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso.")
.font(.caption)
.foregroundStyle(Color.gray)
.multilineTextAlignment(.center)

View File

@@ -114,25 +114,17 @@ struct LoginView: View {
@MainActor
private func applyHiddenStateWithoutAnimation() {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
heroVisible = false
textVisible = false
buttonVisible = false
}
}
@MainActor
private func showFinalStateWithoutAnimation() {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
heroVisible = true
textVisible = true
buttonVisible = true
}
}
@MainActor
private func runEntryAnimationIfNeeded(for token: Int) async {

View File

@@ -41,7 +41,7 @@ struct OtpView: View {
.foregroundStyle(AppColors.textMuted)
.padding(.top, 14)
Text("para \(email)")
Text(otpDeliveryMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
@@ -208,6 +208,29 @@ struct OtpView: View {
#endif
}
private var otpDeliveryMessage: String {
"para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)"
}
private var maskedPhoneForDisplay: String {
let digits = phoneNumber.filter(\.isNumber)
guard digits.isEmpty == false else { return "XXXX" }
let visibleSuffix = String(digits.suffix(min(4, digits.count)))
return "XXXX\(visibleSuffix)"
}
private var maskedEmailForDisplay: String {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return "XXXX" }
let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
guard parts.count == 2 else { return "XXXX" }
let domain = String(parts[1])
return "XXXX@\(domain)"
}
private func validateOtp() {
let code = otp.filter(\.isNumber)
guard code.count == 8 else { return }

View File

@@ -128,7 +128,7 @@ struct AddAddressFormView: View {
.padding(.top, 0)
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.appHiddenNavigationBar()
.onAppear {
populateFromExistingAddressIfNeeded()
}

View File

@@ -129,7 +129,6 @@ struct AddressCard: View {
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.9)
if item.isPrimary {
@@ -137,9 +136,7 @@ struct AddressCard: View {
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
.background(AppColors.tertiary)
.clipShape(Capsule())
}

View File

@@ -79,7 +79,7 @@ struct AddressesView: View {
item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) }
)
.contentShape(Rectangle())
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == rowId {
openSwipeRowId = nil
@@ -106,7 +106,7 @@ struct AddressesView: View {
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.appHiddenNavigationBar()
.sheet(isPresented: $openAddAddressForm) {
NavigationStack {
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in

View File

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

View File

@@ -119,8 +119,8 @@ struct CheckoutView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
.appInlineNavigationTitle()
.appBottomSafeAreaInset {
bottomBar
}
.task {
@@ -433,7 +433,7 @@ struct CheckoutView: View {
}
.padding(.horizontal, 14)
.padding(.top, 14)
.contentShape(Rectangle())
.appContentShape(Rectangle())
.onTapGesture(perform: onTap)
content()
@@ -659,7 +659,7 @@ struct PaymentPixView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.task {
tracker.onOrderUpdated = { updated in
latestOrder = updated
@@ -851,7 +851,7 @@ struct PaymentCardView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.task {
tracker.onOrderUpdated = { updated in
latestOrder = updated
@@ -879,8 +879,7 @@ struct PaymentCardView: View {
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.appNoAutoCap()
.padding(.horizontal, 12)
.frame(height: 46)
.background(AppColors.backgroundLight)

View File

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

View File

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

View File

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

View File

@@ -23,8 +23,8 @@ struct OrderDetailsView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
.appInlineNavigationTitle()
.appBottomSafeAreaInset {
VStack {
reorderButton
.padding(.horizontal, 20)

View File

@@ -12,14 +12,17 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
@Environment(\.openURL) var openURL
@State var isLoading = true
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
@State var storeContactPhone: String? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
@State var reviewSavedObserver: Any?
var body: some View {
ScrollView(showsIndicators: false) {
@@ -41,7 +44,7 @@ struct OrderTrackingView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
@@ -51,26 +54,49 @@ struct OrderTrackingView: View {
await loadInitialOrder()
tracker.onOrderUpdated = { updated in
order = updated
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
storeContactPhone = inlinePhone
}
isLoading = false
errorMessage = nil
}
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
}
.onAppear {
attachReviewSavedObserverIfNeeded()
}
.onDisappear {
tracker.stop()
detachReviewSavedObserver()
}
.navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft)
}
.onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { payload in
}
private func attachReviewSavedObserverIfNeeded() {
guard reviewSavedObserver == nil else { return }
reviewSavedObserver = NotificationCenter.default.addObserver(
forName: .orderReviewDidSave,
object: nil,
queue: nil
) { payload in
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
let currentOrderId = (order?.id ?? orderId).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let currentOrderId = (order?.id ?? orderId)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true
}
}
}
private func detachReviewSavedObserver() {
guard let reviewSavedObserver else { return }
NotificationCenter.default.removeObserver(reviewSavedObserver)
self.reviewSavedObserver = nil
}
private var topHeader: some View {
HStack(spacing: 10) {
Circle()
@@ -224,7 +250,9 @@ struct OrderTrackingView: View {
}
private var contactButton: some View {
Button("CONTATO") {}
Button("CONTATO") {
openStoreWhatsApp()
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
@@ -747,23 +775,82 @@ struct OrderTrackingView: View {
@MainActor
private func loadInitialOrder() async {
logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
logger.info("OrderTracking initial fetch orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} else if let result = response.result {
order = result
storeContactPhone = result.storePhone
errorMessage = nil
logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
await refreshStoreContactPhone(for: result)
}
} catch {
errorMessage = "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
}
isLoading = false
}
@MainActor
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
return
}
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
return
}
}
} catch {
// Fallback handled below.
}
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
}
private func openStoreWhatsApp() {
guard let phoneRaw = storeContactPhone,
let url = makeWhatsAppURL(from: phoneRaw) else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
openURL(url)
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") {
digits = String(digits.drop(while: { $0 == "0" }))
}
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
}

View File

@@ -39,7 +39,7 @@ struct OrdersView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Meus Pedidos")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.task {
await loadOrdersIfNeeded()
await refreshStoreRatings()
@@ -137,7 +137,7 @@ struct OrdersView: View {
.lineLimit(1)
.minimumScaleFactor(0.5)
.buttonStyle(.plain)
.layoutPriority(0)
.appLayoutPriority(0)
Spacer(minLength: 8)
@@ -169,7 +169,7 @@ struct OrdersView: View {
.clipShape(Capsule())
}
.buttonStyle(.plain)
.layoutPriority(2)
.appLayoutPriority(2)
}
}
.padding(18)
@@ -550,6 +550,7 @@ extension AppOrderSummary {
paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType,
storeName: tracked.storeName,
storePhone: tracked.storePhone,
storeLogoURL: tracked.storeLogoURL,
createdAt: tracked.createdAt,
updatedAt: tracked.updatedAt
@@ -571,6 +572,7 @@ extension AppOrderSummary {
paymentMethod: String?,
deliveryType: String?,
storeName: String?,
storePhone: String?,
storeLogoURL: String?,
createdAt: String?,
updatedAt: String?
@@ -589,6 +591,7 @@ extension AppOrderSummary {
self.paymentMethod = paymentMethod
self.deliveryType = deliveryType
self.storeName = storeName
self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -716,16 +719,16 @@ struct OrderEntryDestinationView: View {
@MainActor
func fetchOrderForRouting() async -> PublicOrderResult? {
logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)")
logger.info("OrderEntry fetch route orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result {
logger.info("OrderEntry fetch route success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
return result
}
logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} catch {
logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)")
}
return nil
}

View File

@@ -78,14 +78,12 @@ struct PizzaFlavorAddonsSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Concluir") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
}
private func increment(_ addonId: String) {
quantities[addonId, default: 0] += 1

View File

@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(Array(sizeItems.indices), id: \.self) { index in
ForEach(0..<sizeItems.count, id: \.self) { index in
sizeRow(sizeItems[index])
}
}
@@ -83,7 +83,7 @@ extension PizzaProductDetailSheet {
.font(.caption)
.foregroundStyle(AppColors.textMuted)
ForEach(Array(flavorItems.indices), id: \.self) { index in
ForEach(0..<flavorItems.count, id: \.self) { index in
let flavor = flavorItems[index]
let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
@@ -118,7 +118,7 @@ extension PizzaProductDetailSheet {
.disabled(disableSwitch)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.appContentShape(Rectangle())
.onTapGesture {
guard isSelected else { return }
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
@@ -172,7 +172,7 @@ extension PizzaProductDetailSheet {
Spacer()
}
.contentShape(Rectangle())
.appContentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@@ -222,7 +222,7 @@ struct PizzaProductDetailSheet: View {
.padding(20)
.padding(.bottom, 90)
}
.safeAreaInset(edge: .bottom) {
.appBottomSafeAreaInset {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: { if quantity > 1 { quantity -= 1 } }) {
@@ -292,13 +292,11 @@ struct PizzaProductDetailSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
.onAppear {
applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId)

View File

@@ -164,7 +164,7 @@ struct ProductDetailSheet: View {
.padding(20)
.padding(.bottom, 80)
}
.safeAreaInset(edge: .bottom) {
.appBottomSafeAreaInset {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: { if quantity > 0 { quantity -= 1 } }) {
@@ -222,13 +222,11 @@ struct ProductDetailSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
.onAppear {
let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1

View File

@@ -1,5 +1,7 @@
import SwiftUI
#if canImport(LCEssentials) && os(iOS)
import LCEssentials
#endif
#if canImport(UIKit)
import UIKit
#endif
@@ -89,7 +91,7 @@ struct ProfileView: View {
}
}
.ignoresSafeArea(edges: .top)
.safeAreaInset(edge: .bottom) {
.appBottomSafeAreaInset {
Rectangle()
.fill(AppColors.backgroundLight.opacity(0.8))
.frame(height: tabBarClearance)

View File

@@ -67,7 +67,7 @@ struct MyReviewsView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) }
@@ -451,7 +451,7 @@ struct OrderReviewView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview

View File

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

View File

@@ -1,5 +1,7 @@
import SwiftUI
#if canImport(LCEssentials) && os(iOS)
import LCEssentials
#endif
#if canImport(UIKit)
import UIKit
#endif
@@ -48,6 +50,15 @@ struct StoreDetailView: View {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
#if os(Android)
LazyVStack(spacing: 0) {
topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
sectionedProducts
}
#else
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
topSection
@@ -55,6 +66,7 @@ struct StoreDetailView: View {
sectionedProducts
}
#endif
}
.refreshable {
await loadStoreData(forceRefresh: true)
@@ -74,8 +86,7 @@ struct StoreDetailView: View {
}
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.toolbarBackground(.hidden, for: .navigationBar)
.appHiddenNavigationBar()
.task {
guard didLoad == false else { return }
didLoad = true
@@ -263,6 +274,8 @@ struct StoreDetailView: View {
}
HStack(spacing: 0) {
statItem(title: "DISTÂNCIA", value: distanceValueLabel)
Divider().frame(height: 34)
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
Divider().frame(height: 34)
statItem(title: "ENTREGA", value: deliveryValueLabel)
@@ -469,7 +482,7 @@ struct StoreDetailView: View {
}
.offset(x: 3, y: -3)
.frame(width: 30, height: 30)
.contentShape(Circle())
.appContentShape(Circle())
}
.buttonStyle(.plain)
.frame(width: 30, height: 30)
@@ -481,7 +494,7 @@ struct StoreDetailView: View {
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.onTapGesture {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)

View File

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

View File

@@ -1,3 +1,4 @@
#if !os(Android)
import SwiftUI
#if canImport(PhotosUI) && os(iOS)
import PhotosUI
@@ -33,7 +34,7 @@ struct UserProfileView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil")
.navigationBarTitleDisplayMode(.inline)
.appInlineNavigationTitle()
.onAppear {
hydrateFromAppState()
}
@@ -267,3 +268,4 @@ struct UserProfileView: View {
}
#endif
}
#endif

320
skip_for_android_plan.md Normal file
View 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
View File

@@ -0,0 +1,744 @@
# Subdomain Stores Plan
## Objetivo
Reorganizar o ecossistema do PediFoods para um modelo claro de subdomínios, mantendo a integração com o backend do Atomenta onde ela faz sentido, mas separando melhor identidade, navegação e contexto de cada área.
## Cenário Desejado
### 1. `pedifoods.com.br`
Papel:
- home pública
- landing page
- login
- onboarding inicial
- após login, exibir lista de lojas do usuário
Responsabilidade:
- identidade pública do PediFoods
- autenticação do usuário final/parceiro
- seleção da loja
### 2. `parceiro.pedifoods.com.br`
Papel:
- painel do lojista
- equivalente ao atual "Atomenta Store"
- gestão operacional da loja
Responsabilidade:
- backoffice do parceiro
- configuração da loja
- cardápio
- pedidos
- financeiro
- onboarding do parceiro
### 3. `nome-curto-loja.pedifoods.com.br`
Papel:
- storefront da loja
- vitrine pública ou semi-logada da loja
- produtos, categorias, carrinho e experiência do cliente
Responsabilidade:
- experiência da loja individual
- branding e contexto por loja
- leitura do catálogo
## Princípio Arquitetural
O PediFoods não deve mais parecer "um Atomenta renomeado".
Ele pode continuar:
- consumindo APIs do Atomenta
- reutilizando lógica de OTP
- reutilizando cookies/tokens e integração segura quando necessário
Mas deve deixar claro:
- qual domínio é PediFoods
- qual domínio é painel parceiro
- qual domínio é storefront da loja
## Direção Técnica
### Separar 3 contextos
1. `public app`
- domínio base `pedifoods.com.br`
- homepage, login, seleção de lojas
2. `partner app`
- subdomínio fixo `parceiro.pedifoods.com.br`
- painel do lojista
3. `store app`
- subdomínio dinâmico `{slug}.pedifoods.com.br`
- storefront da loja
### Manter 1 base de código no curto prazo
No curto prazo, o melhor custo-benefício é manter uma única codebase do PediFoods com roteamento por host.
Ou seja:
- o app lê o `Host`
- identifica o contexto
- renderiza a área correta
Exemplo de resolução:
- `pedifoods.com.br` => contexto `public`
- `parceiro.pedifoods.com.br` => contexto `partner`
- `slug.pedifoods.com.br` => contexto `store`
Isso evita:
- duplicação de projeto
- deploy múltiplo desnecessário
- divergência de regra de negócio
## Regras de Resolução de Host
### Contexto `public`
Hosts:
- `pedifoods.com.br`
- `www.pedifoods.com.br` se existir
Fluxos:
- home
- login
- cadastro
- recuperação de conta
- listagem de lojas do usuário autenticado
### Contexto `partner`
Host:
- `parceiro.pedifoods.com.br`
Fluxos:
- login do parceiro
- dashboard da loja
- onboarding
- catálogo
- pedidos
- financeiro
### Contexto `store`
Host:
- `{storeSlug}.pedifoods.com.br`
Fluxos:
- catálogo público da loja
- detalhes da loja
- carrinho
- pedidos do cliente
## Mudanças Estruturais Necessárias
### 1. Resolver host de forma centralizada
Criar uma camada única para:
- ler `req.hostname`
- classificar o contexto
- extrair `storeSlug` quando aplicável
Saída esperada:
- `appContext.type = public | partner | store`
- `appContext.storeSlug = string | null`
### 2. Parar de usar defaults de domínio do Atomenta no PediFoods
Hoje o PediFoods ainda carrega vários defaults como:
- `https://atomenta.com.br`
- `atomenta_token`
- `atomenta_auth`
- nomes e textos herdados
Isso precisa ser separado em 2 grupos:
#### Pode continuar apontando para Atomenta
- integração de API interna
- OTP/crypto
- comunicação backend-to-backend
- consulta de dados mestres, se esta for a arquitetura
#### Não deve mais apontar para Atomenta
- branding
- domínio público
- base URL de navegação do usuário
- nomes do app
- `package.json` e `ecosystem.config.js`
### 3. Definir estratégia de sessão/cookies
Decisão importante:
O sistema terá **dois contextos de autenticação diferentes**:
1. **partner session**
- usada no `parceiro.pedifoods.com.br`
- representa o lojista/operador
- dá acesso ao painel administrativo
2. **customer session**
- usada em `pedifoods.com.br` e em `{slug}.pedifoods.com.br`
- representa o cliente/comprador
- dá acesso à navegação, seleção de lojas, carrinho e compra
Essas duas sessões **não devem ser compartilhadas entre si**.
Se fossem compartilhadas:
- um usuário logado como parceiro poderia cair autenticado na storefront da própria loja
- haveria risco de misturar permissões administrativas com experiência de compra
- o modelo de segurança e UX ficaria ambíguo
#### Opção A
Sessão única compartilhada em `.pedifoods.com.br`
Resultado:
- mesma sessão valeria para:
- `pedifoods.com.br`
- `parceiro.pedifoods.com.br`
- `{slug}.pedifoods.com.br`
Problema:
- **não serve para este cenário**
- mistura sessão de lojista com sessão de cliente
#### Opção B
Sessões separadas por contexto
Modelo recomendado:
- `parceiro.pedifoods.com.br`
- cookie próprio de parceiro
- escopo exclusivo do painel
- `pedifoods.com.br`
- cookie próprio de cliente
- pode ser compartilhado com `{slug}.pedifoods.com.br` se fizer sentido para a jornada do comprador
- `{slug}.pedifoods.com.br`
- usa sessão do cliente, nunca sessão do parceiro
Vantagens:
- separa claramente os perfis
- evita vazamento de autenticação entre painel e loja
- reduz risco de autorização indevida
- deixa a jornada de compra independente da jornada administrativa
Desvantagens:
- exige mais cuidado no desenho de login/logout
- exige nomes de cookies e middleware distintos
Recomendação:
- usar **sessões separadas por contexto**
- nunca compartilhar sessão de `partner` com `customer`
- se necessário, compartilhar apenas a sessão do **cliente** entre:
- `pedifoods.com.br`
- `{slug}.pedifoods.com.br`
Implementação sugerida:
- cookie de parceiro:
- exemplo `pedifoods_partner_session`
- domínio restrito a `parceiro.pedifoods.com.br`
- cookie de cliente:
- exemplo `pedifoods_customer_session`
- domínio `.pedifoods.com.br` apenas se a jornada do cliente precisar navegar entre domínio principal e subdomínios de loja
## Estratégia de Dados
### Fonte de verdade
Precisamos assumir explicitamente:
- o Atomenta continua sendo a fonte de verdade operacional
- o PediFoods atua como camada especializada de experiência/lojista/storefront
Isso significa:
- login e sessão podem existir no PediFoods
- mas dados de loja, catálogo, usuário parceiro e regras sensíveis podem continuar vindo do Atomenta via API
## Fases de Implementação
## Fase 1. Higiene e identidade
Objetivo:
- remover confusão estrutural atual
Itens:
- corrigir `package.json` do PediFoods
- corrigir `ecosystem.config.js`
- revisar `env.example`
- revisar nomes, textos e defaults públicos
- separar claramente o que é branding PediFoods e o que é integração Atomenta
Saída:
- o projeto deixa de se identificar como Atomenta
## Fase 2. Resolver host e contexto
Objetivo:
- introduzir leitura de subdomínio
Itens:
- criar utilitário central de hostname
- classificar `public`, `partner`, `store`
- disponibilizar contexto em middleware
- ajustar templates/layouts e rotas para reagirem ao contexto
Saída:
- uma única app responde diferente por host
## Fase 3. Fluxo público `pedifoods.com.br`
Objetivo:
- consolidar homepage + login + listagem de lojas
Itens:
- manter home atual
- após login, mostrar lojas do usuário
- ao clicar na loja, redirecionar para `slug.pedifoods.com.br`
Saída:
- domínio principal vira hub do usuário
## Fase 4. Fluxo parceiro `parceiro.pedifoods.com.br`
Objetivo:
- isolar o backoffice do lojista
Itens:
- mover/ajustar as telas que hoje se comportam como "Atomenta Store"
- garantir que esse host sempre abra painel parceiro
- revisar permissões e middleware
Saída:
- painel do lojista com identidade própria
## Fase 5. Fluxo loja `slug.pedifoods.com.br`
Objetivo:
- transformar storefront em domínio por loja
Itens:
- mapear slug -> loja
- carregar tema, catálogo e dados por slug
- revisar SEO/meta tags
- revisar links absolutos
Saída:
- cada loja com seu subdomínio
## Fase 6. Sessão compartilhada
Objetivo:
- permitir navegação fluida entre subdomínios
Itens:
- cookie domain `.pedifoods.com.br`
- revisar login/logout
- revisar redirecionamentos
- revisar callbacks OTP
Saída:
- usuário loga uma vez e navega entre contextos compatíveis
## Riscos
### 1. Misturar branding com integração
Se trocar tudo de `atomenta` cegamente, pode quebrar integrações reais.
### 2. Cookies atuais
Se hoje os cookies ainda usam nomes herdados, uma troca brusca pode invalidar sessões e OTP.
### 3. Links absolutos hardcoded
Há muitos pontos do PediFoods ainda apontando para Atomenta.
Eles precisam ser classificados antes de alterar.
### 4. Deploy atual
Hoje o deploy sobe um projeto que ainda está semanticamente misturado.
Sem limpeza mínima, a arquitetura nova vai nascer confusa.
## Recomendações Práticas
### Curto prazo
- corrigir identidade mínima do projeto PediFoods
- mapear todos os usos de:
- `atomenta.com.br`
- `atomenta_token`
- `atomenta_auth`
- `ATOMENTA_*`
- classificar cada ocorrência em:
- branding
- integração legítima
### Médio prazo
- implementar middleware de contexto por host
- separar layouts `public`, `partner`, `store`
- começar pelo domínio `parceiro.pedifoods.com.br`
### Longo prazo
- consolidar storefront por slug
- revisar autenticação multi-subdomínio
- avaliar se no futuro compensa separar em múltiplos apps
## Ordem Recomendada de Execução
1. Higienizar identidade do projeto PediFoods
2. Mapear referências herdadas de Atomenta
3. Criar resolvedor central de host/subdomínio
4. Subir `parceiro.pedifoods.com.br`
5. Ajustar `pedifoods.com.br` para login + lista de lojas
6. Implementar `slug.pedifoods.com.br`
7. Revisar cookies e sessão compartilhada
## Decisão Recomendada Agora
A melhor decisão agora é:
- **não tentar “desatomentar” tudo de uma vez**
- primeiro separar:
- identidade pública
- painel parceiro
- storefront por subdomínio
- e só depois revisar dependências herdadas uma a uma
## Próximo Passo
Próxima entrega recomendada:
1. fazer uma auditoria do repositório PediFoods
2. classificar referências herdadas em:
- `pode trocar agora`
- `depende de integração com Atomenta`
3. implementar o resolvedor de subdomínio
4. subir primeiro o contexto `parceiro.pedifoods.com.br`
## Tasks de Implementação
Legenda:
- 🔴 pendente
- 🟢 concluído
### Fase 1. Higiene do projeto PediFoods
- 🟢 Corrigir identidade básica do projeto em `package.json`
- 🟢 Corrigir identidade básica do processo em `ecosystem.config.js`
- 🔴 Revisar `env.example` para separar branding PediFoods de integração Atomenta
- 🟢 Mapear todas as referências a `atomenta` no repositório
- 🟢 Classificar cada referência como:
- branding/UI
- integração técnica legítima
- legado morto
- 🟢 Remover ou ajustar branding incorreto do PediFoods
### Fase 2. Contexto de domínio e subdomínio
- 🟢 Criar utilitário central para resolver o host atual
- 🟢 Criar classificador de contexto:
- `public`
- `partner`
- `store`
- 🟢 Extrair `storeSlug` automaticamente quando o host for `{slug}.pedifoods.com.br`
- 🟢 Injetar esse contexto em middleware global
- 🟢 Expor o contexto para views/templates
- 🔴 Garantir fallback seguro para domínio desconhecido
### Fase 3. Sessões separadas
- 🟢 Criar sessão de parceiro independente
- 🔴 Criar sessão de cliente independente
- 🔴 Garantir que `parceiro.pedifoods.com.br` nunca reutilize sessão de cliente
- 🔴 Garantir que `{slug}.pedifoods.com.br` nunca reutilize sessão de parceiro
- 🟢 Revisar nomes dos cookies
- 🟢 Revisar `login`, `logout` e middleware de autenticação
- 🟢 Revisar redirecionamentos pós-login por contexto
### Fase 4. `pedifoods.com.br`
- 🟢 Consolidar a home pública
- 🟢 Consolidar login/cadastro/recuperação
- 🔴 Criar tela pós-login com listagem de lojas do usuário
- 🟢 Fazer o clique na loja redirecionar para `{slug}.pedifoods.com.br`
- 🟢 Revisar meta tags, canonical e links absolutos
### Fase 5. `parceiro.pedifoods.com.br`
- 🟢 Isolar o painel do lojista nesse host
- 🟢 Revisar middleware e permissões do painel
- 🟢 Revisar links internos do painel para não mandar ao domínio errado
- 🟢 Ajustar onboarding do parceiro
- 🟢 Ajustar rotas administrativas de loja, pedidos, catálogo e financeiro
- 🟢 Garantir que o painel não renderize a experiência pública da loja
### Fase 6. `{slug}.pedifoods.com.br`
- 🟢 Criar resolução da loja por slug
- 🟢 Buscar loja e catálogo pelo slug
- 🟢 Renderizar storefront por loja
- 🟢 Ajustar tema, título, descrição e identidade da loja
- 🟢 Ajustar links compartilháveis da loja
- 🟢 Revisar SEO e OpenGraph por loja
- 🔴 Garantir fallback para slug inexistente
### Fase 7. Gestão de slug da loja
- 🟢 Adicionar campo `storeSlug` como atributo persistido da loja
- 🟢 Validar unicidade global do slug
- 🟢 Validar formato do slug
- 🟢 Gerar pré-preenchimento automático do slug no fim do onboarding com base no nome fantasia da loja
- 🟢 Criar tela de configuração do slug no painel parceiro
- 🟢 Exibir aviso de impacto da troca de slug
- 🟢 Salvar data da última alteração de slug
- 🟢 Bloquear nova alteração por 90 dias
- 🟢 Exibir contador ou data de desbloqueio para nova alteração
- 🔴 Criar redirect temporário do slug antigo para o novo, se viável
### Fase 8. Integração com Atomenta
- 🟢 Revisar quais chamadas devem continuar indo ao Atomenta
- 🟢 Centralizar base URL interna da API do Atomenta
- 🔴 Revisar OTP e autenticação compartilhada
- 🟢 Revisar cookies herdados `atomenta_*`
- 🔴 Revisar endpoints de catálogo, loja, financeiro e customer lookup
- 🔴 Remover dependências herdadas desnecessárias
### Fase 9. Deploy e infraestrutura
- 🔴 Ajustar nginx/proxy para:
- `pedifoods.com.br`
- `parceiro.pedifoods.com.br`
- `*.pedifoods.com.br`
- 🔴 Validar wildcard localmente antes da etapa de DNS público
- 🔴 Garantir certificado TLS compatível com wildcard
- 🔴 Validar resolução correta de host até a app Node
## Status Atual
Legenda:
- 🟢 concluído
- 🟡 parcial
- 🔴 pendente
Resumo do que já foi entregue no app:
- 🟢 identidade básica do projeto corrigida
- 🟢 resolução central de host/subdomínio
- 🟢 separação inicial entre `public`, `partner` e `store`
- 🟢 slug canônico com preview, unicidade, cooldown de 90 dias e geração automática no onboarding
- 🟢 storefront canônica em `{slug}.pedifoods.com.br`
- 🟢 links públicos e links do painel abrindo a loja publicada correta
- 🟢 limpeza principal de branding visível do PediFoods
- 🟢 frontend priorizando `pedifoods_partner_token` com fallback para legado
O que ainda falta para fechar o fluxo completo:
- 🔴 autenticação real do cliente
- 🔴 tela pós-login do cliente com listagem de lojas
- 🔴 fallback para slug inexistente
- 🔴 redirect temporário de slug antigo para slug novo
- 🔴 wildcard real em proxy/Cloudflare/TLS
- 🔴 Validar deploy do PediFoods sem contaminar com Atomenta
- 🔴 Configurar wildcard DNS no Cloudflare
- esta task deve ficar por último
- quando chegarmos nela, você executa manualmente no Cloudflare
- eu só vou te avisar e te passar o valor exato
### Fase 10. Testes de ponta a ponta
- 🔴 Testar login de cliente em `pedifoods.com.br`
- 🔴 Testar seleção de loja e redirecionamento para `{slug}.pedifoods.com.br`
- 🔴 Testar login de parceiro em `parceiro.pedifoods.com.br`
- 🔴 Testar isolamento entre sessão de parceiro e sessão de cliente
- 🔴 Testar troca de slug com bloqueio de 90 dias
- 🔴 Testar slug inválido/inexistente
- 🔴 Testar logout em cada contexto
- 🔴 Testar links absolutos, redirects e cookies
## Política de Slug da Loja
Sua ideia funciona e faz sentido.
### Recomendação
O slug deve ser:
- criado uma primeira vez no onboarding da loja
- editável pelo parceiro no painel
- bloqueado por 90 dias após cada alteração
### Motivos para essa regra
- evita troca frequente de URL
- reduz confusão para clientes recorrentes
- protege links já compartilhados
- reduz impacto operacional em cache, indexing e hábito do usuário
### Como funcionaria
Cada loja teria campos como:
- `storeSlug`
- `slugUpdatedAt`
- `slugLockedUntil`
- `slugGeneratedBySystemAt`
- `slugFirstManualUpdateAt`
Regra:
- se nunca definiu slug:
- o sistema pré-preenche automaticamente
- o usuário pode ajustar antes de concluir
- se já definiu:
- só pode alterar novamente após 90 dias
### Geração inicial do slug
Recomendação:
- ao terminar o onboarding, gerar um pré-preenchimento automático baseado no nome fantasia
Exemplo:
- `Pizzaria do Zé` -> `pizzaria-do-ze`
- `Temaki House Prime` -> `temaki-house-prime`
O usuário:
- vê o slug sugerido
- pode editar antes de salvar pela primeira vez
Importante:
- o slug gerado automaticamente pelo sistema **não inicia** a janela de bloqueio de 90 dias
- ele serve apenas como valor inicial/sugestão persistida
- o contador de 90 dias só começa quando houver a **primeira alteração manual do lojista**
### Limite de tamanho
Recomendação:
- limitar o slug a **30 caracteres**
Esse limite deve contar:
- letras
- números
- hífens
Na prática:
- espaços viram hífen
- então o total final do slug salvo não pode passar de `30`
Exemplo de regra:
- nome fantasia muito grande:
- `Loja Muito Grande Com Nome Enorme da Silva`
- slug gerado:
- `loja-muito-grande-com-nome`
### Regra de sanitização sugerida
O gerador automático deve:
- converter para minúsculas
- remover acentos
- trocar espaços por hífen
- remover caracteres especiais
- colapsar hífens duplicados
- cortar em `30` caracteres
- remover hífen do início/fim
### Se o slug já existir
Se o slug gerado já estiver em uso:
- adicionar sufixo incremental curto
Exemplo:
- `pizzaria-do-ze`
- `pizzaria-do-ze-2`
- `pizzaria-do-ze-3`
Sempre respeitando o limite final de `30` caracteres
### Regra de bloqueio de 90 dias
Regra correta:
- **geração automática pelo sistema**
- não conta como alteração manual
- não bloqueia o campo
- **primeira edição manual feita pelo lojista**
- passa a contar como alteração real
- grava `slugFirstManualUpdateAt`
- grava `slugUpdatedAt`
- define `slugLockedUntil = slugUpdatedAt + 90 dias`
- **edições seguintes**
- só permitidas após `slugLockedUntil`
### Exemplo prático
1. onboarding termina
2. sistema gera:
- `pizzaria-do-ze`
3. lojista vê o valor e troca para:
- `pizza-do-ze-centro`
4. essa troca manual:
- conta como primeira alteração real
- inicia a trava de 90 dias
Se o lojista aceitar o slug automático sem mexer:
- nenhum bloqueio de 90 dias deve ser iniciado
- ele ainda pode fazer a primeira alteração manual depois
### Ajuste das tasks
- 🔴 Persistir metadado informando se o slug atual foi gerado automaticamente ou definido manualmente
- 🔴 Garantir que a geração automática no onboarding não inicie `slugLockedUntil`
- 🔴 Iniciar a janela de 90 dias somente na primeira alteração manual do lojista
### Validações necessárias
- slug único no sistema
- somente letras minúsculas, números e hífen
- tamanho mínimo e máximo
- blacklist de reservados, por exemplo:
- `www`
- `api`
- `admin`
- `app`
- `parceiro`
- `mail`
- `git`
- `proxy`
### Redirect do slug antigo
Se possível, é recomendável:
- guardar o slug antigo
- fazer redirect `301` do slug antigo para o novo por um período
Isso ajuda:
- SEO
- links salvos
- usuários acostumados com a URL antiga
### Sobre DNS e Cloudflare
Isso funciona bem se você usar:
- wildcard DNS `*.pedifoods.com.br`
- proxy reverso lendo o `Host`
Nesse modelo:
- você **não precisa criar um registro DNS novo por loja**
- o wildcard cobre todos os slugs
- a troca de slug depende mais da aplicação e do cache HTTP do que de propagação DNS individual
Ou seja:
- a trava de 90 dias continua fazendo sentido por produto e UX
- mas não por limitação técnica de DNS em si
### Recomendação final para slug
Implementar assim:
- slug definido no onboarding
- edição permitida no painel parceiro
- nova alteração somente após 90 dias
- aviso visual claro antes de salvar
- registro do slug antigo para redirect temporário