Merge pull request '[2026-07-resubmission] Add guest browsing flow with App Attest session for App Review resubmission' (#24) from fix/app-review/2026-07-resubmission into main
Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#24
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
https://example.org/support/
|
https://pedifoods.com.br/support
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
77
Sources/PediFoods/Services/GuestLocationStore.swift
Normal file
77
Sources/PediFoods/Services/GuestLocationStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
Sources/PediFoods/Services/GuestSessionService.swift
Normal file
222
Sources/PediFoods/Services/GuestSessionService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Sources/PediFoods/Services/KeychainStore.swift
Normal file
60
Sources/PediFoods/Services/KeychainStore.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
117
Sources/PediFoods/Services/PublicLocationModels.swift
Normal file
117
Sources/PediFoods/Services/PublicLocationModels.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
110
Sources/PediFoods/Services/PublicLocationService.swift
Normal file
110
Sources/PediFoods/Services/PublicLocationService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
174
Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
Normal file
174
Sources/PediFoods/Views/Main/PublicLocationPickerView.swift
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user