This commit is contained in:
Daniel Arantes Loverde
2026-03-05 10:10:27 -03:00
parent a8e3631177
commit 1407f0b9de
27 changed files with 1493 additions and 84 deletions

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View 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 = []
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ struct ProfileState {
var name: String = ""
var email: String = ""
var phone: String = ""
var profilePicture: String = ""
}
struct AddressState {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ struct MainTabView: View {
switch selectedTab {
case .home:
NavigationStack {
HomeView(appState: $appState)
HomeView(appState: $appState, selectedTab: $selectedTab)
}
case .cart:
NavigationStack {

View File

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

View File

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

View File

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

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