Merge branch 'main' into ci/gitea-actions/macos-runner

This commit is contained in:
2026-07-31 09:39:30 -03:00
21 changed files with 1140 additions and 136 deletions

View File

@@ -2,5 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>development</string>
</dict> </dict>
</plist> </plist>

View File

@@ -1 +1 @@
https://example.org/support/ https://pedifoods.com.br/support

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main @State var root: RootFlow = .main
@State var selectedTab: MainTab = .home @State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore() private let tokenStore: TokenStore = DefaultTokenStore()
@State var appState = AppState() @State var appState = AppState()
@@ -167,20 +167,35 @@ struct ContentView: View {
appState.cart = cachedCart 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. // Always refresh profile when authenticated.
// This keeps profile/address/cart scope consistent after relogin // This keeps profile/address/cart scope consistent after relogin
// and avoids stale local state during checkout payload generation. // and avoids stale local state during checkout payload generation.
do { // Anonymous browsing has no session to refresh, so skip the call
let response = try await ApiService().profile() // entirely rather than let it 401 and force-logout a guest.
if response.error == false, let customer = response.result { if appState.session.isAuthenticated {
hydrateAppState(with: customer) 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) await refreshFeatureFlags(forceRefresh: false)
@@ -418,13 +433,17 @@ struct AddressPickerModalView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
AddressesView( if appState.session.isAuthenticated {
message: appState.address.onboardingMessage, AddressesView(
appState: $appState, message: appState.address.onboardingMessage,
selectionMode: true appState: $appState,
) selectionMode: true
.onAppear { )
appState.address.onboardingMessage = nil .onAppear {
appState.address.onboardingMessage = nil
}
} else {
PublicLocationPickerView(appState: $appState)
} }
} }
} }

View File

@@ -456,6 +456,12 @@
"Entrar" : { "Entrar" : {
"comment" : "A link that directs the user to the login screen.", "comment" : "A link that directs the user to the login screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Entrar ou Cadastrar" : {
},
"Entre na sua conta" : {
}, },
"Entrega" : { "Entrega" : {
"comment" : "A text describing delivery mode.", "comment" : "A text describing delivery mode.",
@@ -482,9 +488,18 @@
"Escolha o tamanho da sua fome" : { "Escolha o tamanho da sua fome" : {
"comment" : "A label displayed below the pizza size selection.", "comment" : "A label displayed below the pizza size selection.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Escolha seu estado" : {
}, },
"Escolha seu sabor" : { "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." : { "Este sabor não possui adicionais." : {
"comment" : "A message displayed when a pizza flavor does not have any add-ons.", "comment" : "A message displayed when a pizza flavor does not have any add-ons.",
@@ -493,6 +508,15 @@
"Excluir" : { "Excluir" : {
"comment" : "A button label that translates to \"Delete\".", "comment" : "A button label that translates to \"Delete\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Excluir Conta" : {
},
"Excluir sua conta?" : {
},
"Faça login ou cadastre-se para ver seu perfil, pedidos e endereços." : {
}, },
"Favorite" : { "Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite", "comment" : "Item editor title label for marking the item as a favorite",

View File

@@ -57,19 +57,30 @@ struct ApiRequest: Sendable {
let requiresAuth: Bool let requiresAuth: Bool
let queryItems: [URLQueryItem] let queryItems: [URLQueryItem]
let body: Data? 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 <token>` 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, init(path: String,
method: String = "GET", method: String = "GET",
module: ApiModule = .none, module: ApiModule = .none,
requiresAuth: Bool = true, requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [], queryItems: [URLQueryItem] = [],
body: Data? = nil) { body: Data? = nil,
baseURLOverride: URL? = nil,
customBearerToken: String? = nil) {
self.path = path self.path = path
self.method = method self.method = method
self.module = module self.module = module
self.requiresAuth = requiresAuth self.requiresAuth = requiresAuth
self.queryItems = queryItems self.queryItems = queryItems
self.body = body self.body = body
self.baseURLOverride = baseURLOverride
self.customBearerToken = customBearerToken
} }
} }
@@ -120,7 +131,7 @@ final class ApiClient: @unchecked Sendable {
private extension ApiClient { private extension ApiClient {
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T { func sendWithLCEssentials<T: Decodable>(_ 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 method = request.method
let headers = buildHeaders(for: request) let headers = buildHeaders(for: request)
let params = request.body let params = request.body
@@ -228,7 +239,7 @@ private extension ApiClient {
#endif #endif
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T { func sendWithURLSession<T: Decodable>(_ 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) var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body urlRequest.httpBody = request.body
@@ -314,7 +325,9 @@ private extension ApiClient {
if let token = ApiConfig.token(for: request.module) { if let token = ApiConfig.token(for: request.module) {
headers["Atomenta-Token"] = token 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)" headers["Authorization"] = "Bearer \(jwt)"
} }
return headers return headers
@@ -474,8 +487,8 @@ private extension ApiClient {
return trimmed return trimmed
} }
func buildURL(path: String, query: [URLQueryItem]) throws -> URL { func buildURL(path: String, query: [URLQueryItem], baseURL: URL) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL throw NetworkError.invalidURL
} }
components.path = components.path.appending(path) components.path = components.path.appending(path)

View File

@@ -9,6 +9,11 @@ enum ApiModule: Sendable {
} }
enum ApiConfig { 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 { static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br" let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "https://atomenta.com.br"
return URL(string: raw) ?? URL(string: "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")! 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 { static var featureControlEnvironment: String {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -35,7 +35,7 @@ final class ApiService {
do { do {
return try await client.send(req) return try await client.send(req)
} catch let error as NetworkError { } catch let error as NetworkError {
if case .unauthorized(let message) = error { if canTriggerSessionExpiry(for: req), case .unauthorized(let message) = error {
expireSession(message) expireSession(message)
throw ApiServiceError.sessionExpired(message) throw ApiServiceError.sessionExpired(message)
} }
@@ -45,13 +45,22 @@ final class ApiService {
private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> { private func sendEnvelope<T: Decodable>(_ req: ApiRequest) async throws -> ApiEnvelope<T> {
let envelope: ApiEnvelope<T> = try await send(req) let envelope: ApiEnvelope<T> = try await send(req)
if isSessionExpiredEnvelope(envelope) { if canTriggerSessionExpiry(for: req), isSessionExpiredEnvelope(envelope) {
expireSession(envelope.message) expireSession(envelope.message)
throw ApiServiceError.sessionExpired(envelope.message) throw ApiServiceError.sessionExpired(envelope.message)
} }
return envelope 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<T>(_ envelope: ApiEnvelope<T>) -> Bool { private func isSessionExpiredEnvelope<T>(_ envelope: ApiEnvelope<T>) -> Bool {
guard envelope.error else { return false } guard envelope.error else { return false }
let code = (envelope.code ?? "").lowercased() let code = (envelope.code ?? "").lowercased()
@@ -96,6 +105,16 @@ final class ApiService {
if let birthDate, birthDate.isEmpty == false { if let birthDate, birthDate.isEmpty == false {
payload["birthDate"] = birthDate 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 body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body) let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await sendEnvelope(req) return try await sendEnvelope(req)
@@ -308,7 +327,10 @@ final class ApiService {
return cached 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) let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false { if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
@@ -447,6 +469,11 @@ final class ApiService {
return try await sendEnvelope(req) return try await sendEnvelope(req)
} }
func deleteAccount() async throws -> ApiEnvelope<EmptyResult> {
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<ChangePaymentMethodResult> { func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload) 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) let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)

View File

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

View File

@@ -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<String, Error>?
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<ChallengeResult> = 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<GuestSessionResult> = 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
}
}

View File

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

View File

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

View File

@@ -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<PublicStoreDetail> = 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<T: Decodable & Sendable>(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<T: Decodable & Sendable>(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<T> = 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
}
}
}

View File

@@ -1,7 +1,4 @@
import Foundation import Foundation
#if os(iOS)
import Security
#endif
protocol TokenStore: AnyObject { protocol TokenStore: AnyObject {
var jwt: String? { get set } var jwt: String? { get set }
@@ -10,12 +7,13 @@ protocol TokenStore: AnyObject {
final class DefaultTokenStore: TokenStore { final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt" private let key = "auth_jwt"
private let serviceName = "com.br.pedifoods.app.auth"
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
var jwt: String? { var jwt: String? {
get { get {
#if os(iOS) #if os(iOS)
if let keychainValue = loadKeychainValue(for: key) { if let keychainValue = KeychainStore.load(service: serviceName, key: key) {
return keychainValue return keychainValue
} }
#endif #endif
@@ -24,9 +22,9 @@ final class DefaultTokenStore: TokenStore {
set { set {
#if os(iOS) #if os(iOS)
if let newValue { if let newValue {
saveKeychainValue(newValue, for: key) KeychainStore.save(newValue, service: serviceName, key: key)
} else { } else {
deleteKeychainValue(for: key) KeychainStore.delete(service: serviceName, key: key)
} }
#endif #endif
defaults.set(newValue, forKey: key) defaults.set(newValue, forKey: key)
@@ -35,57 +33,8 @@ final class DefaultTokenStore: TokenStore {
func clear() { func clear() {
#if os(iOS) #if os(iOS)
deleteKeychainValue(for: key) KeychainStore.delete(service: serviceName, key: key)
#endif #endif
defaults.removeObject(forKey: key) 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
} }

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct CartView: View { struct CartView: View {
@Binding var appState: AppState @Binding var appState: AppState
@Binding var selectedTab: MainTab @Binding var selectedTab: MainTab
@Binding var root: RootFlow
@State var openCheckout = false @State var openCheckout = false
@State var couponCode = "" @State var couponCode = ""
@State var appliedCouponCode: String? = nil @State var appliedCouponCode: String? = nil
@@ -148,6 +149,10 @@ struct CartView: View {
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
Button { Button {
guard appState.session.isAuthenticated else {
root = .auth
return
}
openCheckout = true openCheckout = true
} label: { } label: {
HStack(spacing: 10) { HStack(spacing: 10) {

View File

@@ -135,4 +135,62 @@ extension HomeView {
} }
return "Não foi possível carregar os estabelecimentos." 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
}
} }

View File

@@ -123,17 +123,7 @@ struct HomeView: View {
HStack(spacing: 16) { HStack(spacing: 16) {
ForEach(featuredStoresCards) { store in ForEach(featuredStoresCards) { store in
NavigationLink { NavigationLink {
StoreDetailView( storeDestination(for: store)
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
)
} label: { } label: {
FeaturedStoreCard( FeaturedStoreCard(
store: store, 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 @ViewBuilder
private var storeCardsList: some View { private var storeCardsList: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
ForEach(filteredStoreCards) { store in ForEach(filteredStoreCards) { store in
NavigationLink { NavigationLink {
StoreDetailView( storeDestination(for: store)
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
)
} label: { } label: {
FeaturedStoreCard( FeaturedStoreCard(
store: store, store: store,
@@ -407,6 +402,14 @@ struct HomeView: View {
storesError = nil 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 coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
let hasAddress = hasConfiguredAddress() 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 { if hadExistingStores {
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0) SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
} else { } else {
@@ -548,7 +551,7 @@ struct HomeView: View {
return selected.title return selected.title
} }
private func isCancelledRequest(_ error: Error) -> Bool { func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError { if error is CancellationError {
return true return true
} }

View File

@@ -44,6 +44,8 @@ struct SearchBar: View {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text) TextField(placeholder, text: $text)
.appNoAutoCap() .appNoAutoCap()
.foregroundStyle(AppColors.textPrimary)
.tint(AppColors.textPrimary)
Spacer() Spacer()
Button(action: onFilterTap) { Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3") Image(systemName: "slider.horizontal.3")

View File

@@ -16,11 +16,15 @@ struct MainTabView: View {
} }
case .cart: case .cart:
NavigationStack { NavigationStack {
CartView(appState: $appState, selectedTab: $selectedTab) CartView(appState: $appState, selectedTab: $selectedTab, root: $root)
} }
case .profile: case .profile:
NavigationStack { 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)
}
} }
} }
} }

View File

@@ -14,6 +14,8 @@ struct ProfileView: View {
@State var openAddressesOnboarding = false @State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil @State var onboardingMessage: String? = nil
@State var showLogoutAlert = false @State var showLogoutAlert = false
@State var showDeleteAccountAlert = false
@State var isDeletingAccount = false
@State private var openOrders = false @State private var openOrders = false
let tabBarClearance: CGFloat = 120 let tabBarClearance: CGFloat = 120
@@ -87,6 +89,25 @@ struct ProfileView: View {
.padding(.top, 10) .padding(.top, 10)
.padding(.horizontal, 20) .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") Text("Versão 1.0b")
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
@@ -130,6 +151,14 @@ struct ProfileView: View {
} message: { } message: {
Text("Tem certeza que deseja sair da sua conta?") 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 { .onAppear {
guard let message = appState.address.onboardingMessage else { guard let message = appState.address.onboardingMessage else {
return return
@@ -251,6 +280,78 @@ struct ProfileView: View {
appState = AppState() appState = AppState()
root = .auth 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 { struct ProfileMenuRow: View {

View File

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

View File

@@ -167,36 +167,59 @@ extension StoreDetailView {
} }
do { do {
let apiService = ApiService() if appState.session.isAuthenticated {
let infoResponse = try await apiService.storeInfo(storeId: storeId) let apiService = ApiService()
let catalogResponse = try await apiService.storeCatalog(storeId: storeId) let infoResponse = try await apiService.storeInfo(storeId: storeId)
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
if infoResponse.error { if infoResponse.error {
isLoading = false isLoading = false
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent) reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
return return
} }
if catalogResponse.error { if catalogResponse.error {
isLoading = false isLoading = false
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent) reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
return return
} }
let normalizedCatalog = StoreCatalogNormalizer.sanitize( let normalizedCatalog = StoreCatalogNormalizer.sanitize(
categories: catalogResponse.result ?? [], categories: catalogResponse.result ?? [],
storeId: storeId storeId: storeId
) )
info = infoResponse.result info = infoResponse.result
categories = normalizedCatalog categories = normalizedCatalog
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog, from: normalizedCatalog,
preferredId: selectedCategoryId preferredId: selectedCategoryId
) )
if let info = infoResponse.result { if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) 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 errorMessage = nil
isLoading = false isLoading = false
} catch { } catch {