diff --git a/Darwin/Entitlements.plist b/Darwin/Entitlements.plist index 6631ffa..70084b4 100644 --- a/Darwin/Entitlements.plist +++ b/Darwin/Entitlements.plist @@ -2,5 +2,7 @@ + com.apple.developer.devicecheck.appattest-environment + development diff --git a/Darwin/fastlane/metadata/en-US/support_url.txt b/Darwin/fastlane/metadata/en-US/support_url.txt index fcfa1f7..7ef4109 100644 --- a/Darwin/fastlane/metadata/en-US/support_url.txt +++ b/Darwin/fastlane/metadata/en-US/support_url.txt @@ -1 +1 @@ -https://example.org/support/ +https://pedifoods.com.br/support diff --git a/Sources/PediFoods/ContentView.swift b/Sources/PediFoods/ContentView.swift index 3930ebf..809c4de 100644 --- a/Sources/PediFoods/ContentView.swift +++ b/Sources/PediFoods/ContentView.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct ContentView: View { - @State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main + @State var root: RootFlow = .main @State var selectedTab: MainTab = .home private let tokenStore: TokenStore = DefaultTokenStore() @State var appState = AppState() @@ -167,20 +167,35 @@ struct ContentView: View { appState.cart = cachedCart } + // Anonymous session: the account address cache above doesn't apply — + // restore the "ENTREGAR EM" label from the state/city picked via the + // public locator (GuestLocationStore persists this in Keychain across + // launches on its own; this just resyncs the display label with it). + if appState.session.isAuthenticated == false, + let guestState = GuestLocationStore.shared.selectedState, + let guestCity = GuestLocationStore.shared.selectedCity { + appState.address.display = "\(guestCity), \(guestState)" + dismissAddressPickerIfAddressExists() + } + // Always refresh profile when authenticated. // This keeps profile/address/cart scope consistent after relogin // and avoids stale local state during checkout payload generation. - do { - let response = try await ApiService().profile() - if response.error == false, let customer = response.result { - hydrateAppState(with: customer) + // Anonymous browsing has no session to refresh, so skip the call + // entirely rather than let it 401 and force-logout a guest. + if appState.session.isAuthenticated { + do { + let response = try await ApiService().profile() + if response.error == false, let customer = response.result { + hydrateAppState(with: customer) + } + } catch let error as ApiServiceError { + if case .sessionExpired = error { + forceLogoutToStart() + } + } catch { + // Keep local state when backend refresh fails transiently } - } catch let error as ApiServiceError { - if case .sessionExpired = error { - forceLogoutToStart() - } - } catch { - // Keep local state when backend refresh fails transiently } await refreshFeatureFlags(forceRefresh: false) @@ -418,13 +433,17 @@ struct AddressPickerModalView: View { var body: some View { NavigationStack { - AddressesView( - message: appState.address.onboardingMessage, - appState: $appState, - selectionMode: true - ) - .onAppear { - appState.address.onboardingMessage = nil + if appState.session.isAuthenticated { + AddressesView( + message: appState.address.onboardingMessage, + appState: $appState, + selectionMode: true + ) + .onAppear { + appState.address.onboardingMessage = nil + } + } else { + PublicLocationPickerView(appState: $appState) } } } diff --git a/Sources/PediFoods/Resources/Localizable.xcstrings b/Sources/PediFoods/Resources/Localizable.xcstrings index e776746..b1307e2 100644 --- a/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/Sources/PediFoods/Resources/Localizable.xcstrings @@ -456,6 +456,12 @@ "Entrar" : { "comment" : "A link that directs the user to the login screen.", "isCommentAutoGenerated" : true + }, + "Entrar ou Cadastrar" : { + + }, + "Entre na sua conta" : { + }, "Entrega" : { "comment" : "A text describing delivery mode.", @@ -482,9 +488,18 @@ "Escolha o tamanho da sua fome" : { "comment" : "A label displayed below the pizza size selection.", "isCommentAutoGenerated" : true + }, + "Escolha seu estado" : { + }, "Escolha seu sabor" : { + }, + "Escolha sua cidade" : { + + }, + "Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados." : { + }, "Este sabor não possui adicionais." : { "comment" : "A message displayed when a pizza flavor does not have any add-ons.", @@ -493,6 +508,15 @@ "Excluir" : { "comment" : "A button label that translates to \"Delete\".", "isCommentAutoGenerated" : true + }, + "Excluir Conta" : { + + }, + "Excluir sua conta?" : { + + }, + "Faça login ou cadastre-se para ver seu perfil, pedidos e endereços." : { + }, "Favorite" : { "comment" : "Item editor title label for marking the item as a favorite", diff --git a/Sources/PediFoods/Services/ApiClient.swift b/Sources/PediFoods/Services/ApiClient.swift index cdc919a..0fcae62 100644 --- a/Sources/PediFoods/Services/ApiClient.swift +++ b/Sources/PediFoods/Services/ApiClient.swift @@ -57,19 +57,30 @@ struct ApiRequest: Sendable { let requiresAuth: Bool let queryItems: [URLQueryItem] let body: Data? + /// Overrides `ApiConfig.baseURL` (Atomenta) for this single request — used + /// to reach the PediFoods BFF (`ApiConfig.pediFoodsBFFURL`) instead. + let baseURLOverride: URL? + /// Sent as `Authorization: Bearer ` instead of the customer JWT. + /// Used for guest-session-authenticated public locator calls, which must + /// never touch `TokenStore`'s customer session. + let customBearerToken: String? init(path: String, method: String = "GET", module: ApiModule = .none, requiresAuth: Bool = true, queryItems: [URLQueryItem] = [], - body: Data? = nil) { + body: Data? = nil, + baseURLOverride: URL? = nil, + customBearerToken: String? = nil) { self.path = path self.method = method self.module = module self.requiresAuth = requiresAuth self.queryItems = queryItems self.body = body + self.baseURLOverride = baseURLOverride + self.customBearerToken = customBearerToken } } @@ -120,7 +131,7 @@ final class ApiClient: @unchecked Sendable { private extension ApiClient { #if canImport(LCEssentials) && os(iOS) func sendWithLCEssentials(_ request: ApiRequest) async throws -> T { - let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString + let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString let method = request.method let headers = buildHeaders(for: request) let params = request.body @@ -228,7 +239,7 @@ private extension ApiClient { #endif func sendWithURLSession(_ request: ApiRequest) async throws -> T { - let url = try buildURL(path: request.path, query: request.queryItems) + let url = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL) var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method urlRequest.httpBody = request.body @@ -314,7 +325,9 @@ private extension ApiClient { if let token = ApiConfig.token(for: request.module) { headers["Atomenta-Token"] = token } - if request.requiresAuth, let jwt = tokenStore.jwt { + if let customBearerToken = request.customBearerToken { + headers["Authorization"] = "Bearer \(customBearerToken)" + } else if request.requiresAuth, let jwt = tokenStore.jwt { headers["Authorization"] = "Bearer \(jwt)" } return headers @@ -474,8 +487,8 @@ private extension ApiClient { return trimmed } - func buildURL(path: String, query: [URLQueryItem]) throws -> URL { - guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { + func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { throw NetworkError.invalidURL } components.path = components.path.appending(path) diff --git a/Sources/PediFoods/Services/ApiConfig.swift b/Sources/PediFoods/Services/ApiConfig.swift index 4c6366a..028105c 100644 --- a/Sources/PediFoods/Services/ApiConfig.swift +++ b/Sources/PediFoods/Services/ApiConfig.swift @@ -9,6 +9,11 @@ enum ApiModule: Sendable { } enum ApiConfig { + // Atomenta directly — customer/store/cards/orders/addresses/etc. Only + // the public locator (session, locations, categories, stores-by-location, + // store detail) goes through pediFoodsBFFURL below via explicit + // baseURLOverride on those specific requests, per + // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. static var baseURL: URL { let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br" return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! @@ -19,6 +24,15 @@ enum ApiConfig { return URL(string: raw) ?? URL(string: "http://localhost:8787")! } + /// BFF (`PediFoods_web`) base URL. The public store locator (guest session, + /// states/cities/stores-by-location, store detail) must go through here — + /// never call `baseURL` (Atomenta) directly for these, per + /// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. + static var pediFoodsBFFURL: URL { + let raw = ProcessInfo.processInfo.environment["PEDIFOODS_BFF_URL"] ?? "https://pedifoods.com.br" + return URL(string: raw) ?? URL(string: "https://pedifoods.com.br")! + } + static var featureControlEnvironment: String { let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/PediFoods/Services/ApiService.swift b/Sources/PediFoods/Services/ApiService.swift index 5d55a62..2eba839 100644 --- a/Sources/PediFoods/Services/ApiService.swift +++ b/Sources/PediFoods/Services/ApiService.swift @@ -35,7 +35,7 @@ final class ApiService { do { return try await client.send(req) } catch let error as NetworkError { - if case .unauthorized(let message) = error { + if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error { expireSession(message) throw ApiServiceError.sessionExpired(message) } @@ -45,13 +45,22 @@ final class ApiService { private func sendEnvelope(_ req: ApiRequest) async throws -> ApiEnvelope { let envelope: ApiEnvelope = try await send(req) - if isSessionExpiredEnvelope(envelope) { + if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) { expireSession(envelope.message) throw ApiServiceError.sessionExpired(envelope.message) } return envelope } + /// A request that never carried the customer JWT (public/unauthenticated + /// calls) can never mean *the customer's* session expired — an unrelated + /// error (e.g. Atomenta's module-token check) must not force-logout a + /// user, anonymous or not, just because its error code happens to + /// contain the substring "token". See app-migrate-atomenta-calls-to-pedifoods-bff.md. + private func canTriggerSessionExpiry(for req: ApiRequest) -> Bool { + req.requiresAuth && tokenStore.jwt != nil + } + private func isSessionExpiredEnvelope(_ envelope: ApiEnvelope) -> Bool { guard envelope.error else { return false } let code = (envelope.code ?? "").lowercased() @@ -96,6 +105,16 @@ final class ApiService { if let birthDate, birthDate.isEmpty == false { payload["birthDate"] = birthDate } + // If the visitor picked a state/city via the public locator before + // signing up, forward it so the backend can set it as the account's + // default city. NOTE: as of this writing Atomenta's customer create + // controller only reads name/email/phoneNumber — these two fields + // are a no-op server-side until that controller is updated to + // persist them (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md). + if let state = GuestLocationStore.shared.selectedState, let city = GuestLocationStore.shared.selectedCity { + payload["defaultState"] = state + payload["defaultCity"] = city + } let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) return try await sendEnvelope(req) @@ -308,7 +327,10 @@ final class ApiService { return cached } - let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) + // Open endpoint, no guest session needed, but it lives on the BFF + // domain (pedifoods.com.br), not Atomenta — see + // docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. + let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false, baseURLOverride: ApiConfig.pediFoodsBFFURL) let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req) if envelope.error == false { AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours) @@ -447,6 +469,11 @@ final class ApiService { return try await sendEnvelope(req) } + func deleteAccount() async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/account", method: "DELETE", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body) diff --git a/Sources/PediFoods/Services/GuestLocationStore.swift b/Sources/PediFoods/Services/GuestLocationStore.swift new file mode 100644 index 0000000..212af56 --- /dev/null +++ b/Sources/PediFoods/Services/GuestLocationStore.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Local (device-only) state for the pre-login public store locator — +/// see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. +final class GuestLocationStore: @unchecked Sendable { + static let shared = GuestLocationStore() + + private let serviceName = "com.br.pedifoods.app.guest" + private let deviceIdKey = "device_id" + private let attestationKey = "attestation" + private let attestKeyIdKey = "attest_key_id" + private let stateKey = "selected_state" + private let cityKey = "selected_city" + + /// Stable per-install identifier sent as `deviceId` in the guest handshake. + var deviceId: String { + if let existing = KeychainStore.load(service: serviceName, key: deviceIdKey) { + return existing + } + let generated = UUID().uuidString + KeychainStore.save(generated, service: serviceName, key: deviceIdKey) + return generated + } + + /// Fallback-only placeholder (backend just checks non-empty) for + /// environments that can't run real App Attest — Simulator, or non-iOS. + /// Real devices use DCAppAttestService via GuestSessionService instead. + var attestationPlaceholder: String { + if let existing = KeychainStore.load(service: serviceName, key: attestationKey) { + return existing + } + let generated = UUID().uuidString + KeychainStore.save(generated, service: serviceName, key: attestationKey) + return generated + } + + /// App Attest key ID already registered with the backend for this + /// device, if any. Present -> use it to sign assertions; absent -> this + /// device needs to attest a freshly generated key first. + var appAttestKeyId: String? { + get { KeychainStore.load(service: serviceName, key: attestKeyIdKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: attestKeyIdKey) + } else { + KeychainStore.delete(service: serviceName, key: attestKeyIdKey) + } + } + } + + var selectedState: String? { + get { KeychainStore.load(service: serviceName, key: stateKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: stateKey) + } else { + KeychainStore.delete(service: serviceName, key: stateKey) + } + } + } + + var selectedCity: String? { + get { KeychainStore.load(service: serviceName, key: cityKey) } + set { + if let newValue { + KeychainStore.save(newValue, service: serviceName, key: cityKey) + } else { + KeychainStore.delete(service: serviceName, key: cityKey) + } + } + } + + func clearSelectedLocation() { + KeychainStore.delete(service: serviceName, key: stateKey) + KeychainStore.delete(service: serviceName, key: cityKey) + } +} diff --git a/Sources/PediFoods/Services/GuestSessionService.swift b/Sources/PediFoods/Services/GuestSessionService.swift new file mode 100644 index 0000000..869c3ed --- /dev/null +++ b/Sources/PediFoods/Services/GuestSessionService.swift @@ -0,0 +1,222 @@ +import Foundation +#if os(iOS) +import DeviceCheck +import CryptoKit +#endif + +private struct ChallengeResult: Decodable, Sendable { + let challenge: String +} + +/// Fields differ by handshake step — Optional properties are omitted from +/// the encoded JSON entirely (Codable synthesis uses encodeIfPresent), so +/// this one struct covers both the fresh-attestation and assertion payloads. +private struct GuestSessionAttestPayload: Encodable, Sendable { + let platform: String + let deviceId: String + let challenge: String + let keyId: String? + let attestation: String? + let assertion: String? +} + +private struct GuestSessionPlaceholderPayload: Encodable, Sendable { + let platform: String + let deviceId: String + let attestation: String +} + +/// Issues and caches the short-lived (15min) guest JWT used by the pre-login +/// public store locator. Deliberately separate from ApiService/TokenStore — +/// a guest-session 401 must never be treated as the customer session +/// expiring (see docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md). +/// +/// iOS uses real App Attest (2-call protocol: challenge, then +/// attest-a-fresh-key the first time per device or sign-an-assertion with +/// the already-registered key every time after). If the server doesn't +/// recognize a previously-registered key (e.g. it lost its in-memory +/// registration), the assertion call fails and this falls back to +/// re-attesting with a brand new key rather than leaving the guest stuck. +actor GuestSessionService { + static let shared = GuestSessionService() + + private var cachedToken: String? + private var expiresAt: Date? + private let client: ApiClient + private let store: GuestLocationStore + + // Actors are reentrant across `await` points — without this, two + // concurrent callers with no cached token yet (very possible: the + // location picker and Home's guest store load can both need a guest + // session near launch) would each independently run the App Attest + // flow, racing each other's key registration against the server. This + // makes every caller share the one in-flight handshake instead. + private var inFlightRefresh: Task? + + init(client: ApiClient = ApiClient(), store: GuestLocationStore = .shared) { + self.client = client + self.store = store + } + + func validToken() async throws -> String { + if let cachedToken, let expiresAt, expiresAt > Date() { + return cachedToken + } + return try await refreshToken() + } + + /// Forces a fresh handshake, used for the silent retry-on-401 flow. + func invalidateAndRefresh() async throws -> String { + cachedToken = nil + expiresAt = nil + inFlightRefresh = nil + return try await refreshToken() + } + + private func refreshToken() async throws -> String { + if let inFlightRefresh { + return try await inFlightRefresh.value + } + let task = Task { try await performRefresh() } + inFlightRefresh = task + defer { inFlightRefresh = nil } + return try await task.value + } + + private func performRefresh() async throws -> String { +#if os(iOS) + return try await refreshTokenWithAppAttest() +#else + return try await refreshTokenWithPlaceholder() +#endif + } + +#if os(iOS) + private func refreshTokenWithAppAttest() async throws -> String { + guard DCAppAttestService.shared.isSupported else { + // Simulator can never support App Attest (hardware limitation, + // not environment-specific) — server has its own documented + // bypass for this case, gated by an admin toggle server-side. + let challenge = try await fetchChallenge() + return try await handshakeWithSimulatorBypass(challenge: challenge) + } + + if let existingKeyId = store.appAttestKeyId { + do { + let challenge = try await fetchChallenge() + return try await handshakeWithAssertion(keyId: existingKeyId, challenge: challenge) + } catch let error as NetworkError where isKeyRejectedByServer(error) { + // Server explicitly rejected this key (403 + // APP_ATTEST_VERIFICATION_FAILED — e.g. it lost the + // credential registration) — re-attest with a new key. Any + // other error (network blip, timeout, decode issue) must + // NOT wipe a perfectly valid registered key. + store.appAttestKeyId = nil + } + } + + let challenge = try await fetchChallenge() + return try await handshakeWithFreshAttestation(challenge: challenge) + } + + private func isKeyRejectedByServer(_ error: NetworkError) -> Bool { + if case .httpError(403, _) = error { return true } + return false + } + + private func fetchChallenge() async throws -> String { + let req = ApiRequest( + path: "/api/public/attest/challenge", + method: "GET", + module: .none, + requiresAuth: false, + queryItems: [URLQueryItem(name: "deviceId", value: store.deviceId)], + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result.challenge + } + + private func handshakeWithFreshAttestation(challenge: String) async throws -> String { + let keyId = try await DCAppAttestService.shared.generateKey() + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let attestationObject = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash) + + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: keyId, + attestation: attestationObject.base64EncodedString(), + assertion: nil + ) + let token = try await sendSessionRequest(body: JSONEncoder().encode(payload)) + store.appAttestKeyId = keyId + return token + } + + /// Simulator can never run real App Attest — server accepts this literal + /// bypass value instead, gated by its own admin toggle (403 + /// SIMULATOR_BYPASS_DISABLED if that toggle is off; not a client bug). + private func handshakeWithSimulatorBypass(challenge: String) async throws -> String { + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: nil, + attestation: "SIMULATOR_BYPASS", + assertion: nil + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } + + private func handshakeWithAssertion(keyId: String, challenge: String) async throws -> String { + let clientDataHash = Data(SHA256.hash(data: Data(challenge.utf8))) + let assertionObject = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: clientDataHash) + + let payload = GuestSessionAttestPayload( + platform: "ios", + deviceId: store.deviceId, + challenge: challenge, + keyId: nil, + attestation: nil, + assertion: assertionObject.base64EncodedString() + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } +#endif + + /// Only reachable on non-iOS builds (this app's macOS target is test-only, + /// never a real distribution target — no documented contract for it, so + /// this stays a best-effort placeholder rather than matching a real spec. + private func refreshTokenWithPlaceholder() async throws -> String { + let payload = GuestSessionPlaceholderPayload( + platform: "ios", + deviceId: store.deviceId, + attestation: store.attestationPlaceholder + ) + return try await sendSessionRequest(body: JSONEncoder().encode(payload)) + } + + private func sendSessionRequest(body: Data) async throws -> String { + let req = ApiRequest( + path: "/api/public/session", + method: "POST", + module: .none, + requiresAuth: false, + body: body, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + cachedToken = result.guestToken + // Refresh a bit early so a request started near expiry doesn't race the server's own clock. + expiresAt = Date().addingTimeInterval(TimeInterval(result.expiresIn) - 30) + return result.guestToken + } +} diff --git a/Sources/PediFoods/Services/KeychainStore.swift b/Sources/PediFoods/Services/KeychainStore.swift new file mode 100644 index 0000000..fcb48a7 --- /dev/null +++ b/Sources/PediFoods/Services/KeychainStore.swift @@ -0,0 +1,60 @@ +import Foundation +#if os(iOS) +import Security +#endif + +/// Small generic Keychain wrapper (iOS) with a UserDefaults fallback on other +/// platforms (macOS test target), namespaced by `service`+`key`. +enum KeychainStore { + static func save(_ value: String, service: String, key: String) { +#if os(iOS) + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + var attributes = query + attributes[kSecValueData as String] = data + SecItemAdd(attributes as CFDictionary, nil) +#else + UserDefaults.standard.set(value, forKey: "\(service).\(key)") +#endif + } + + static func load(service: String, key: String) -> String? { +#if os(iOS) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value +#else + UserDefaults.standard.string(forKey: "\(service).\(key)") +#endif + } + + static func delete(service: String, key: String) { +#if os(iOS) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) +#else + UserDefaults.standard.removeObject(forKey: "\(service).\(key)") +#endif + } +} diff --git a/Sources/PediFoods/Services/PublicLocationModels.swift b/Sources/PediFoods/Services/PublicLocationModels.swift new file mode 100644 index 0000000..516c2a2 --- /dev/null +++ b/Sources/PediFoods/Services/PublicLocationModels.swift @@ -0,0 +1,117 @@ +import Foundation + +// DTOs for the pre-login public store locator (pedifoods.com.br BFF). +// See docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md for the +// exact contract these mirror. + +struct GuestSessionResult: Decodable, Sendable { + let guestToken: String + let expiresIn: Int +} + +/// `GET /api/public/locations` returns states+cities in one call, keyed by +/// state UF with an array of city names — e.g. `{"SP": ["Aguaí", "Campinas"]}`. +typealias PublicLocationsResult = [String: [String]] + +struct PublicStoreListItem: Decodable, Sendable, Identifiable { + let id: String + let storeId: String? + let name: String? + let logo: String? + let cover: String? + let category: String? + let isOpen: Bool? + let statusLabel: String? + let nextOpenLabel: String? + let rating: Double? + let totalReviews: Int? + let deliveryTime: String? + let deliveryFee: Double? + let minOrder: Double? +} + +struct PublicStoreDetail: Decodable, Sendable { + let id: String + let storeId: String? + let slug: String? + let fantasyName: String? + let razaoSocial: String? + let logo: String? + let cover: String? + let specialty: String? + let phone: String? + let responsiblePhone: String? + let address: String? + let neighborhood: String? + let city: String? + let state: String? + let zipcode: String? + let isOpen: Bool? + let statusLabel: String? + let nextOpenLabel: String? + let averageRate: Double? + let totalReviews: Int? + let deliveryTime: String? + let deliveryFee: Double? + let deliveryPrice: Double? + let minOrder: Double? + let acceptPix: Bool? +} + +// Maps the public (anonymous) store-detail projection onto the same models +// StoreDetailView already renders for authenticated users, so the view +// itself doesn't need to know which source the data came from. +extension StoreInfoResult { + init(publicDetail: PublicStoreDetail) { + isOpen = publicDetail.isOpen + statusLabel = publicDetail.statusLabel + fantasyName = publicDetail.fantasyName + phone = publicDetail.phone + whatsapp = nil + logo = publicDetail.logo + cover = publicDetail.cover + deliveryTime = publicDetail.deliveryTime + minOrder = publicDetail.minOrder + address = StoreAddressInfo(publicDetail: publicDetail) + paymentMethods = StorePaymentMethodsInfo(acceptPix: publicDetail.acceptPix) + } +} + +extension StoreAddressInfo { + init(publicDetail: PublicStoreDetail) { + street = publicDetail.address + number = nil + neighborhood = publicDetail.neighborhood + city = publicDetail.city + state = publicDetail.state + zipCode = publicDetail.zipcode + latitude = nil + longitude = nil + } +} + +extension StorePaymentMethodsInfo { + /// The public projection only exposes whether Pix is accepted — every + /// other payment flag is unknown until the user is authenticated and can + /// see it via the real store-info call. + init(acceptPix: Bool?) { + paymentOnDelivery = nil + paymentOnPickup = nil + self.acceptPix = acceptPix + acceptCash = nil + acceptCreditCard = nil + acceptDebitCard = nil + acceptCreditVisa = nil + acceptCreditMaster = nil + acceptCreditElo = nil + acceptCreditAmex = nil + acceptCreditHipercard = nil + acceptDebitVisa = nil + acceptDebitMaster = nil + acceptDebitElo = nil + acceptVoucherAlelo = nil + acceptVoucherSodexo = nil + acceptVoucherTicket = nil + acceptVoucherVR = nil + } +} diff --git a/Sources/PediFoods/Services/PublicLocationService.swift b/Sources/PediFoods/Services/PublicLocationService.swift new file mode 100644 index 0000000..af0eade --- /dev/null +++ b/Sources/PediFoods/Services/PublicLocationService.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Pre-login public store locator — states/cities/stores-by-location, and +/// store detail. Always talks to `pedifoods.com.br` (the BFF), never +/// `atomenta.com.br` directly. See +/// docs/plans/app-migrate-atomenta-calls-to-pedifoods-bff.md. +/// +/// Deliberately does not go through ApiService.sendEnvelope — that treats +/// any session-expired-shaped response as the *customer* session expiring +/// (clears TokenStore, forces logout). A guest-session 401 here means the +/// short-lived guest JWT expired and must be silently refreshed instead. +final class PublicLocationService: @unchecked Sendable { + static let shared = PublicLocationService() + + private let client: ApiClient + private let guestSession: GuestSessionService + + init(client: ApiClient = ApiClient(), guestSession: GuestSessionService = .shared) { + self.client = client + self.guestSession = guestSession + } + + /// States + their cities in one call — keyed by UF, e.g. `{"SP": [...]}`. + func fetchLocations() async throws -> PublicLocationsResult { + try await sendGuestAuthed(path: "/api/public/locations") + } + + func fetchStores(state: String, city: String) async throws -> [PublicStoreListItem] { + let query = [ + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "city", value: city) + ] + return try await sendGuestAuthed(path: "/api/public/stores/by-location", query: query) + } + + /// No guest token — this route is fully public/unauthenticated per the doc. + func fetchStoreDetail(identifier: String) async throws -> PublicStoreDetail { + let encodedIdentifier = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? identifier + let req = ApiRequest( + path: "/api/public/store/\(encodedIdentifier)", + method: "GET", + module: .none, + requiresAuth: false, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + /// No guest token — same open pattern as fetchStoreDetail. Reuses the + /// existing StoreCatalogCategory/StoreCatalogProduct models directly: + /// the public response is the same category+products shape (with extra + /// computed inventory/stockStatus fields the decoder just ignores). + func fetchStoreProducts(storeId: String) async throws -> [StoreCatalogCategory] { + let encodedStoreId = storeId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? storeId + let req = ApiRequest( + path: "/api/public/store/\(encodedStoreId)/products", + method: "GET", + module: .none, + requiresAuth: false, + baseURLOverride: ApiConfig.pediFoodsBFFURL + ) + let envelope: ApiEnvelope<[StoreCatalogCategory]> = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + private func sendGuestAuthed(path: String, query: [URLQueryItem] = []) async throws -> T { + let token = try await guestSession.validToken() + do { + return try await performGuestRequest(path: path, query: query, token: token) + } catch let error as NetworkError where isGuestSessionExpired(error) { + let refreshed = try await guestSession.invalidateAndRefresh() + return try await performGuestRequest(path: path, query: query, token: refreshed) + } + } + + private func performGuestRequest(path: String, query: [URLQueryItem], token: String) async throws -> T { + let req = ApiRequest( + path: path, + method: "GET", + module: .none, + requiresAuth: false, + queryItems: query, + baseURLOverride: ApiConfig.pediFoodsBFFURL, + customBearerToken: token + ) + let envelope: ApiEnvelope = try await client.send(req) + guard envelope.error == false, let result = envelope.result else { + throw NetworkError.invalidResponse + } + return result + } + + private func isGuestSessionExpired(_ error: NetworkError) -> Bool { + switch error { + case .unauthorized: + return true + case .httpError(let statusCode, _): + return statusCode == 401 + default: + return false + } + } +} diff --git a/Sources/PediFoods/Services/TokenStore.swift b/Sources/PediFoods/Services/TokenStore.swift index 7813a31..a0155c8 100644 --- a/Sources/PediFoods/Services/TokenStore.swift +++ b/Sources/PediFoods/Services/TokenStore.swift @@ -1,7 +1,4 @@ import Foundation -#if os(iOS) -import Security -#endif protocol TokenStore: AnyObject { var jwt: String? { get set } @@ -10,12 +7,13 @@ protocol TokenStore: AnyObject { final class DefaultTokenStore: TokenStore { private let key = "auth_jwt" + private let serviceName = "com.br.pedifoods.app.auth" private let defaults = UserDefaults.standard var jwt: String? { get { #if os(iOS) - if let keychainValue = loadKeychainValue(for: key) { + if let keychainValue = KeychainStore.load(service: serviceName, key: key) { return keychainValue } #endif @@ -24,9 +22,9 @@ final class DefaultTokenStore: TokenStore { set { #if os(iOS) if let newValue { - saveKeychainValue(newValue, for: key) + KeychainStore.save(newValue, service: serviceName, key: key) } else { - deleteKeychainValue(for: key) + KeychainStore.delete(service: serviceName, key: key) } #endif defaults.set(newValue, forKey: key) @@ -35,57 +33,8 @@ final class DefaultTokenStore: TokenStore { func clear() { #if os(iOS) - deleteKeychainValue(for: key) + KeychainStore.delete(service: serviceName, key: key) #endif defaults.removeObject(forKey: key) } - -#if os(iOS) - private var serviceName: String { "com.br.pedifoods.app.auth" } - - private func saveKeychainValue(_ value: String, for key: String) { - guard let data = value.data(using: .utf8) else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: key - ] - - SecItemDelete(query as CFDictionary) - let attributes: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - SecItemAdd(attributes as CFDictionary, nil) - } - - private func loadKeychainValue(for key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let data = result as? Data, - let value = String(data: data, encoding: .utf8) else { - return nil - } - return value - } - - private func deleteKeychainValue(for key: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName, - kSecAttrAccount as String: key - ] - SecItemDelete(query as CFDictionary) - } -#endif } diff --git a/Sources/PediFoods/Views/Main/CartView.swift b/Sources/PediFoods/Views/Main/CartView.swift index 00e93aa..9f64b4b 100644 --- a/Sources/PediFoods/Views/Main/CartView.swift +++ b/Sources/PediFoods/Views/Main/CartView.swift @@ -4,6 +4,7 @@ import SwiftUI struct CartView: View { @Binding var appState: AppState @Binding var selectedTab: MainTab + @Binding var root: RootFlow @State var openCheckout = false @State var couponCode = "" @State var appliedCouponCode: String? = nil @@ -148,6 +149,10 @@ struct CartView: View { summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) Button { + guard appState.session.isAuthenticated else { + root = .auth + return + } openCheckout = true } label: { HStack(spacing: 10) { diff --git a/Sources/PediFoods/Views/Main/HomeView+Data.swift b/Sources/PediFoods/Views/Main/HomeView+Data.swift index 355fa8b..2c1510b 100644 --- a/Sources/PediFoods/Views/Main/HomeView+Data.swift +++ b/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -135,4 +135,62 @@ extension HomeView { } return "Não foi possível carregar os estabelecimentos." } + + /// Anonymous store loading: no account, no coordinates — just the + /// manually-picked state/city from the public locator. The BFF endpoint + /// has no category filter, so any category chip selection is applied + /// client-side via `filteredStores` (HomeView+Filtering.swift), same as + /// the multi-select filters already do. + @MainActor + func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async { + guard let state = GuestLocationStore.shared.selectedState, + let city = GuestLocationStore.shared.selectedCity else { + isLoadingStores = false + stores = [] + storesError = "Escolha um estado e cidade para visualizar os estabelecimentos." + appState.activeModal = .addressPicker + return + } + + do { + let items = try await PublicLocationService.shared.fetchStores(state: state, city: city) + isLoadingStores = false + let mapped = items.map(StoreSummary.init(publicItem:)) + stores = mapped + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + } catch { + if isCancelledRequest(error) { + isLoadingStores = false + return + } + isLoadingStores = false + reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores) + } + } +} + +extension StoreSummary { + /// Maps the public-locator DTO onto the same model HomeView already + /// renders — distance/positiveReviews don't exist in that response. + init(publicItem: PublicStoreListItem) { + self.id = publicItem.id + self.name = publicItem.name ?? "Loja" + self.logo = publicItem.logo + self.cover = publicItem.cover + self.category = publicItem.category + self.rating = publicItem.rating + self.reviewsCount = publicItem.totalReviews + self.positiveReviews = nil + self.deliveryTime = publicItem.deliveryTime + self.deliveryFee = publicItem.deliveryFee + self.distance = nil + self.isOpen = publicItem.isOpen + self.statusLabel = publicItem.statusLabel + } } diff --git a/Sources/PediFoods/Views/Main/HomeView.swift b/Sources/PediFoods/Views/Main/HomeView.swift index 7f4a23c..093e102 100644 --- a/Sources/PediFoods/Views/Main/HomeView.swift +++ b/Sources/PediFoods/Views/Main/HomeView.swift @@ -123,17 +123,7 @@ struct HomeView: View { HStack(spacing: 16) { ForEach(featuredStoresCards) { store in NavigationLink { - StoreDetailView( - storeId: store.id, - storeName: store.name, - storeCoverURL: store.coverURL, - storeLogoURL: store.logoURL, - storeCategory: store.category, - storeRating: store.rating, - storeDistance: store.distance, - storeDeliveryFee: store.deliveryFee, - appState: $appState - ) + storeDestination(for: store) } label: { FeaturedStoreCard( store: store, @@ -210,22 +200,27 @@ struct HomeView: View { } } + @ViewBuilder + private func storeDestination(for store: FeaturedStoreCardModel) -> some View { + StoreDetailView( + storeId: store.id, + storeName: store.name, + storeCoverURL: store.coverURL, + storeLogoURL: store.logoURL, + storeCategory: store.category, + storeRating: store.rating, + storeDistance: store.distance, + storeDeliveryFee: store.deliveryFee, + appState: $appState + ) + } + @ViewBuilder private var storeCardsList: some View { VStack(spacing: 16) { ForEach(filteredStoreCards) { store in NavigationLink { - StoreDetailView( - storeId: store.id, - storeName: store.name, - storeCoverURL: store.coverURL, - storeLogoURL: store.logoURL, - storeCategory: store.category, - storeRating: store.rating, - storeDistance: store.distance, - storeDeliveryFee: store.deliveryFee, - appState: $appState - ) + storeDestination(for: store) } label: { FeaturedStoreCard( store: store, @@ -407,6 +402,14 @@ struct HomeView: View { storesError = nil } + // Anonymous browsing has no account address/coordinates — the public + // locator uses a manually-picked state/city instead (geolocation is + // out of scope for that flow, see public-store-locator-sdd.md). + guard appState.session.isAuthenticated else { + await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories) + return + } + let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) let hasAddress = hasConfiguredAddress() @@ -498,7 +501,7 @@ struct HomeView: View { } } - private func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) { + func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) { if hadExistingStores { SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0) } else { @@ -548,7 +551,7 @@ struct HomeView: View { return selected.title } - private func isCancelledRequest(_ error: Error) -> Bool { + func isCancelledRequest(_ error: Error) -> Bool { if error is CancellationError { return true } diff --git a/Sources/PediFoods/Views/Main/HomeViewComponents.swift b/Sources/PediFoods/Views/Main/HomeViewComponents.swift index 2a519f3..b94b3c9 100644 --- a/Sources/PediFoods/Views/Main/HomeViewComponents.swift +++ b/Sources/PediFoods/Views/Main/HomeViewComponents.swift @@ -44,6 +44,8 @@ struct SearchBar: View { .foregroundStyle(AppColors.textMuted) TextField(placeholder, text: $text) .appNoAutoCap() + .foregroundStyle(AppColors.textPrimary) + .tint(AppColors.textPrimary) Spacer() Button(action: onFilterTap) { Image(systemName: "slider.horizontal.3") diff --git a/Sources/PediFoods/Views/Main/MainTabView.swift b/Sources/PediFoods/Views/Main/MainTabView.swift index 3fffbf8..d9eec5f 100644 --- a/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/Sources/PediFoods/Views/Main/MainTabView.swift @@ -16,11 +16,15 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView(appState: $appState, selectedTab: $selectedTab) + CartView(appState: $appState, selectedTab: $selectedTab, root: $root) } case .profile: NavigationStack { - ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + if appState.session.isAuthenticated { + ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + } else { + ProfileLoggedOutView(root: $root) + } } } } diff --git a/Sources/PediFoods/Views/Main/ProfileView.swift b/Sources/PediFoods/Views/Main/ProfileView.swift index 0064efb..7666ed3 100644 --- a/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/Sources/PediFoods/Views/Main/ProfileView.swift @@ -14,6 +14,8 @@ struct ProfileView: View { @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil @State var showLogoutAlert = false + @State var showDeleteAccountAlert = false + @State var isDeletingAccount = false @State private var openOrders = false let tabBarClearance: CGFloat = 120 @@ -87,6 +89,25 @@ struct ProfileView: View { .padding(.top, 10) .padding(.horizontal, 20) + Button(action: { showDeleteAccountAlert = true }) { + HStack(spacing: 10) { + if isDeletingAccount { + ProgressView() + .tint(Color.red) + } else { + Image(systemName: "trash.fill") + .font(.system(size: 16, weight: .semibold)) + } + Text("Excluir Conta") + .font(AppTypography.body) + } + .foregroundStyle(Color.red.opacity(0.7)) + } + .buttonStyle(.plain) + .disabled(isDeletingAccount) + .padding(.top, 2) + .padding(.horizontal, 20) + Text("Versão 1.0b") .font(.system(size: 16, weight: .medium)) .foregroundStyle(AppColors.textMuted) @@ -130,6 +151,14 @@ struct ProfileView: View { } message: { Text("Tem certeza que deseja sair da sua conta?") } + .alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) { + Button("Cancelar", role: .cancel) {} + Button("Excluir", role: .destructive) { + Task { await deleteAccount() } + } + } message: { + Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.") + } .onAppear { guard let message = appState.address.onboardingMessage else { return @@ -251,6 +280,78 @@ struct ProfileView: View { appState = AppState() root = .auth } + + @MainActor + private func deleteAccount() async { + guard isDeletingAccount == false else { return } + isDeletingAccount = true + defer { isDeletingAccount = false } + + do { + let response = try await ApiService().deleteAccount() + guard response.error == false else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível excluir sua conta.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.5 + ) + return + } + logout() + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível excluir sua conta. Tente novamente.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.5 + ) + } + } +} + +struct ProfileLoggedOutView: View { + @Binding var root: RootFlow + + var body: some View { + VStack(spacing: 18) { + Spacer() + + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 56, weight: .regular)) + .foregroundStyle(AppColors.textMuted) + + Text("Entre na sua conta") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Button { + root = .auth + } label: { + Text("Entrar ou Cadastrar") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 32) + .padding(.top, 8) + + Spacer() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } } struct ProfileMenuRow: View { diff --git a/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift b/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift new file mode 100644 index 0000000..085b911 --- /dev/null +++ b/Sources/PediFoods/Views/Main/PublicLocationPickerView.swift @@ -0,0 +1,174 @@ +import SwiftUI + +/// State -> city picker for anonymous browsing (public store locator). +/// Replaces AddressesView in the address-picker modal when the user is +/// not authenticated — see docs/plans/public-store-locator-sdd.md. +struct PublicLocationPickerView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + private enum Step { + case state + case city + } + + @State private var step: Step = .state + @State private var locations: PublicLocationsResult = [:] + @State private var selectedState: String? = nil + @State private var isLoading = false + @State private var errorMessage: String? = nil + + private var states: [String] { + locations.keys.sorted() + } + + private var cities: [String] { + guard let selectedState else { return [] } + return (locations[selectedState] ?? []).sorted() + } + + var body: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + VStack(spacing: 20) { + header + + if isLoading { + ProgressView() + .padding(.top, 40) + } else if let errorMessage { + VStack(spacing: 12) { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + Button("Tentar novamente") { + Task { await loadLocations() } + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + } + .padding(.horizontal, 20) + .padding(.top, 40) + } else { + list + } + + Spacer(minLength: 0) + } + .padding(.top, 18) + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .task { + await loadLocations() + } + } + + private var header: some View { + ZStack { + Text(step == .state ? "Escolha seu estado" : "Escolha sua cidade") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack { + Button(action: back) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + .padding(.horizontal, 20) + } + + private var list: some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { + switch step { + case .state: + ForEach(states, id: \.self) { state in + rowButton(title: state) { + selectedState = state + step = .city + } + } + case .city: + ForEach(cities, id: \.self) { city in + rowButton(title: city) { + confirmSelection(city: city) + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + } + } + + private func rowButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func back() { + switch step { + case .city: + step = .state + errorMessage = nil + case .state: + dismiss() + } + } + + private func confirmSelection(city: String) { + guard let selectedState else { return } + GuestLocationStore.shared.selectedState = selectedState + GuestLocationStore.shared.selectedCity = city + appState.address.display = "\(city), \(selectedState)" + appState.address.onboardingMessage = nil + dismiss() + } + + @MainActor + private func loadLocations() async { + isLoading = true + errorMessage = nil + do { + locations = try await PublicLocationService.shared.fetchLocations() + if locations.isEmpty { + errorMessage = "Nenhum estado disponível no momento." + } + } catch { + errorMessage = "Não foi possível carregar. Tente novamente." + } + isLoading = false + } +} + +#Preview { + NavigationStack { + PublicLocationPickerView(appState: .constant(AppState())) + } +} diff --git a/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index cd0f82b..a45bedb 100644 --- a/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -167,36 +167,59 @@ extension StoreDetailView { } do { - let apiService = ApiService() - let infoResponse = try await apiService.storeInfo(storeId: storeId) - let catalogResponse = try await apiService.storeCatalog(storeId: storeId) + if appState.session.isAuthenticated { + let apiService = ApiService() + let infoResponse = try await apiService.storeInfo(storeId: storeId) + let catalogResponse = try await apiService.storeCatalog(storeId: storeId) - if infoResponse.error { - isLoading = false - reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent) - return - } - if catalogResponse.error { - isLoading = false - reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent) - return - } + if infoResponse.error { + isLoading = false + reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent) + return + } + if catalogResponse.error { + isLoading = false + reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent) + return + } - let normalizedCatalog = StoreCatalogNormalizer.sanitize( - categories: catalogResponse.result ?? [], - storeId: storeId - ) + let normalizedCatalog = StoreCatalogNormalizer.sanitize( + categories: catalogResponse.result ?? [], + storeId: storeId + ) - info = infoResponse.result - categories = normalizedCatalog - selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( - from: normalizedCatalog, - preferredId: selectedCategoryId - ) - if let info = infoResponse.result { - AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + info = infoResponse.result + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) + if let info = infoResponse.result { + AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + } + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + } else { + // Anonymous browsing — public/no-login store detail + catalog + // via pedifoods.com.br, mapped onto the same StoreInfoResult / + // StoreCatalogCategory models the authenticated path uses + // above, so the rest of this view doesn't need to know which + // source the data came from. + async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId) + async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId) + let (detail, products) = try await (publicDetail, publicProducts) + + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId) + let publicInfo = StoreInfoResult(publicDetail: detail) + + info = publicInfo + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) + AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours) + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) } - AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) errorMessage = nil isLoading = false } catch {