Fix
This commit is contained in:
@@ -139,10 +139,7 @@ final class CachedRemoteImageLoader: ObservableObject {
|
||||
}
|
||||
|
||||
private static func normalizeImageSource(_ value: String?) -> String? {
|
||||
guard var normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
normalized.isEmpty == false else { return nil }
|
||||
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
|
||||
return normalized
|
||||
ImageSourceResolver.resolve(value)
|
||||
}
|
||||
|
||||
private static func dataURLCacheKey(_ source: String) -> String {
|
||||
|
||||
@@ -127,6 +127,7 @@ struct ContentView: View {
|
||||
appState.profile.name = customer.name
|
||||
appState.profile.email = customer.email
|
||||
appState.profile.phone = customer.phoneNumber ?? ""
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
@@ -143,13 +144,49 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let preferredAddress = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first
|
||||
let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address)
|
||||
if let preferredAddress {
|
||||
applyAddress(preferredAddress)
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? {
|
||||
guard addresses.isEmpty == false else { return nil }
|
||||
|
||||
if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
selectedId.isEmpty == false,
|
||||
let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
|
||||
return byId
|
||||
}
|
||||
|
||||
let normalizedDisplay = current.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
|
||||
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
|
||||
let byLabel = addresses.first(where: {
|
||||
(($0.label ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()) == normalizedDisplay
|
||||
}) {
|
||||
return byLabel
|
||||
}
|
||||
|
||||
if let lat = current.latitude, let lng = current.longitude,
|
||||
let byCoordinate = addresses.first(where: { address in
|
||||
guard let addrLat = address.latLong?.first,
|
||||
let addrLng = address.latLong?.dropFirst().first else { return false }
|
||||
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
|
||||
}) {
|
||||
return byCoordinate
|
||||
}
|
||||
|
||||
return addresses.first
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAddress(_ address: CustomerAddress) {
|
||||
appState.address.selectedId = address.id
|
||||
|
||||
@@ -36,6 +36,7 @@ enum AppTypography {
|
||||
// Avoid applying dynamic weight on custom font descriptors to prevent
|
||||
// SwiftUI runtime warnings on some platforms/toolchains.
|
||||
static let heading1 = Font.custom(fontFamily, size: 28)
|
||||
static let heading25 = Font.custom(fontFamily, size: 25)
|
||||
static let heading2 = Font.custom(fontFamily, size: 20)
|
||||
static let heading3 = Font.custom(fontFamily, size: 16)
|
||||
static let body = Font.custom(fontFamily, size: 16)
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
" " : {
|
||||
"comment" : "A placeholder text used to maintain vertical spacing in the UI.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"..." : {
|
||||
|
||||
@@ -51,6 +55,18 @@
|
||||
},
|
||||
"%lldx" : {
|
||||
|
||||
},
|
||||
"%lldx %@" : {
|
||||
"comment" : "A line that shows the quantity of an ordered item and its name. The first argument is the quantity of the item. The second argument is the name of the item.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lldx %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+ %@" : {
|
||||
"comment" : "A small, secondary text label displaying the price of an item.",
|
||||
@@ -115,6 +131,10 @@
|
||||
"comment" : "A heading for the additional options available for a product.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Adicionar fotos" : {
|
||||
"comment" : "A label for adding photos in the order review section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Adicionar novo endereço" : {
|
||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -184,6 +204,28 @@
|
||||
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avaliação" : {
|
||||
"comment" : "A label describing a review section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avaliar pedido" : {
|
||||
"comment" : "A button that allows users to rate and review their orders.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avaliar Pedido" : {
|
||||
|
||||
},
|
||||
"AVALIAR PEDIDO" : {
|
||||
|
||||
},
|
||||
"Avaliar pedido #%@" : {
|
||||
"comment" : "A button that lets the user review a previously placed order. The placeholder `#\\(initialOrder.shortId ?? initialOrder.orderId)` will be replaced with the actual order number when displayed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avalie o Pedi Foods também" : {
|
||||
"comment" : "A heading that asks users to review Pedi Foods.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Boas-vindas!" : {
|
||||
"comment" : "A welcome message displayed in the login view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -257,6 +299,14 @@
|
||||
"comment" : "A button that allows the user to paste their OTP code directly into the field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Comentário" : {
|
||||
"comment" : "A label for the comment section in the order review view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Comprar novamente" : {
|
||||
"comment" : "A button that allows a user to purchase an order again.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Concluir" : {
|
||||
"comment" : "The text for a button that confirms and closes a sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -273,6 +323,10 @@
|
||||
"comment" : "The text on a button that takes the user to contact support.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Conte-nos se gostou ou não." : {
|
||||
"comment" : "A prompt asking users to share their opinion about the delivery.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Conteúdo da política de privacidade..." : {
|
||||
"comment" : "A placeholder text describing the content of the privacy policy.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -365,16 +419,20 @@
|
||||
"comment" : "A label describing the delivery location section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Descubra lorem ipsum ba bla" : {
|
||||
"comment" : "A welcome message displayed on the login screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Detalhes" : {
|
||||
"comment" : "The title of the navigation bar at the top of the product detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Do que você gostou?" : {
|
||||
"comment" : "A prompt asking users to mention what they liked about a review.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Editar endereço" : {
|
||||
|
||||
},
|
||||
"Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : {
|
||||
"comment" : "A description under the NPS scale question in the order review view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"ENDEREÇO DE ENTREGA" : {
|
||||
"comment" : "A label displayed above the address section when delivery is selected.",
|
||||
@@ -387,6 +445,9 @@
|
||||
"Entrega" : {
|
||||
"comment" : "A text describing delivery mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enviar Avaliação" : {
|
||||
|
||||
},
|
||||
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
|
||||
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
|
||||
@@ -396,6 +457,10 @@
|
||||
"comment" : "A description under the QR code that instructs the user on how to pay using their bank app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Escolha de 1 a 5 estrelas para classificar." : {
|
||||
"comment" : "A description under the star rating section of the order review view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Escolha o tamanho da sua fome" : {
|
||||
"comment" : "A label displayed below the pizza size selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -581,6 +646,10 @@
|
||||
"comment" : "The title of the user's cart screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meu Perfil" : {
|
||||
"comment" : "The title of the user profile view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meus Endereços" : {
|
||||
"comment" : "A label displayed at the top of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -650,6 +719,10 @@
|
||||
},
|
||||
"Nenhum pedido encontrado." : {
|
||||
|
||||
},
|
||||
"Nota do pedido" : {
|
||||
"comment" : "A title for the rating of a review.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Notes" : {
|
||||
"comment" : "Item editor form label for the Notes field",
|
||||
@@ -689,12 +762,32 @@
|
||||
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"O que pode melhorar?" : {
|
||||
"comment" : "A label for the section where users can select tags they think could improve an order.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"O que podemos melhorar? Conte-nos sua experiência..." : {
|
||||
"comment" : "A placeholder text displayed in the text editor of the review draft view, when the text editor is empty.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"O que vai querer \npedir hoje?" : {
|
||||
"comment" : "A title displayed above the search bar in the home view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"O que vai querer pedir hoje?\n " : {
|
||||
|
||||
},
|
||||
"O que você achou do pedido?" : {
|
||||
"comment" : "A question prompt asking users to rate their experience with the order.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"O que você achou do pedido? Conte-nos sua experiência..." : {
|
||||
"comment" : "A placeholder text displayed in the text editor of the order review view, instructing the user to leave a comment about their experience with the order.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Obrigatório" : {
|
||||
"comment" : "A label indicating a required field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pagamento" : {
|
||||
"comment" : "The title of the screen where a user can enter and save payment information.",
|
||||
@@ -710,6 +803,16 @@
|
||||
},
|
||||
"Pedido #%@" : {
|
||||
|
||||
},
|
||||
"Pedido #%@ • %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Pedido #%1$@ • %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pedido %@" : {
|
||||
|
||||
@@ -778,6 +881,10 @@
|
||||
},
|
||||
"Progresso do Pedido" : {
|
||||
|
||||
},
|
||||
"Reavaliar" : {
|
||||
"comment" : "The text for a button that allows a user to re-evaluate an order.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Receber Código" : {
|
||||
"comment" : "A button label that says \"Receive Code\".",
|
||||
@@ -787,6 +894,14 @@
|
||||
"comment" : "A button that allows a user to request a new OTP code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remover" : {
|
||||
"comment" : "A button that deletes the user's profile picture.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Resposta da loja" : {
|
||||
"comment" : "A heading for the reply from the store in an order review.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"RESTAURANT" : {
|
||||
|
||||
},
|
||||
@@ -811,6 +926,14 @@
|
||||
},
|
||||
"Sair da conta?" : {
|
||||
|
||||
},
|
||||
"Salvando..." : {
|
||||
"comment" : "The title of a button while it is \"saving\" (e.g. waiting for a network request to complete).",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Salvar Alterações" : {
|
||||
"comment" : "The text on a button that saves changes to a user's profile.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Save" : {
|
||||
"comment" : "Button title indicating that the current contents should be saved",
|
||||
@@ -894,6 +1017,10 @@
|
||||
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
|
||||
"comment" : "A tagline displayed in the login view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"System" : {
|
||||
"comment" : "Menu item indicating that the appearance should be in the default system mode",
|
||||
"extractionState" : "stale",
|
||||
@@ -989,6 +1116,14 @@
|
||||
"comment" : "A title for an alert that prompts the user to switch stores.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Trocar Foto" : {
|
||||
"comment" : "A button label that allows a user to change their profile picture.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"UPLOAD" : {
|
||||
"comment" : "A button label that says \"UPLOAD\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ver motivo do cancelamento" : {
|
||||
|
||||
},
|
||||
@@ -1039,6 +1174,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Você ainda não avaliou nenhum pedido." : {
|
||||
"comment" : "A message displayed when a user has not reviewed any orders yet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Você gostou da entrega?" : {
|
||||
|
||||
},
|
||||
"Welcome" : {
|
||||
"comment" : "Tab bar item title for the Welcome tab",
|
||||
|
||||
@@ -84,12 +84,8 @@ final class ApiClient {
|
||||
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
// NOTE:
|
||||
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
|
||||
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
|
||||
// Also route order-related polling/listing through URLSession to avoid
|
||||
// intermittent cancellation observed with the shared API bridge.
|
||||
if request.path == "/api/customer/login" ||
|
||||
request.path == "/api/app/orders" ||
|
||||
request.path.hasPrefix("/api/public/orders/") {
|
||||
// On iOS, route this endpoint through URLSession to preserve raw JSON body behavior.
|
||||
if request.path == "/api/customer/login" {
|
||||
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
|
||||
print("[ApiClient] /api/customer/login body: \(bodyText)")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,37 @@ struct CustomerProfileUpdatePayload: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomerIdentityUpdatePayload: Encodable {
|
||||
let name: String
|
||||
let email: String
|
||||
let phoneNumber: String
|
||||
let profilePicture: String?
|
||||
let addressBook: [CustomerAddressPayload]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case email
|
||||
case phoneNumber
|
||||
case phone
|
||||
case profilePicture
|
||||
case profile_picture
|
||||
case addressBook = "address_book"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(email, forKey: .email)
|
||||
try container.encode(phoneNumber, forKey: .phoneNumber)
|
||||
try container.encode(phoneNumber, forKey: .phone)
|
||||
try container.encode(addressBook, forKey: .addressBook)
|
||||
if let profilePicture, profilePicture.isEmpty == false {
|
||||
try container.encode(profilePicture, forKey: .profilePicture)
|
||||
try container.encode(profilePicture, forKey: .profile_picture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomerAddressPayload: Encodable {
|
||||
let label: String?
|
||||
let address: String?
|
||||
|
||||
@@ -114,6 +114,9 @@ struct StoreSummary: Decodable {
|
||||
struct StoreInfoResult: Decodable {
|
||||
let isOpen: Bool?
|
||||
let statusLabel: String?
|
||||
let fantasyName: String?
|
||||
let logo: String?
|
||||
let cover: String?
|
||||
let deliveryTime: String?
|
||||
let minOrder: Double?
|
||||
let address: StoreAddressInfo?
|
||||
@@ -122,6 +125,9 @@ struct StoreInfoResult: Decodable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case isOpen
|
||||
case statusLabel
|
||||
case fantasyName
|
||||
case logo
|
||||
case cover
|
||||
case deliveryTime
|
||||
case minOrder
|
||||
case address
|
||||
@@ -132,6 +138,9 @@ struct StoreInfoResult: Decodable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
||||
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
||||
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
|
||||
logo = try? container.decode(String.self, forKey: .logo)
|
||||
cover = try? container.decode(String.self, forKey: .cover)
|
||||
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
||||
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
|
||||
address = try? container.decode(StoreAddressInfo.self, forKey: .address)
|
||||
|
||||
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal file
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import Foundation
|
||||
|
||||
enum ReviewPlatform: String, Encodable {
|
||||
case ios
|
||||
case android
|
||||
case web
|
||||
|
||||
static var current: ReviewPlatform {
|
||||
#if os(iOS)
|
||||
return .ios
|
||||
#elseif os(Android)
|
||||
return .android
|
||||
#else
|
||||
return .web
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct SubmitOrderReviewPayload: Encodable {
|
||||
let rate: Int
|
||||
let message: String
|
||||
let orderRate: Int
|
||||
let orderComment: String
|
||||
let orderPositiveTags: [String]
|
||||
let orderImprovementTags: [String]
|
||||
let deliverySentiment: String
|
||||
let deliveryPositiveTags: [String]
|
||||
let deliveryNegativeTags: [String]
|
||||
let appNps: Int
|
||||
let platform: String
|
||||
}
|
||||
|
||||
struct ReviewTagItem: Decodable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
struct ReviewOrderTagRules: Decodable {
|
||||
let positiveAllowedWhenRateGte: Int?
|
||||
let improvementAllowedWhenRateLte: Int?
|
||||
}
|
||||
|
||||
struct ReviewOrderTagsCatalog: Decodable {
|
||||
let positive: [ReviewTagItem]
|
||||
let improvement: [ReviewTagItem]
|
||||
let rules: ReviewOrderTagRules?
|
||||
}
|
||||
|
||||
struct ReviewDeliverySentimentRule: Decodable {
|
||||
let id: String
|
||||
let allowedTags: [String]
|
||||
}
|
||||
|
||||
struct ReviewDeliveryTagsCatalog: Decodable {
|
||||
let sentiments: [ReviewDeliverySentimentRule]
|
||||
let positive: [ReviewTagItem]
|
||||
let negative: [ReviewTagItem]
|
||||
}
|
||||
|
||||
struct ReviewNpsCatalog: Decodable {
|
||||
let min: Int?
|
||||
let max: Int?
|
||||
}
|
||||
|
||||
struct ReviewAppTagsCatalog: Decodable {
|
||||
let nps: ReviewNpsCatalog?
|
||||
let platforms: [String]?
|
||||
}
|
||||
|
||||
struct ReviewTagsCatalog: Decodable {
|
||||
let version: String?
|
||||
let order: ReviewOrderTagsCatalog?
|
||||
let delivery: ReviewDeliveryTagsCatalog?
|
||||
let app: ReviewAppTagsCatalog?
|
||||
}
|
||||
|
||||
struct SubmitOrderReviewResult: Decodable {
|
||||
let id: String?
|
||||
let storeId: String?
|
||||
let userId: String?
|
||||
let clientName: String?
|
||||
let rate: Int?
|
||||
let message: String?
|
||||
let orderRate: Int?
|
||||
let orderComment: String?
|
||||
let deliverySentiment: String?
|
||||
let orderPositiveTags: [String]?
|
||||
let orderImprovementTags: [String]?
|
||||
let deliveryPositiveTags: [String]?
|
||||
let deliveryNegativeTags: [String]?
|
||||
let appNps: Int?
|
||||
let platform: String?
|
||||
let orderId: String?
|
||||
let date: String?
|
||||
let editableUntil: String?
|
||||
let storeReplyUntil: String?
|
||||
let reviewWindowExpiresAt: String?
|
||||
let storeReplyMessage: String?
|
||||
let storeReplyAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case storeId
|
||||
case userId
|
||||
case clientName
|
||||
case rate
|
||||
case message
|
||||
case orderRate
|
||||
case orderComment
|
||||
case deliverySentiment
|
||||
case deliveryFeedback
|
||||
case itemFeedback
|
||||
case improvementFeedback
|
||||
case orderPositiveTags
|
||||
case orderImprovementTags
|
||||
case deliveryPositiveTags
|
||||
case deliveryNegativeTags
|
||||
case appNps
|
||||
case app_nps
|
||||
case platform
|
||||
case orderId
|
||||
case date
|
||||
case editableUntil
|
||||
case storeReplyUntil
|
||||
case reviewWindowExpiresAt
|
||||
case storeReply
|
||||
case store_response
|
||||
case storeResponse
|
||||
case reply
|
||||
case storeReplyMessage
|
||||
case storeReplyAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
|
||||
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
|
||||
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
|
||||
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
|
||||
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
|
||||
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
|
||||
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
|
||||
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
|
||||
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
|
||||
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
|
||||
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
|
||||
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
|
||||
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
|
||||
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
|
||||
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
|
||||
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
|
||||
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
||||
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
|
||||
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
|
||||
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
|
||||
storeReplyMessage = Self.decodeReplyMessage(from: container)
|
||||
storeReplyAt = Self.decodeReplyDate(from: container)
|
||||
}
|
||||
|
||||
private static func decodeStringList(
|
||||
from container: KeyedDecodingContainer<CodingKeys>,
|
||||
keys: [CodingKeys]
|
||||
) -> [String]? {
|
||||
for key in keys {
|
||||
if let list = try? container.decode([String].self, forKey: key) {
|
||||
return list
|
||||
}
|
||||
if let single = try? container.decode(String.self, forKey: key) {
|
||||
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalized.isEmpty == false {
|
||||
return [normalized]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeNps(
|
||||
from container: KeyedDecodingContainer<CodingKeys>,
|
||||
keys: [CodingKeys]
|
||||
) -> Int? {
|
||||
for key in keys {
|
||||
if let value = try? container.decode(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
return Int(value.rounded())
|
||||
}
|
||||
if let raw = try? container.decode(String.self, forKey: key) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { continue }
|
||||
if let asInt = Int(trimmed) {
|
||||
return asInt
|
||||
}
|
||||
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
|
||||
if let asDouble = Double(normalized) {
|
||||
return Int(asDouble.rounded())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
|
||||
return value
|
||||
}
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
|
||||
return value
|
||||
}
|
||||
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
|
||||
if let object = try? container.decode([String: String].self, forKey: key) {
|
||||
let candidates = ["message", "text", "reply", "content", "body"]
|
||||
for candidate in candidates {
|
||||
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty == false { return value }
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
|
||||
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
|
||||
return value
|
||||
}
|
||||
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
|
||||
if let object = try? container.decode([String: String].self, forKey: key) {
|
||||
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
|
||||
for candidate in candidates {
|
||||
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty == false { return value }
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicStoreReviewsResult: Decodable {
|
||||
let reviews: [SubmitOrderReviewResult]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case reviews
|
||||
case data
|
||||
case items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let list = try? [SubmitOrderReviewResult](from: decoder) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
|
||||
reviews = list
|
||||
return
|
||||
}
|
||||
reviews = []
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,31 @@ final class ApiService {
|
||||
return envelope
|
||||
}
|
||||
|
||||
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
let payload = CustomerIdentityUpdatePayload(
|
||||
name: name,
|
||||
email: email,
|
||||
phoneNumber: phoneNumber,
|
||||
profilePicture: profilePicture,
|
||||
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
|
||||
)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if envelope.error == false, envelope.result != nil {
|
||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||
} else {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||
}
|
||||
@@ -297,7 +322,13 @@ final class ApiService {
|
||||
return cached
|
||||
}
|
||||
|
||||
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
|
||||
let req = ApiRequest(
|
||||
path: "/api/app/orders",
|
||||
method: "GET",
|
||||
module: .app,
|
||||
requiresAuth: true,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
|
||||
if envelope.error == false {
|
||||
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
@@ -306,7 +337,46 @@ final class ApiService {
|
||||
}
|
||||
|
||||
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
|
||||
let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true)
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/orders/\(orderId)",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: true,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/orders/\(orderId)/review",
|
||||
method: "POST",
|
||||
module: .none,
|
||||
requiresAuth: true,
|
||||
body: body
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/reviews/tags",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
|
||||
let req = ApiRequest(
|
||||
path: "/api/public/store/\(storeId)/reviews",
|
||||
method: "GET",
|
||||
module: .none,
|
||||
requiresAuth: false,
|
||||
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ enum AppCacheTTL {
|
||||
static let homeStores: TimeInterval = 5 * 60
|
||||
}
|
||||
|
||||
enum AppCacheKey {
|
||||
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
|
||||
}
|
||||
|
||||
final class AppContentCache: @unchecked Sendable {
|
||||
static let shared = AppContentCache()
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
enum ImageSourceResolver {
|
||||
static func resolve(_ raw: String?) -> String? {
|
||||
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
normalized.isEmpty == false else { return nil }
|
||||
|
||||
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
|
||||
let lower = normalized.lowercased()
|
||||
|
||||
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// if let base64DataURL = normalizedBase64DataURL(normalized) {
|
||||
// return base64DataURL
|
||||
// }
|
||||
|
||||
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
|
||||
return "\(base)\(path)"
|
||||
}
|
||||
|
||||
private static func normalizedBase64DataURL(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty == false else { return nil }
|
||||
|
||||
let payload: String
|
||||
if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) {
|
||||
payload = String(trimmed[marker.upperBound...])
|
||||
} else {
|
||||
payload = trimmed
|
||||
}
|
||||
|
||||
let sanitized = payload
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
guard sanitized.count >= 64 else { return nil }
|
||||
guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil }
|
||||
return "data:image/png;base64,\(sanitized)"
|
||||
}
|
||||
}
|
||||
@@ -76,13 +76,18 @@ final class OrderRealtimeTracker {
|
||||
@discardableResult
|
||||
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
|
||||
do {
|
||||
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
guard response.error == false, let order = response.result else { return nil }
|
||||
SessionStateStore.saveTrackedOrder(order)
|
||||
guard response.error == false, let order = response.result else {
|
||||
logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
clearPendingCartIfNeeded(for: order)
|
||||
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
|
||||
onOrderUpdated?(order)
|
||||
return order
|
||||
} catch {
|
||||
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ import Foundation
|
||||
extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
||||
}
|
||||
|
||||
@@ -37,6 +37,48 @@ private struct PersistedTrackedOrdersState: Codable {
|
||||
let orders: [PublicOrderResult]
|
||||
}
|
||||
|
||||
struct OrderReviewRecord: Codable, Identifiable, Hashable {
|
||||
var id: String { orderId }
|
||||
let orderId: String
|
||||
let storeId: String?
|
||||
let shortId: String?
|
||||
let storeName: String?
|
||||
let storeLogoURL: String?
|
||||
let createdAt: String?
|
||||
let submittedAt: String
|
||||
let rating: Int
|
||||
let comment: String
|
||||
let orderPositiveTags: [String]?
|
||||
let orderImprovementTags: [String]?
|
||||
let deliverySentiment: String?
|
||||
let deliveryPositiveTags: [String]?
|
||||
let deliveryNegativeTags: [String]?
|
||||
let appNps: Int?
|
||||
let platform: String?
|
||||
let editableUntil: String?
|
||||
let storeReplyUntil: String?
|
||||
let reviewWindowExpiresAt: String?
|
||||
let storeReplyMessage: String?
|
||||
let storeReplyAt: String?
|
||||
}
|
||||
|
||||
struct OrderReviewDraftState: Codable, Hashable {
|
||||
var orderId: String
|
||||
var orderRate: Int
|
||||
var orderComment: String
|
||||
var orderPositiveTags: [String]
|
||||
var orderImprovementTags: [String]
|
||||
var deliverySentiment: String
|
||||
var deliveryPositiveTags: [String]
|
||||
var deliveryNegativeTags: [String]
|
||||
var appNps: Int
|
||||
var platform: String
|
||||
}
|
||||
|
||||
private struct PersistedOrderReviewsState: Codable {
|
||||
let reviews: [OrderReviewRecord]
|
||||
}
|
||||
|
||||
enum SessionStateStore {
|
||||
private static let legacyAddressKey = "session.address.state.v1"
|
||||
private static let addressKeyPrefix = "session.address.state.v2."
|
||||
@@ -44,6 +86,8 @@ enum SessionStateStore {
|
||||
private static let cartKeyPrefix = "session.cart.state.v1."
|
||||
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
|
||||
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
||||
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
|
||||
|
||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||
let id = (profileId ?? "")
|
||||
@@ -261,6 +305,23 @@ enum SessionStateStore {
|
||||
return pendingCartOrderKeyPrefix + safe
|
||||
}
|
||||
|
||||
private static func orderReviewsStorageKey(for userKey: String?) -> String {
|
||||
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let safe = key.replacingOccurrences(of: " ", with: "_")
|
||||
return orderReviewsKeyPrefix + safe
|
||||
}
|
||||
|
||||
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
|
||||
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: " ", with: "_")
|
||||
let id = orderId
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
|
||||
}
|
||||
|
||||
static func loadTrackedOrders() -> [PublicOrderResult] {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = trackedOrdersStorageKey(for: nil)
|
||||
@@ -315,4 +376,66 @@ enum SessionStateStore {
|
||||
static func clearPendingCartOrder() {
|
||||
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviews() -> [OrderReviewRecord] {
|
||||
let key = orderReviewsStorageKey(for: nil)
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return decoded.reviews.sorted { lhs, rhs in
|
||||
lhs.submittedAt > rhs.submittedAt
|
||||
}
|
||||
}
|
||||
|
||||
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
|
||||
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard normalized.isEmpty == false else { return nil }
|
||||
return loadOrderReviews().first { review in
|
||||
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
||||
}
|
||||
}
|
||||
|
||||
static func hasOrderReview(orderId: String) -> Bool {
|
||||
loadOrderReview(orderId: orderId) != nil
|
||||
}
|
||||
|
||||
static func saveOrderReview(_ review: OrderReviewRecord) {
|
||||
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard cleanId.isEmpty == false else { return }
|
||||
|
||||
var reviews = loadOrderReviews()
|
||||
if let index = reviews.firstIndex(where: {
|
||||
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
|
||||
}) {
|
||||
reviews[index] = review
|
||||
} else {
|
||||
reviews.insert(review, at: 0)
|
||||
}
|
||||
|
||||
let payload = PersistedOrderReviewsState(reviews: reviews)
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
|
||||
}
|
||||
|
||||
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return draft
|
||||
}
|
||||
|
||||
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
|
||||
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
|
||||
guard let data = try? JSONEncoder().encode(draft) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
|
||||
static func clearOrderReviewDraft(orderId: String) {
|
||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct ProfileState {
|
||||
var name: String = ""
|
||||
var email: String = ""
|
||||
var phone: String = ""
|
||||
var profilePicture: String = ""
|
||||
}
|
||||
|
||||
struct AddressState {
|
||||
|
||||
@@ -55,8 +55,8 @@ struct LoginView: View {
|
||||
.scaledToFit()
|
||||
.frame(height: 180)
|
||||
|
||||
Text("Descubra lorem ipsum ba bla")
|
||||
.font(AppTypography.heading1)
|
||||
Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
|
||||
.font(AppTypography.heading25)
|
||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -261,6 +261,7 @@ struct OtpView: View {
|
||||
appState.profile.name = customer.name
|
||||
appState.profile.email = customer.email
|
||||
appState.profile.phone = customer.phoneNumber ?? ""
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
|
||||
@@ -208,7 +208,7 @@ struct AddressesView: View {
|
||||
}
|
||||
|
||||
private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) {
|
||||
let selected = updatedAddresses.first(where: { $0.id == appState.address.selectedId }) ?? updatedAddresses.first
|
||||
let selected = resolvePreferredAddress(from: updatedAddresses)
|
||||
appState.address.selectedId = selected?.id
|
||||
appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco"
|
||||
|
||||
@@ -314,6 +314,7 @@ struct AddressesView: View {
|
||||
appState.profile.name = customer.name
|
||||
appState.profile.email = customer.email
|
||||
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
@@ -321,7 +322,7 @@ struct AddressesView: View {
|
||||
} else {
|
||||
addresses = []
|
||||
}
|
||||
if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first {
|
||||
if let selected = resolvePreferredAddress(from: addresses) {
|
||||
appState.address.selectedId = selected.id
|
||||
appState.address.display = selected.label ?? "Defina seu endereco"
|
||||
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
||||
@@ -336,4 +337,39 @@ struct AddressesView: View {
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func resolvePreferredAddress(from list: [CustomerAddress]) -> CustomerAddress? {
|
||||
guard list.isEmpty == false else { return nil }
|
||||
|
||||
let selectedId = appState.address.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if selectedId.isEmpty == false,
|
||||
let byId = list.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
|
||||
return byId
|
||||
}
|
||||
|
||||
let normalizedDisplay = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
|
||||
let byLabel = list.first(where: {
|
||||
(($0.label ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()) == normalizedDisplay
|
||||
}) {
|
||||
return byLabel
|
||||
}
|
||||
|
||||
if let lat = appState.address.latitude, let lng = appState.address.longitude,
|
||||
let byCoordinate = list.first(where: { address in
|
||||
guard let addrLat = address.latLong?.first,
|
||||
let addrLng = address.latLong?.dropFirst().first else { return false }
|
||||
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
|
||||
}) {
|
||||
return byCoordinate
|
||||
}
|
||||
|
||||
return list.first
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ extension CheckoutView {
|
||||
if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false {
|
||||
appState.profile.phone = phoneNumber
|
||||
}
|
||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
@@ -84,6 +85,15 @@ extension CheckoutView {
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil,
|
||||
let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||
selectedCustomerAddress = addresses.first { address in
|
||||
guard let addrLat = address.latLong?.first,
|
||||
let addrLng = address.latLong?.dropFirst().first else { return false }
|
||||
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil {
|
||||
selectedCustomerAddress = addresses.first
|
||||
}
|
||||
|
||||
@@ -93,22 +93,25 @@ extension HomeView {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !forceRefresh {
|
||||
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||
return (lat, lng)
|
||||
}
|
||||
if let cached = LocationService.shared.cachedLocation() {
|
||||
appState.address.latitude = cached.0
|
||||
appState.address.longitude = cached.1
|
||||
return cached
|
||||
}
|
||||
// If user selected/saved an address, always trust its coordinates.
|
||||
// This avoids overriding the chosen city with current device GPS.
|
||||
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||
return (lat, lng)
|
||||
}
|
||||
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
|
||||
if let coordinate {
|
||||
appState.address.latitude = coordinate.0
|
||||
appState.address.longitude = coordinate.1
|
||||
|
||||
if !forceRefresh, let cached = LocationService.shared.cachedLocation() {
|
||||
appState.address.latitude = cached.0
|
||||
appState.address.longitude = cached.1
|
||||
return cached
|
||||
}
|
||||
return coordinate
|
||||
|
||||
// Fallback to device location only when no address coordinates are available.
|
||||
let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
|
||||
if let deviceCoordinate {
|
||||
appState.address.latitude = deviceCoordinate.0
|
||||
appState.address.longitude = deviceCoordinate.1
|
||||
}
|
||||
return deviceCoordinate
|
||||
}
|
||||
|
||||
func hasConfiguredAddress() -> Bool {
|
||||
|
||||
@@ -7,6 +7,7 @@ import UIKit
|
||||
|
||||
struct HomeView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
@State var searchText = ""
|
||||
@State var selectedCategory = "all"
|
||||
@State var categories: [CategoryModel] = [
|
||||
@@ -205,13 +206,24 @@ struct HomeView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Spacer().frame(height: 20)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundStyle(AppColors.brandDark)
|
||||
)
|
||||
Button {
|
||||
selectedTab = .profile
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay {
|
||||
if let profilePictureURL {
|
||||
AsyncStoreImage(imageURL: profilePictureURL)
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.foregroundStyle(AppColors.brandDark)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .center, spacing: 4) {
|
||||
Text("DELIVERY LOCATION")
|
||||
@@ -376,18 +388,11 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func resolveStoreMediaURL(_ raw: String?) -> String? {
|
||||
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
normalized.isEmpty == false else { return nil }
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
|
||||
let lower = normalized.lowercased()
|
||||
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
|
||||
return "\(base)\(path)"
|
||||
private var profilePictureURL: String? {
|
||||
resolveStoreMediaURL(appState.profile.profilePicture)
|
||||
}
|
||||
|
||||
private func formatDistance(_ distance: Double?) -> String {
|
||||
@@ -430,6 +435,15 @@ struct HomeView: View {
|
||||
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
|
||||
isLoadingStores = false
|
||||
stores = cachedStores
|
||||
#if os(iOS)
|
||||
for store in cachedStores {
|
||||
printLog(
|
||||
title: "LOGO HOME",
|
||||
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
@@ -453,7 +467,16 @@ struct HomeView: View {
|
||||
}
|
||||
let results = response.result ?? []
|
||||
stores = results
|
||||
#if os(iOS)
|
||||
for store in results {
|
||||
printLog(
|
||||
title: "LOGO HOME",
|
||||
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
||||
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
@@ -488,7 +511,9 @@ struct HomeView: View {
|
||||
let display = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return "\(selected)|\(display)"
|
||||
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
return "\(selected)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
|
||||
|
||||
@@ -12,7 +12,7 @@ struct MainTabView: View {
|
||||
switch selectedTab {
|
||||
case .home:
|
||||
NavigationStack {
|
||||
HomeView(appState: $appState)
|
||||
HomeView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
case .cart:
|
||||
NavigationStack {
|
||||
|
||||
@@ -18,6 +18,8 @@ struct OrderTrackingView: View {
|
||||
@State var order: PublicOrderResult? = nil
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var showCancellationReason = false
|
||||
@State var reviewDraft: ReviewDraft? = nil
|
||||
@State var didSaveReviewForCurrentOrder = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -27,7 +29,11 @@ struct OrderTrackingView: View {
|
||||
statusBanner
|
||||
timelineSection
|
||||
placeholderCard
|
||||
contactButton
|
||||
if shouldShowReviewButton {
|
||||
reviewButton
|
||||
} else {
|
||||
contactButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
@@ -53,6 +59,16 @@ struct OrderTrackingView: View {
|
||||
.onDisappear {
|
||||
tracker.stop()
|
||||
}
|
||||
.navigationDestination(item: $reviewDraft) { draft in
|
||||
MyReviewsView(initialOrder: draft)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { payload in
|
||||
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
|
||||
let currentOrderId = (order?.id ?? orderId).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
|
||||
didSaveReviewForCurrentOrder = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var topHeader: some View {
|
||||
@@ -217,6 +233,19 @@ struct OrderTrackingView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var reviewButton: some View {
|
||||
Button("AVALIAR PEDIDO") {
|
||||
guard let reviewTargetDraft else { return }
|
||||
reviewDraft = reviewTargetDraft
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color(hex: "#0E1A06"))
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color(hex: "#7CF02A"))
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
@@ -444,12 +473,43 @@ struct OrderTrackingView: View {
|
||||
return customerOtpSubtitle
|
||||
}
|
||||
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
if shouldUseTimelineEventLabel(label, fallback: fallback) {
|
||||
return label
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool {
|
||||
guard label.isEmpty == false else { return false }
|
||||
|
||||
let foldedLabel = label
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
let foldedFallback = fallback
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
|
||||
if foldedLabel == foldedFallback { return false }
|
||||
|
||||
let englishHints = [
|
||||
"order",
|
||||
"confirmed",
|
||||
"in progress",
|
||||
"progress",
|
||||
"delivery",
|
||||
"delivered",
|
||||
"ready",
|
||||
"sent",
|
||||
"out for",
|
||||
"began"
|
||||
]
|
||||
if englishHints.contains(where: { foldedLabel.contains($0) }) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private var customerOtpSubtitle: String {
|
||||
if let otp = customerOtpCode {
|
||||
return "Código para o entregador: \(otp)"
|
||||
@@ -548,6 +608,57 @@ struct OrderTrackingView: View {
|
||||
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
|
||||
}
|
||||
|
||||
private var shouldShowReviewButton: Bool {
|
||||
guard isCompletedOrder else { return false }
|
||||
guard isCanceled == false else { return false }
|
||||
guard let reviewTargetDraft else { return false }
|
||||
if didSaveReviewForCurrentOrder { return false }
|
||||
if hasPersistedReviewForCurrentOrder { return false }
|
||||
return order?.review == nil
|
||||
}
|
||||
|
||||
private var hasPersistedReviewForCurrentOrder: Bool {
|
||||
reviewIdCandidates.contains { candidate in
|
||||
SessionStateStore.hasOrderReview(orderId: candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewIdCandidates: [String] {
|
||||
let values = [
|
||||
orderId,
|
||||
order?.id,
|
||||
order?.realId,
|
||||
order?.shortId
|
||||
]
|
||||
var unique: [String] = []
|
||||
var seen = Set<String>()
|
||||
for raw in values {
|
||||
let normalized = (raw ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue }
|
||||
seen.insert(normalized)
|
||||
unique.append(normalized)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private var reviewTargetDraft: ReviewDraft? {
|
||||
let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let id = idFromOrder.isEmpty ? orderId : idFromOrder
|
||||
guard id.isEmpty == false else { return nil }
|
||||
|
||||
return ReviewDraft(
|
||||
orderId: id,
|
||||
storeId: order?.storeId,
|
||||
shortId: order?.shortId ?? initialShortId,
|
||||
storeName: order?.storeName,
|
||||
storeLogoURL: order?.storeLogoURL,
|
||||
createdAt: order?.createdAt,
|
||||
total: order?.total
|
||||
)
|
||||
}
|
||||
|
||||
private var cancellationReasonText: String {
|
||||
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? "Sem detalhe informado." : value
|
||||
@@ -590,7 +701,7 @@ struct OrderTrackingView: View {
|
||||
case 1:
|
||||
return "tracking-preparing"
|
||||
case 2:
|
||||
return isPickup ? "tracking-ready" : "tracking-preparing"
|
||||
return "tracking-ready"
|
||||
case 3:
|
||||
return "tracking-delivering"
|
||||
default:
|
||||
@@ -636,24 +747,21 @@ struct OrderTrackingView: View {
|
||||
|
||||
@MainActor
|
||||
private func loadInitialOrder() async {
|
||||
if let cached = SessionStateStore.loadTrackedOrder(orderId: orderId) {
|
||||
order = cached
|
||||
isLoading = false
|
||||
}
|
||||
logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
|
||||
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error {
|
||||
errorMessage = response.message ?? "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
|
||||
} else if let result = response.result {
|
||||
order = result
|
||||
SessionStateStore.saveTrackedOrder(result)
|
||||
errorMessage = nil
|
||||
logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
if order == nil {
|
||||
errorMessage = "Não foi possível carregar o pedido."
|
||||
}
|
||||
errorMessage = "Não foi possível carregar o pedido."
|
||||
logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
@@ -17,7 +17,12 @@ struct ProfileView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 18) {
|
||||
header
|
||||
NavigationLink {
|
||||
UserProfileView(appState: $appState)
|
||||
} label: {
|
||||
header
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
NavigationLink {
|
||||
@@ -42,7 +47,7 @@ struct ProfileView: View {
|
||||
// .buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
Text("Minhas Avaliações")
|
||||
MyReviewsView()
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações")
|
||||
}
|
||||
@@ -122,9 +127,17 @@ struct ProfileView: View {
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: 96, height: 96)
|
||||
.overlay(
|
||||
Text(profileInitials)
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
Group {
|
||||
if let picture = profilePictureURL {
|
||||
AsyncStoreImage(imageURL: picture)
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Text(profileInitials)
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Circle()
|
||||
@@ -189,6 +202,12 @@ struct ProfileView: View {
|
||||
return joined.isEmpty ? "AS" : joined.uppercased()
|
||||
}
|
||||
|
||||
private var profilePictureURL: String? {
|
||||
let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard raw.isEmpty == false else { return nil }
|
||||
return ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
|
||||
@@ -143,17 +143,7 @@ extension StoreDetailView {
|
||||
}
|
||||
|
||||
func resolvedURL(_ raw: String?) -> String? {
|
||||
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
normalized.isEmpty == false else { return nil }
|
||||
|
||||
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
|
||||
let lower = normalized.lowercased()
|
||||
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
|
||||
return normalized
|
||||
}
|
||||
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
|
||||
return "\(base)\(path)"
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
func formatCurrency(_ value: Double?) -> String {
|
||||
|
||||
269
pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
Normal file
269
pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
Normal file
@@ -0,0 +1,269 @@
|
||||
import SwiftUI
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
import PhotosUI
|
||||
#endif
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct UserProfileView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var name: String = ""
|
||||
@State var email: String = ""
|
||||
@State var phone: String = ""
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
@State var selectedPhotoItem: PhotosPickerItem?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
avatarSection
|
||||
formSection
|
||||
saveButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meu Perfil")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
.onChange(of: selectedPhotoItem) { _, newItem in
|
||||
Task { await applySelectedPhoto(newItem) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 110, height: 110)
|
||||
.overlay {
|
||||
if let imageSource = resolvedProfilePicture {
|
||||
AsyncStoreImage(imageURL: imageSource)
|
||||
.frame(width: 104, height: 104)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Text(initials)
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
|
||||
Text("Trocar Foto")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
#endif
|
||||
Button("Remover") {
|
||||
profilePicture = ""
|
||||
}
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(resolvedProfilePicture == nil)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
|
||||
|
||||
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
|
||||
.appNoAutoCap()
|
||||
|
||||
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
|
||||
.onChange(of: phone) { _, newValue in
|
||||
let masked = formatPhoneBR(displayPhoneDigits(newValue))
|
||||
if masked != newValue {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
||||
Task { await saveProfile() }
|
||||
}
|
||||
.font(AppTypography.button)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSaving || canSave == false)
|
||||
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
|
||||
}
|
||||
|
||||
private var resolvedProfilePicture: String? {
|
||||
let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty == false else { return nil }
|
||||
return ImageSourceResolver.resolve(trimmed)
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = name
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
let letters = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return letters.isEmpty ? "PF" : letters.uppercased()
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
return cleanName.isEmpty == false
|
||||
&& cleanEmail.isEmpty == false
|
||||
&& cleanEmail.contains("@")
|
||||
&& normalizedPhone.isEmpty == false
|
||||
}
|
||||
|
||||
private func hydrateFromAppState() {
|
||||
name = appState.profile.name
|
||||
email = appState.profile.email
|
||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||
profilePicture = appState.profile.profilePicture
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveProfile() async {
|
||||
guard canSave else { return }
|
||||
|
||||
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().updateCustomerProfile(
|
||||
name: cleanName,
|
||||
email: cleanEmail,
|
||||
phoneNumber: normalizedPhone,
|
||||
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
|
||||
)
|
||||
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let customer = response.result
|
||||
appState.profile.id = customer?.id ?? appState.profile.id
|
||||
appState.profile.name = customer?.name ?? cleanName
|
||||
appState.profile.email = customer?.email ?? cleanEmail
|
||||
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
|
||||
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
|
||||
)
|
||||
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Perfil atualizado com sucesso.",
|
||||
style: .success,
|
||||
icon: "checkmark.circle.fill",
|
||||
duration: 2.0
|
||||
)
|
||||
dismiss()
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível atualizar seu perfil.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatPhoneForDisplay(_ raw: String) -> String {
|
||||
let digits = displayPhoneDigits(raw)
|
||||
if digits.isEmpty { return "" }
|
||||
return formatPhoneBR(digits)
|
||||
}
|
||||
|
||||
private func displayPhoneDigits(_ raw: String) -> String {
|
||||
var digits = raw.filter(\.isNumber)
|
||||
if digits.hasPrefix("55"), digits.count > 11 {
|
||||
digits = String(digits.dropFirst(2))
|
||||
}
|
||||
return String(digits.prefix(11))
|
||||
}
|
||||
|
||||
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
TextField(placeholder, text: text)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 50)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(PhotosUI) && os(iOS)
|
||||
@MainActor
|
||||
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
#if canImport(UIKit)
|
||||
guard let image = UIImage(data: data),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
|
||||
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
||||
#else
|
||||
profilePicture = "data:image/jpeg;base64,\(data.base64EncodedString())"
|
||||
#endif
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível carregar a foto selecionada.",
|
||||
style: .warning,
|
||||
icon: "photo",
|
||||
duration: 2.5
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user