[2026-07-resubmission] Add guest browsing flow with App Attest session for App Review resubmission
Adds a pre-login public store locator (guest session via DeviceCheck/App Attest, keychain-backed token storage) so the app no longer forces sign-in before showing any content, plus updated support URL metadata.
This commit is contained in:
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
struct CartView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
@Binding var root: RootFlow
|
||||
@State var openCheckout = false
|
||||
@State var couponCode = ""
|
||||
@State var appliedCouponCode: String? = nil
|
||||
@@ -148,6 +149,10 @@ struct CartView: View {
|
||||
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
|
||||
|
||||
Button {
|
||||
guard appState.session.isAuthenticated else {
|
||||
root = .auth
|
||||
return
|
||||
}
|
||||
openCheckout = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
|
||||
@@ -135,4 +135,62 @@ extension HomeView {
|
||||
}
|
||||
return "Não foi possível carregar os estabelecimentos."
|
||||
}
|
||||
|
||||
/// Anonymous store loading: no account, no coordinates — just the
|
||||
/// manually-picked state/city from the public locator. The BFF endpoint
|
||||
/// has no category filter, so any category chip selection is applied
|
||||
/// client-side via `filteredStores` (HomeView+Filtering.swift), same as
|
||||
/// the multi-select filters already do.
|
||||
@MainActor
|
||||
func loadGuestStores(hadExistingStores: Bool, category: String?, refreshCategories: Bool) async {
|
||||
guard let state = GuestLocationStore.shared.selectedState,
|
||||
let city = GuestLocationStore.shared.selectedCity else {
|
||||
isLoadingStores = false
|
||||
stores = []
|
||||
storesError = "Escolha um estado e cidade para visualizar os estabelecimentos."
|
||||
appState.activeModal = .addressPicker
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let items = try await PublicLocationService.shared.fetchStores(state: state, city: city)
|
||||
isLoadingStores = false
|
||||
let mapped = items.map(StoreSummary.init(publicItem:))
|
||||
stores = mapped
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(withFallbackStores: mapped, forceRefresh: refreshCategories)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
}
|
||||
storesError = nil
|
||||
} catch {
|
||||
if isCancelledRequest(error) {
|
||||
isLoadingStores = false
|
||||
return
|
||||
}
|
||||
isLoadingStores = false
|
||||
reportStoresLoadFailure(storesUserMessage(error), hadExistingStores: hadExistingStores)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreSummary {
|
||||
/// Maps the public-locator DTO onto the same model HomeView already
|
||||
/// renders — distance/positiveReviews don't exist in that response.
|
||||
init(publicItem: PublicStoreListItem) {
|
||||
self.id = publicItem.id
|
||||
self.name = publicItem.name ?? "Loja"
|
||||
self.logo = publicItem.logo
|
||||
self.cover = publicItem.cover
|
||||
self.category = publicItem.category
|
||||
self.rating = publicItem.rating
|
||||
self.reviewsCount = publicItem.totalReviews
|
||||
self.positiveReviews = nil
|
||||
self.deliveryTime = publicItem.deliveryTime
|
||||
self.deliveryFee = publicItem.deliveryFee
|
||||
self.distance = nil
|
||||
self.isOpen = publicItem.isOpen
|
||||
self.statusLabel = publicItem.statusLabel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,17 +123,7 @@ struct HomeView: View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(featuredStoresCards) { store in
|
||||
NavigationLink {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
storeCoverURL: store.coverURL,
|
||||
storeLogoURL: store.logoURL,
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
storeDestination(for: store)
|
||||
} label: {
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
@@ -210,22 +200,27 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func storeDestination(for store: FeaturedStoreCardModel) -> some View {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
storeCoverURL: store.coverURL,
|
||||
storeLogoURL: store.logoURL,
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var storeCardsList: some View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(filteredStoreCards) { store in
|
||||
NavigationLink {
|
||||
StoreDetailView(
|
||||
storeId: store.id,
|
||||
storeName: store.name,
|
||||
storeCoverURL: store.coverURL,
|
||||
storeLogoURL: store.logoURL,
|
||||
storeCategory: store.category,
|
||||
storeRating: store.rating,
|
||||
storeDistance: store.distance,
|
||||
storeDeliveryFee: store.deliveryFee,
|
||||
appState: $appState
|
||||
)
|
||||
storeDestination(for: store)
|
||||
} label: {
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
@@ -407,6 +402,14 @@ struct HomeView: View {
|
||||
storesError = nil
|
||||
}
|
||||
|
||||
// Anonymous browsing has no account address/coordinates — the public
|
||||
// locator uses a manually-picked state/city instead (geolocation is
|
||||
// out of scope for that flow, see public-store-locator-sdd.md).
|
||||
guard appState.session.isAuthenticated else {
|
||||
await loadGuestStores(hadExistingStores: hadExistingStores, category: category, refreshCategories: refreshCategories)
|
||||
return
|
||||
}
|
||||
|
||||
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
|
||||
let hasAddress = hasConfiguredAddress()
|
||||
|
||||
@@ -498,7 +501,7 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
|
||||
func reportStoresLoadFailure(_ message: String, hadExistingStores: Bool) {
|
||||
if hadExistingStores {
|
||||
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 3.0)
|
||||
} else {
|
||||
@@ -548,7 +551,7 @@ struct HomeView: View {
|
||||
return selected.title
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ struct SearchBar: View {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
TextField(placeholder, text: $text)
|
||||
.appNoAutoCap()
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.tint(AppColors.textPrimary)
|
||||
Spacer()
|
||||
Button(action: onFilterTap) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
|
||||
@@ -16,11 +16,15 @@ struct MainTabView: View {
|
||||
}
|
||||
case .cart:
|
||||
NavigationStack {
|
||||
CartView(appState: $appState, selectedTab: $selectedTab)
|
||||
CartView(appState: $appState, selectedTab: $selectedTab, root: $root)
|
||||
}
|
||||
case .profile:
|
||||
NavigationStack {
|
||||
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
if appState.session.isAuthenticated {
|
||||
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||
} else {
|
||||
ProfileLoggedOutView(root: $root)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ struct ProfileView: View {
|
||||
@State var openAddressesOnboarding = false
|
||||
@State var onboardingMessage: String? = nil
|
||||
@State var showLogoutAlert = false
|
||||
@State var showDeleteAccountAlert = false
|
||||
@State var isDeletingAccount = false
|
||||
@State private var openOrders = false
|
||||
let tabBarClearance: CGFloat = 120
|
||||
|
||||
@@ -87,6 +89,25 @@ struct ProfileView: View {
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Button(action: { showDeleteAccountAlert = true }) {
|
||||
HStack(spacing: 10) {
|
||||
if isDeletingAccount {
|
||||
ProgressView()
|
||||
.tint(Color.red)
|
||||
} else {
|
||||
Image(systemName: "trash.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text("Excluir Conta")
|
||||
.font(AppTypography.body)
|
||||
}
|
||||
.foregroundStyle(Color.red.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDeletingAccount)
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Text("Versão 1.0b")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
@@ -130,6 +151,14 @@ struct ProfileView: View {
|
||||
} message: {
|
||||
Text("Tem certeza que deseja sair da sua conta?")
|
||||
}
|
||||
.alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) {
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
Button("Excluir", role: .destructive) {
|
||||
Task { await deleteAccount() }
|
||||
}
|
||||
} message: {
|
||||
Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.")
|
||||
}
|
||||
.onAppear {
|
||||
guard let message = appState.address.onboardingMessage else {
|
||||
return
|
||||
@@ -251,6 +280,78 @@ struct ProfileView: View {
|
||||
appState = AppState()
|
||||
root = .auth
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deleteAccount() async {
|
||||
guard isDeletingAccount == false else { return }
|
||||
isDeletingAccount = true
|
||||
defer { isDeletingAccount = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().deleteAccount()
|
||||
guard response.error == false else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível excluir sua conta.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.5
|
||||
)
|
||||
return
|
||||
}
|
||||
logout()
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível excluir sua conta. Tente novamente.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.5
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileLoggedOutView: View {
|
||||
@Binding var root: RootFlow
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||
.font(.system(size: 56, weight: .regular))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
Text("Entre na sua conta")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Button {
|
||||
root = .auth
|
||||
} label: {
|
||||
Text("Entrar ou Cadastrar")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileMenuRow: View {
|
||||
|
||||
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 {
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
if appState.session.isAuthenticated {
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
|
||||
if infoResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
if catalogResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
if infoResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(infoResponse.message ?? "Não foi possível carregar a loja.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
if catalogResponse.error {
|
||||
isLoading = false
|
||||
reportStoreLoadFailure(catalogResponse.message ?? "Não foi possível carregar o catálogo.", hadExistingContent: hadExistingContent)
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||
categories: catalogResponse.result ?? [],
|
||||
storeId: storeId
|
||||
)
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||
categories: catalogResponse.result ?? [],
|
||||
storeId: storeId
|
||||
)
|
||||
|
||||
info = infoResponse.result
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
if let info = infoResponse.result {
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
info = infoResponse.result
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
if let info = infoResponse.result {
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
} else {
|
||||
// Anonymous browsing — public/no-login store detail + catalog
|
||||
// via pedifoods.com.br, mapped onto the same StoreInfoResult /
|
||||
// StoreCatalogCategory models the authenticated path uses
|
||||
// above, so the rest of this view doesn't need to know which
|
||||
// source the data came from.
|
||||
async let publicDetail = PublicLocationService.shared.fetchStoreDetail(identifier: storeId)
|
||||
async let publicProducts = PublicLocationService.shared.fetchStoreProducts(storeId: storeId)
|
||||
let (detail, products) = try await (publicDetail, publicProducts)
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: products, storeId: storeId)
|
||||
let publicInfo = StoreInfoResult(publicDetail: detail)
|
||||
|
||||
info = publicInfo
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
AppContentCache.shared.set(publicInfo, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user