payment and list

This commit is contained in:
Daniel Arantes Loverde
2026-02-24 10:19:47 -03:00
parent 3a3dc7217b
commit ca83a275ac
51 changed files with 1645 additions and 256 deletions

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-canceled.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-completed.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-delivering.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-pending.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-preparing.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tracking-ready.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

View File

@@ -15,7 +15,7 @@ struct CachedRemoteImage<Placeholder: View>: View {
init( init(
imageURL: String?, imageURL: String?,
ttl: TimeInterval = 6 * 60 * 60, ttl: TimeInterval = AppCacheTTL.twoHours,
@ViewBuilder placeholder: () -> Placeholder @ViewBuilder placeholder: () -> Placeholder
) { ) {
self.imageURL = imageURL self.imageURL = imageURL
@@ -100,7 +100,18 @@ final class CachedRemoteImageLoader: ObservableObject {
guard let normalized, normalized.isEmpty == false else { return } guard let normalized, normalized.isEmpty == false else { return }
#if canImport(UIKit) || canImport(AppKit) #if canImport(UIKit) || canImport(AppKit)
let dataCacheKey = Self.dataURLCacheKey(normalized)
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
#if canImport(UIKit)
uiImage = cachedDataImage
#elseif canImport(AppKit)
nsImage = cachedDataImage
#endif
return
}
if let image = Self.imageFromDataURL(normalized) { if let image = Self.imageFromDataURL(normalized) {
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
#if canImport(UIKit) #if canImport(UIKit)
uiImage = image uiImage = image
#elseif canImport(AppKit) #elseif canImport(AppKit)
@@ -134,6 +145,12 @@ final class CachedRemoteImageLoader: ObservableObject {
return normalized return normalized
} }
private static func dataURLCacheKey(_ source: String) -> String {
let head = String(source.prefix(48))
let tail = String(source.suffix(48))
return "data-image:\(source.count):\(head):\(tail)"
}
#if canImport(UIKit) || canImport(AppKit) #if canImport(UIKit) || canImport(AppKit)
private static func imageFromDataURL(_ source: String) -> PlatformImage? { private static func imageFromDataURL(_ source: String) -> PlatformImage? {
let lower = source.lowercased() let lower = source.lowercased()

View File

@@ -52,6 +52,9 @@ struct ContentView: View {
.onChange(of: appState.address.longitude) { _, _ in .onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists() dismissAddressPickerIfAddressExists()
} }
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
appState.cart = CartState()
}
.task(id: root) { .task(id: root) {
await bootstrapSessionStateIfNeeded() await bootstrapSessionStateIfNeeded()
} }
@@ -169,6 +172,7 @@ struct ContentView: View {
tokenStore.clear() tokenStore.clear()
SessionStateStore.clearActiveUser() SessionStateStore.clearActiveUser()
SessionStateStore.clearTrackedOrders() SessionStateStore.clearTrackedOrders()
SessionStateStore.clearPendingCartOrder()
AppContentCache.shared.invalidate() AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll() AppImageCache.shared.invalidateAll()
isBootstrappingSession = false isBootstrappingSession = false

View File

@@ -78,10 +78,7 @@
} }
} }
}, },
"Acompanhamento" : { "Acompanhamento em tempo real" : {
},
"Acompanhe seu pedido em tempo real" : {
}, },
"Add" : { "Add" : {
@@ -179,6 +176,9 @@
} }
} }
} }
},
"Atualizando status do pedido..." : {
}, },
"Atualize os dados do endereço abaixo." : { "Atualize os dados do endereço abaixo." : {
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.", "comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
@@ -233,6 +233,9 @@
"Cardápio indisponível no momento." : { "Cardápio indisponível no momento." : {
"comment" : "A message displayed when a store's menu is unavailable.", "comment" : "A message displayed when a store's menu is unavailable.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Carregando pedido..." : {
}, },
"Carregando sua sessão..." : { "Carregando sua sessão..." : {
"comment" : "A loading message displayed while bootstrapping the user's session.", "comment" : "A loading message displayed while bootstrapping the user's session.",
@@ -250,6 +253,10 @@
"comment" : "The title of the field that displays the PIX code.", "comment" : "The title of the field that displays the PIX code.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Colar código" : {
"comment" : "A button that allows the user to paste their OTP code directly into the field.",
"isCommentAutoGenerated" : true
},
"Concluir" : { "Concluir" : {
"comment" : "The text for a button that confirms and closes a sheet.", "comment" : "The text for a button that confirms and closes a sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -262,6 +269,10 @@
"comment" : "A button label that translates to \"Confirm and Pay\".", "comment" : "A button label that translates to \"Confirm and Pay\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"CONTATO" : {
"comment" : "The text on a button that takes the user to contact support.",
"isCommentAutoGenerated" : true
},
"Conteúdo da política de privacidade..." : { "Conteúdo da política de privacidade..." : {
"comment" : "A placeholder text describing the content of the privacy policy.", "comment" : "A placeholder text describing the content of the privacy policy.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -397,10 +408,6 @@
"comment" : "A button label that translates to \"Delete\".", "comment" : "A button label that translates to \"Delete\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Expira em: %@" : {
"comment" : "A label displaying the expiration date of a payment code. The text inside the parentheses should be replaced with the actual expiration date.",
"isCommentAutoGenerated" : true
},
"Favorite" : { "Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite", "comment" : "Item editor title label for marking the item as a favorite",
"extractionState" : "stale", "extractionState" : "stale",
@@ -525,10 +532,6 @@
"comment" : "A button label that translates to \"Go to Payment\" in English.", "comment" : "A button label that translates to \"Go to Payment\" in English.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Já realizei o pagamento" : {
"comment" : "A button that lets the user know they've completed the payment.",
"isCommentAutoGenerated" : true
},
"Já tem uma conta?" : { "Já tem uma conta?" : {
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -592,6 +595,9 @@
"Monte sua pizza" : { "Monte sua pizza" : {
"comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.", "comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Motivo do cancelamento" : {
}, },
"Name" : { "Name" : {
"comment" : "Placeholder title for the Name field in a form", "comment" : "Placeholder title for the Name field in a form",
@@ -707,9 +713,6 @@
}, },
"Pedido %@" : { "Pedido %@" : {
},
"Pedido ID #%@" : {
}, },
"PediFoods" : { "PediFoods" : {
"comment" : "The name of the app.", "comment" : "The name of the app.",
@@ -772,6 +775,9 @@
"Privacidade" : { "Privacidade" : {
"comment" : "The title of the privacy policy screen.", "comment" : "The title of the privacy policy screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Progresso do Pedido" : {
}, },
"Receber Código" : { "Receber Código" : {
"comment" : "A button label that says \"Receive Code\".", "comment" : "A button label that says \"Receive Code\".",
@@ -876,13 +882,13 @@
"Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : {
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Seu código do pedido: %@ - Informe esse número ao motoboy" : {
}, },
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : { "Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
"comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one", "comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Seu pedido está em andamento" : {
}, },
"Sim" : { "Sim" : {
"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.", "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.",
@@ -982,6 +988,9 @@
"Trocar de loja?" : { "Trocar de loja?" : {
"comment" : "A title for an alert that prompts the user to switch stores.", "comment" : "A title for an alert that prompts the user to switch stores.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Ver motivo do cancelamento" : {
}, },
"Ver Perfil" : { "Ver Perfil" : {
"comment" : "A button label that translates to \"View Profile\" in English.", "comment" : "A button label that translates to \"View Profile\" in English.",

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

View File

@@ -13,6 +13,7 @@ enum NetworkError: Error, LocalizedError {
case unauthorized(String?) case unauthorized(String?)
case decodeError(String?) case decodeError(String?)
case rateLimited(Int?) case rateLimited(Int?)
case cancelled
case transportError(String) case transportError(String)
var errorDescription: String? { var errorDescription: String? {
@@ -33,6 +34,8 @@ enum NetworkError: Error, LocalizedError {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s." return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
} }
return "Muitas requisicoes. Tente novamente." return "Muitas requisicoes. Tente novamente."
case .cancelled:
return "Requisicao cancelada"
case .transportError(let message): case .transportError(let message):
return "Erro de rede: \(message)" return "Erro de rede: \(message)"
} }
@@ -82,7 +85,11 @@ final class ApiClient {
// NOTE: // NOTE:
// /api/customer/login is strict about body fields (email/phoneNumber/otp). // /api/customer/login is strict about body fields (email/phoneNumber/otp).
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. // On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
if request.path == "/api/customer/login" { // 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/") {
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
print("[ApiClient] /api/customer/login body: \(bodyText)") print("[ApiClient] /api/customer/login body: \(bodyText)")
} }
@@ -161,7 +168,6 @@ private extension ApiClient {
} }
func mapError(_ error: Error) -> NetworkError { func mapError(_ error: Error) -> NetworkError {
printError(title: "httpReqError", msg: error.localizedDescription)
if let network = error as? NetworkError { if let network = error as? NetworkError {
return network return network
} }
@@ -176,6 +182,12 @@ private extension ApiClient {
return .unauthorized(payload?.message ?? apiMessage) return .unauthorized(payload?.message ?? apiMessage)
} }
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return .cancelled
}
printError(title: "httpReqError", msg: error.localizedDescription)
switch nsError.code { switch nsError.code {
case 401, 403: case 401, 403:
return .unauthorized(apiMessage) return .unauthorized(apiMessage)
@@ -209,6 +221,8 @@ private extension ApiClient {
while attempt <= maxAttempts { while attempt <= maxAttempts {
do { do {
return try await perform(urlRequest, as: T.self) return try await perform(urlRequest, as: T.self)
} catch is CancellationError {
throw NetworkError.cancelled
} catch let error as NetworkError { } catch let error as NetworkError {
guard shouldRetry(error), attempt < maxAttempts else { guard shouldRetry(error), attempt < maxAttempts else {
throw error throw error
@@ -230,6 +244,9 @@ private extension ApiClient {
do { do {
(data, response) = try await session.data(for: request) (data, response) = try await session.data(for: request)
} catch { } catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
throw NetworkError.cancelled
}
throw NetworkError.transportError(error.localizedDescription) throw NetworkError.transportError(error.localizedDescription)
} }
@@ -283,7 +300,7 @@ private extension ApiClient {
return true return true
case .httpError(let statusCode, _): case .httpError(let statusCode, _):
return statusCode >= 500 return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized: case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled:
return false return false
} }
} }

View File

@@ -46,6 +46,7 @@ struct CreateOrderResult: Decodable {
let shortId: String? let shortId: String?
let status: String? let status: String?
let paymentStatus: String? let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String? let paymentMethod: String?
let paymentPayload: CreateOrderPaymentPayload? let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo? let payment: CreateOrderPaymentInfo?
@@ -55,6 +56,7 @@ struct CreateOrderResult: Decodable {
case shortId case shortId
case status case status
case paymentStatus case paymentStatus
case paymentConfirmed
case paymentMethod case paymentMethod
case paymentPayload case paymentPayload
case payment case payment
@@ -62,11 +64,12 @@ struct CreateOrderResult: Decodable {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = try? container.decode(String.self, forKey: .id) id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = try? container.decode(String.self, forKey: .shortId) shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
status = try? container.decode(String.self, forKey: .status) status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
@@ -83,13 +86,13 @@ struct CreateOrderResult: Decodable {
} }
} }
struct CreateOrderPaymentInfo: Decodable { struct CreateOrderPaymentInfo: Codable {
let method: String? let method: String?
let status: String? let status: String?
let pix: CreateOrderPaymentPayload? let pix: CreateOrderPaymentPayload?
} }
struct CreateOrderPaymentPayload: Decodable { struct CreateOrderPaymentPayload: Codable {
let copyPaste: String? let copyPaste: String?
let qrCodeImage: String? let qrCodeImage: String?
let expirationDate: String? let expirationDate: String?
@@ -116,6 +119,13 @@ struct CreateOrderPaymentPayload: Decodable {
?? (try? container.decode(String.self, forKey: .encodedImage)) ?? (try? container.decode(String.self, forKey: .encodedImage))
expirationDate = try? container.decode(String.self, forKey: .expirationDate) expirationDate = try? container.decode(String.self, forKey: .expirationDate)
} }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(copyPaste, forKey: .copyPaste)
try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage)
try container.encodeIfPresent(expirationDate, forKey: .expirationDate)
}
} }
struct ValidateDeliveryAddressPayload: Encodable { struct ValidateDeliveryAddressPayload: Encodable {

View File

@@ -2,9 +2,14 @@ import Foundation
struct AppOrderSummary: Decodable, Identifiable { struct AppOrderSummary: Decodable, Identifiable {
let id: String let id: String
let orderId: String?
let realId: String?
let shortId: String? let shortId: String?
let total: Double? let total: Double?
let status: String? let status: String?
let statusDetailed: String?
let statusLabel: String?
let nextAction: String?
let paymentStatus: String? let paymentStatus: String?
let paymentMethod: String? let paymentMethod: String?
let deliveryType: String? let deliveryType: String?
@@ -14,39 +19,57 @@ struct AppOrderSummary: Decodable, Identifiable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case orderId
case realId
case shortId case shortId
case total case total
case status case status
case statusDetailed
case statusLabel
case nextAction
case paymentStatus case paymentStatus
case paymentMethod case paymentMethod
case deliveryType case deliveryType
case storeName case storeName
case date
case createdAt case createdAt
case updatedAt case updatedAt
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
shortId = try? container.decode(String.self, forKey: .shortId) id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
status = try? container.decode(String.self, forKey: .status) status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
deliveryType = try? container.decode(String.self, forKey: .deliveryType) nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
storeName = try? container.decode(String.self, forKey: .storeName) paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
createdAt = try? container.decode(String.self, forKey: .createdAt) paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
updatedAt = try? container.decode(String.self, forKey: .updatedAt) deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
} }
} }
struct PublicOrderResult: Codable, Identifiable { struct PublicOrderResult: Codable, Identifiable {
let id: String let id: String
let shortId: String? let shortId: String?
let realId: String?
let status: String? let status: String?
let paymentStatus: String? let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String? let paymentMethod: String?
let paymentMethodCode: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
let deliveryType: String? let deliveryType: String?
let deliveryTypeLabel: String?
let total: Double? let total: Double?
let storeName: String? let storeName: String?
let createdAt: String? let createdAt: String?
@@ -54,16 +77,23 @@ struct PublicOrderResult: Codable, Identifiable {
let otp: String? let otp: String?
let customerOtp: String? let customerOtp: String?
let confirmOtp: String? let confirmOtp: String?
let cancellationReason: String?
let items: [PublicOrderItem] let items: [PublicOrderItem]
let timeline: [PublicOrderTimelineEvent] let timeline: [PublicOrderTimelineEvent]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case shortId case shortId
case realId
case status case status
case paymentStatus case paymentStatus
case paymentConfirmed
case paymentMethod case paymentMethod
case paymentMethodCode
case paymentPayload
case payment
case deliveryType case deliveryType
case deliveryTypeLabel
case total case total
case storeName case storeName
case createdAt case createdAt
@@ -71,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
case otp case otp
case customerOtp case customerOtp
case confirmOtp case confirmOtp
case cancellationReason
case items case items
case timeline case timeline
} }
@@ -78,10 +109,16 @@ struct PublicOrderResult: Codable, Identifiable {
init( init(
id: String, id: String,
shortId: String? = nil, shortId: String? = nil,
realId: String? = nil,
status: String? = nil, status: String? = nil,
paymentStatus: String? = nil, paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
paymentMethod: String? = nil, paymentMethod: String? = nil,
paymentMethodCode: String? = nil,
paymentPayload: CreateOrderPaymentPayload? = nil,
payment: CreateOrderPaymentInfo? = nil,
deliveryType: String? = nil, deliveryType: String? = nil,
deliveryTypeLabel: String? = nil,
total: Double? = nil, total: Double? = nil,
storeName: String? = nil, storeName: String? = nil,
createdAt: String? = nil, createdAt: String? = nil,
@@ -89,15 +126,22 @@ struct PublicOrderResult: Codable, Identifiable {
otp: String? = nil, otp: String? = nil,
customerOtp: String? = nil, customerOtp: String? = nil,
confirmOtp: String? = nil, confirmOtp: String? = nil,
cancellationReason: String? = nil,
items: [PublicOrderItem] = [], items: [PublicOrderItem] = [],
timeline: [PublicOrderTimelineEvent] = [] timeline: [PublicOrderTimelineEvent] = []
) { ) {
self.id = id self.id = id
self.shortId = shortId self.shortId = shortId
self.realId = realId
self.status = status self.status = status
self.paymentStatus = paymentStatus self.paymentStatus = paymentStatus
self.paymentConfirmed = paymentConfirmed
self.paymentMethod = paymentMethod self.paymentMethod = paymentMethod
self.paymentMethodCode = paymentMethodCode
self.paymentPayload = paymentPayload
self.payment = payment
self.deliveryType = deliveryType self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel
self.total = total self.total = total
self.storeName = storeName self.storeName = storeName
self.createdAt = createdAt self.createdAt = createdAt
@@ -105,25 +149,43 @@ struct PublicOrderResult: Codable, Identifiable {
self.otp = otp self.otp = otp
self.customerOtp = customerOtp self.customerOtp = customerOtp
self.confirmOtp = confirmOtp self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.items = items self.items = items
self.timeline = timeline self.timeline = timeline
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
shortId = try? container.decode(String.self, forKey: .shortId) shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
status = try? container.decode(String.self, forKey: .status) realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
deliveryType = try? container.decode(String.self, forKey: .deliveryType) paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = try? container.decode(String.self, forKey: .storeName) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
createdAt = try? container.decode(String.self, forKey: .createdAt) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
updatedAt = try? container.decode(String.self, forKey: .updatedAt) updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = try? container.decode(String.self, forKey: .otp) otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = try? container.decode(String.self, forKey: .customerOtp) customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = try? container.decode(String.self, forKey: .confirmOtp) confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? [] timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
} }
@@ -152,6 +214,10 @@ struct PublicOrderResult: Codable, Identifiable {
} }
var isPaymentConfirmed: Bool { var isPaymentConfirmed: Bool {
if let paymentConfirmed {
return paymentConfirmed
}
let payment = (paymentStatus ?? "").uppercased() let payment = (paymentStatus ?? "").uppercased()
let currentStatus = (status ?? "").uppercased() let currentStatus = (status ?? "").uppercased()
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) { if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
@@ -226,21 +292,38 @@ struct PublicOrderItem: Codable, Identifiable {
struct PublicOrderTimelineEvent: Codable, Identifiable { struct PublicOrderTimelineEvent: Codable, Identifiable {
let id: String let id: String
let status: String? let status: String?
let label: String?
let active: Bool?
let completed: Bool?
let message: String? let message: String?
let time: String? let time: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case status case status
case label
case active
case completed
case message case message
case time case time
case createdAt case createdAt
case updatedAt case updatedAt
} }
init(id: String = UUID().uuidString, status: String?, message: String?, time: String?) { init(
id: String = UUID().uuidString,
status: String?,
label: String? = nil,
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?
) {
self.id = id self.id = id
self.status = status self.status = status
self.label = label
self.active = active
self.completed = completed
self.message = message self.message = message
self.time = time self.time = time
} }
@@ -249,6 +332,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
status = try? container.decode(String.self, forKey: .status) status = try? container.decode(String.self, forKey: .status)
label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed)
message = try? container.decode(String.self, forKey: .message) message = try? container.decode(String.self, forKey: .message)
time = (try? container.decode(String.self, forKey: .time)) time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt)) ?? (try? container.decode(String.self, forKey: .createdAt))
@@ -259,6 +345,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encodeIfPresent(status, forKey: .status) try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(active, forKey: .active)
try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message) try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time) try container.encodeIfPresent(time, forKey: .time)
} }
@@ -272,6 +361,27 @@ struct OrderRealtimeUpdate: Decodable {
let status: String? let status: String?
let paymentStatus: String? let paymentStatus: String?
let updatedAt: String? let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case shortId
case storeId
case userId
case status
case paymentStatus
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
}
} }
extension CreateOrderResult { extension CreateOrderResult {
@@ -281,7 +391,10 @@ extension CreateOrderResult {
shortId: shortId, shortId: shortId,
status: status, status: status,
paymentStatus: paymentStatus, paymentStatus: paymentStatus,
paymentMethod: paymentMethod paymentConfirmed: paymentConfirmed,
paymentMethod: paymentMethod,
paymentPayload: paymentPayload,
payment: payment
) )
} }
} }

View File

@@ -21,6 +21,9 @@ struct ApiEnvelope<T: Decodable>: Decodable {
final class ApiService { final class ApiService {
private let client: ApiClient private let client: ApiClient
private var tokenStore: TokenStore private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client self.client = client
@@ -63,9 +66,18 @@ final class ApiService {
private func expireSession(_ message: String?) { private func expireSession(_ message: String?) {
tokenStore.clear() tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message) NotificationCenter.default.post(name: .sessionExpired, object: message)
} }
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
return String(jwt.prefix(16))
}
// MARK: - Auth // MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> { func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
@@ -121,9 +133,19 @@ final class ApiService {
return try JSONSerialization.data(withJSONObject: payload, options: []) return try JSONSerialization.data(withJSONObject: payload, options: [])
} }
func profile() async throws -> ApiEnvelope<CustomerProfile> { func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req) let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
} }
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> { func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
@@ -131,7 +153,7 @@ final class ApiService {
} }
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> { func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile() let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else { guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
@@ -151,7 +173,7 @@ final class ApiService {
} }
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> { func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile() let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else { guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
@@ -189,7 +211,14 @@ final class ApiService {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook) let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req) 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
} }
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
@@ -205,9 +234,18 @@ final class ApiService {
// MARK: - Stores // MARK: - Stores
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> { func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
if forceRefresh == false,
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
return cached
}
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false) let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
return try await sendEnvelope(req) let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
} }
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> { func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
@@ -239,7 +277,11 @@ final class ApiService {
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> { func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body) let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req) let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
}
return envelope
} }
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> { func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
@@ -248,9 +290,19 @@ final class ApiService {
return try await sendEnvelope(req) return try await sendEnvelope(req)
} }
func listOrders() async throws -> ApiEnvelope<[AppOrderSummary]> { func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
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)
return try await sendEnvelope(req) let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
} }
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> { func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
@@ -259,6 +311,27 @@ final class ApiService {
} }
} }
extension ApiService { extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false {
return trimmed
}
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return String(asInt)
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
if asDouble.rounded() == asDouble {
return String(Int(asDouble))
}
return String(asDouble)
}
}
return nil
}
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? { static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys { for key in keys {
if let value = try? container.decode(Double.self, forKey: key) { if let value = try? container.decode(Double.self, forKey: key) {

View File

@@ -7,6 +7,11 @@ import AppKit
typealias PlatformImage = NSImage typealias PlatformImage = NSImage
#endif #endif
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
static let homeStores: TimeInterval = 5 * 60
}
final class AppContentCache: @unchecked Sendable { final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache() static let shared = AppContentCache()

View File

@@ -5,6 +5,9 @@ import FoundationNetworking
@MainActor @MainActor
final class OrderRealtimeTracker { final class OrderRealtimeTracker {
// Keep realtime tracking on polling to avoid socket.io handshake failures
// on environments where websocket upgrade is not available.
private let useSocketRealtime = false
private var pollingTask: Task<Void, Never>? = nil private var pollingTask: Task<Void, Never>? = nil
private var socketClient: OrderSocketClient? = nil private var socketClient: OrderSocketClient? = nil
private var activeOrderId: String? = nil private var activeOrderId: String? = nil
@@ -20,7 +23,7 @@ final class OrderRealtimeTracker {
await self.runPollingLoop(orderId: orderId) await self.runPollingLoop(orderId: orderId)
} }
guard let jwt, jwt.isEmpty == false else { return } guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
let socket = OrderSocketClient() let socket = OrderSocketClient()
socket.onOrderUpdate = { [weak self] update in socket.onOrderUpdate = { [weak self] update in
guard let self else { return } guard let self else { return }
@@ -76,12 +79,42 @@ final class OrderRealtimeTracker {
let response = try await ApiService().publicOrder(orderId: orderId) let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else { return nil } guard response.error == false, let order = response.result else { return nil }
SessionStateStore.saveTrackedOrder(order) SessionStateStore.saveTrackedOrder(order)
clearPendingCartIfNeeded(for: order)
onOrderUpdated?(order) onOrderUpdated?(order)
return order return order
} catch { } catch {
return nil return nil
} }
} }
private func clearPendingCartIfNeeded(for order: PublicOrderResult) {
guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return }
let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalizedPending.isEmpty { return }
let ids = [order.id, order.realId]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard ids.contains(normalizedPending) else { return }
if shouldClearCart(for: order) == false { return }
SessionStateStore.clearCart()
SessionStateStore.clearPendingCartOrder()
NotificationCenter.default.post(name: .cartDidReset, object: nil)
}
private func shouldClearCart(for order: PublicOrderResult) -> Bool {
if order.isPaymentConfirmed {
return true
}
let status = (order.status ?? "").uppercased()
if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") {
return true
}
return false
}
} }
final class OrderSocketClient: @unchecked Sendable { final class OrderSocketClient: @unchecked Sendable {

View File

@@ -2,5 +2,5 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification") static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification")
} }

View File

@@ -43,6 +43,7 @@ enum SessionStateStore {
private static let activeUserKey = "session.active.user.v1" private static let activeUserKey = "session.active.user.v1"
private static let cartKeyPrefix = "session.cart.state.v1." private static let cartKeyPrefix = "session.cart.state.v1."
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1." private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
static func makeUserKey(profileId: String?, email: String?) -> String? { static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "") let id = (profileId ?? "")
@@ -253,6 +254,13 @@ enum SessionStateStore {
return trackedOrdersKeyPrefix + safe return trackedOrdersKeyPrefix + safe
} }
private static func pendingCartOrderStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return pendingCartOrderKeyPrefix + safe
}
static func loadTrackedOrders() -> [PublicOrderResult] { static func loadTrackedOrders() -> [PublicOrderResult] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let key = trackedOrdersStorageKey(for: nil) let key = trackedOrdersStorageKey(for: nil)
@@ -285,4 +293,26 @@ enum SessionStateStore {
static func clearTrackedOrders() { static func clearTrackedOrders() {
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil)) UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
} }
static func savePendingCartOrderId(_ orderId: String) {
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard clean.isEmpty == false else {
clearPendingCartOrder()
return
}
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadPendingCartOrderId() -> String? {
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
static func clearPendingCartOrder() {
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
} }

View File

@@ -77,6 +77,8 @@ func userFacingAuthErrorMessage(_ error: Error) -> String {
return "Não foi possível se conectar ao servidor. Tente novamente." return "Não foi possível se conectar ao servidor. Tente novamente."
case .invalidURL, .invalidResponse, .decodeError: case .invalidURL, .invalidResponse, .decodeError:
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente." return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
case .cancelled:
return "Cancelado"
} }
} }

View File

@@ -1,4 +1,7 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct OtpView: View { struct OtpView: View {
private let resendDelaySeconds = 45 private let resendDelaySeconds = 45
@@ -66,6 +69,10 @@ struct OtpView: View {
.frame(height: 204) .frame(height: 204)
.onTapGesture { .onTapGesture {
isOtpFocused = true isOtpFocused = true
autoFillOtpFromClipboardIfAvailable()
}
.onLongPressGesture {
pasteOtpFromClipboard()
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, 26) .padding(.top, 26)
@@ -83,6 +90,13 @@ struct OtpView: View {
} }
.padding(.top, 22) .padding(.top, 22)
Button("Colar código") {
pasteOtpFromClipboard()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.padding(.top, 22)
PrimaryButton(title: "Verificar e Entrar") { PrimaryButton(title: "Verificar e Entrar") {
validateOtp() validateOtp()
} }
@@ -166,6 +180,34 @@ struct OtpView: View {
return Array(otp)[index] return Array(otp)[index]
} }
private func pasteOtpFromClipboard() {
#if canImport(UIKit)
let raw = UIPasteboard.general.string ?? ""
let digits = raw.filter(\.isNumber)
let trimmed = String(digits.prefix(8))
if trimmed.isEmpty == false {
otp = trimmed
}
#elseif canImport(AppKit)
let raw = NSPasteboard.general.string(forType: .string) ?? ""
let digits = raw.filter(\.isNumber)
let trimmed = String(digits.prefix(8))
if trimmed.isEmpty == false {
otp = trimmed
}
#endif
}
private func autoFillOtpFromClipboardIfAvailable() {
#if canImport(UIKit)
guard otp.isEmpty else { return }
let raw = UIPasteboard.general.string ?? ""
let digits = raw.filter(\.isNumber)
guard digits.count >= 8 else { return }
otp = String(digits.prefix(8))
#endif
}
private func validateOtp() { private func validateOtp() {
let code = otp.filter(\.isNumber) let code = otp.filter(\.isNumber)
guard code.count == 8 else { return } guard code.count == 8 else { return }

View File

@@ -7,7 +7,9 @@ struct CartView: View {
@State var couponCode = "" @State var couponCode = ""
@State var appliedCouponCode: String? = nil @State var appliedCouponCode: String? = nil
@State var discountValue: Double = 0 @State var discountValue: Double = 0
@State var deliveryFee: Double = 5 @State var deliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var isLoadingDeliveryFee = false
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -50,6 +52,9 @@ struct CartView: View {
.navigationDestination(isPresented: $openCheckout) { .navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState) CheckoutView(appState: $appState)
} }
.task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee()
}
} }
private var subtotalValue: Double { private var subtotalValue: Double {
@@ -57,7 +62,16 @@ struct CartView: View {
} }
private var totalValue: Double { private var totalValue: Double {
max(0, subtotalValue + deliveryFee - discountValue) max(0, subtotalValue + (deliveryFee ?? 0) - discountValue)
}
private var deliveryFeeWatchKey: String {
let storeId = appState.cart.storeId ?? "nil"
let selectedId = appState.address.selectedId ?? "nil"
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
} }
private var couponSection: some View { private var couponSection: some View {
@@ -108,7 +122,7 @@ struct CartView: View {
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue)) summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
summaryRow(title: "Taxa de Entrega", value: formatCurrency(deliveryFee)) summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red) summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
Divider() Divider()
@@ -136,6 +150,16 @@ struct CartView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
} }
private var deliveryFeeLabel: String {
if isLoadingDeliveryFee {
return "Calculando..."
}
if let deliveryFee {
return formatCurrency(deliveryFee)
}
return "Indisponível"
}
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View { private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
HStack { HStack {
Text(title) Text(title)
@@ -230,4 +254,88 @@ struct CartView: View {
private func formatCurrency(_ value: Double) -> String { private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
} }
@MainActor
private func refreshDeliveryFee() async {
guard appState.cart.items.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
deliveryFee = nil
selectedCustomerAddress = nil
return
}
isLoadingDeliveryFee = true
defer { isLoadingDeliveryFee = false }
do {
let profileResponse = try await ApiService().profile()
let addresses = profileResponse.result?.addressBook ?? []
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
} else {
selectedCustomerAddress = nil
}
if selectedCustomerAddress == nil {
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if display.isEmpty == false, display != "defina seu endereco" {
selectedCustomerAddress = addresses.first {
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
}
}
}
if selectedCustomerAddress == nil {
selectedCustomerAddress = addresses.first
}
if let selected = selectedCustomerAddress {
appState.address.selectedId = selected.id
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
let payload = ValidateDeliveryAddressPayload(
address: ValidateDeliveryAddressDataPayload(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
lat: payloadLat,
lng: payloadLng
)
)
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
guard validationResponse.error == false,
validationResponse.result?.deliveryAllowed == true,
let fee = validationResponse.result?.deliveryFee else {
deliveryFee = nil
return
}
deliveryFee = fee
} catch {
deliveryFee = nil
}
}
} }

View File

@@ -260,11 +260,23 @@ extension CheckoutView {
SessionStateStore.saveTrackedOrder(orderSnapshot) SessionStateStore.saveTrackedOrder(orderSnapshot)
let orderId = result.id ?? UUID().uuidString let orderId = result.id ?? UUID().uuidString
if useInAppPayment == false { let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
if useInAppPayment == false || isInAppMethod == false {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
return return
} }
if orderSnapshot.isPaymentConfirmed {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
return
}
SessionStateStore.savePendingCartOrderId(orderId)
if effectivePaymentMethod == .creditCard { if effectivePaymentMethod == .creditCard {
cardPaymentContext = CardPaymentContext( cardPaymentContext = CardPaymentContext(
orderId: orderId, orderId: orderId,

View File

@@ -152,12 +152,28 @@ struct CheckoutView: View {
} }
} }
.navigationDestination(item: $pixPaymentContext) { context in .navigationDestination(item: $pixPaymentContext) { context in
PaymentPixView(context: context) { PaymentPixView(
context: context,
onPaymentConfirmed: {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
}
) {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
} }
} }
.navigationDestination(item: $cardPaymentContext) { context in .navigationDestination(item: $cardPaymentContext) { context in
PaymentCardView(context: context) { PaymentCardView(
context: context,
onPaymentConfirmed: {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
}
) {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
} }
} }
@@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable {
struct PaymentPixView: View { struct PaymentPixView: View {
let context: PixPaymentContext let context: PixPaymentContext
var onPaymentConfirmed: (() -> Void)? = nil
var onOpenTracking: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var tracker = OrderRealtimeTracker() @State var tracker = OrderRealtimeTracker()
@State var latestOrder: PublicOrderResult? = nil @State var latestOrder: PublicOrderResult? = nil
@State var hasOpenedTracking = false @State var hasOpenedTracking = false
@State var hasShownPixExpiredSnackbar = false
@State var currentTime = Date()
private var qrImageSource: String? { private var qrImageSource: String? {
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -590,51 +609,51 @@ struct PaymentPixView: View {
Text("AGUARDANDO PAGAMENTO") Text("AGUARDANDO PAGAMENTO")
.font(AppTypography.heading3) .font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 12) .padding(.horizontal, 20)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(AppColors.brandSoft) .background(AppColors.brandSoft)
.clipShape(Capsule()) .clipShape(Capsule())
} }
) )
.frame(maxWidth: .infinity, minHeight: 360) .frame(maxWidth: .infinity, minHeight: 380)
Text("Código PIX")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface) .fill(AppColors.surface)
.frame(height: 20)
.overlay( .overlay(
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Código PIX")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(context.copyPaste) Text(context.copyPaste)
.font(.system(size: 12, weight: .medium, design: .monospaced)) .font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
.lineLimit(3) .lineLimit(0)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 12) .padding(.horizontal, 10)
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
Text("Expira em: \(expirationDate)")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
} }
.padding(.vertical, 14) .padding(.vertical, 14)
) )
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
Text(expirationLabel)
.font(.caption)
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
}
PrimaryButton(title: "Copiar Código PIX") { PrimaryButton(title: "Copiar Código PIX") {
if isPixExpired {
showPixExpiredSnackbar()
return
}
copyToClipboard(context.copyPaste) copyToClipboard(context.copyPaste)
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
} }
.disabled(isPixExpired)
Button("Já realizei o pagamento") { .opacity(isPixExpired ? 0.5 : 1.0)
if latestOrder?.isPaymentConfirmed == true { .padding(.top, 10)
openTrackingOnce()
} else {
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
}
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 8)
} }
.padding(20) .padding(20)
} }
@@ -645,11 +664,22 @@ struct PaymentPixView: View {
tracker.onOrderUpdated = { updated in tracker.onOrderUpdated = { updated in
latestOrder = updated latestOrder = updated
if updated.isPaymentConfirmed { if updated.isPaymentConfirmed {
onPaymentConfirmed?()
openTrackingOnce() openTrackingOnce()
} }
} }
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
} }
.task {
while Task.isCancelled == false {
currentTime = Date()
if isPixExpired {
showPixExpiredSnackbar()
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
.onDisappear { .onDisappear {
tracker.stop() tracker.stop()
} }
@@ -659,7 +689,6 @@ struct PaymentPixView: View {
guard hasOpenedTracking == false else { return } guard hasOpenedTracking == false else { return }
hasOpenedTracking = true hasOpenedTracking = true
onOpenTracking?() onOpenTracking?()
dismiss()
} }
private func copyToClipboard(_ value: String) { private func copyToClipboard(_ value: String) {
@@ -670,10 +699,83 @@ struct PaymentPixView: View {
NSPasteboard.general.setString(value, forType: .string) NSPasteboard.general.setString(value, forType: .string)
#endif #endif
} }
private var parsedExpirationDate: Date? {
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard raw.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: raw) { return date }
iso.formatOptions = [.withInternetDateTime]
if let date = iso.date(from: raw) { return date }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
let formats = [
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm"
]
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: raw) {
return date
}
}
return nil
}
private var isPixExpired: Bool {
guard let parsedExpirationDate else { return false }
return currentTime >= parsedExpirationDate
}
private var expirationLabel: String {
guard let parsedExpirationDate else {
return "Expira em: --"
}
if isPixExpired {
return "Expirado"
}
let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime)))
let day = 24 * 60 * 60
let hour = 60 * 60
if remaining >= day {
let days = remaining / day
return "Expira em: \(days) dia(s)"
}
if remaining >= hour {
let hours = remaining / hour
return "Expira em: \(hours) hora(s)"
}
if remaining >= 60 {
let minutes = remaining / 60
return "Expira em: \(minutes) min"
}
return "Vai expirar em \(remaining) segundos"
}
private func showPixExpiredSnackbar() {
guard hasShownPixExpiredSnackbar == false else { return }
hasShownPixExpiredSnackbar = true
SnackbarCenter.shared.show(
title: "PIX expirou. Gere um novo pedido para continuar.",
style: .warning,
icon: "clock.badge.xmark.fill",
duration: 4.0
)
}
} }
struct PaymentCardView: View { struct PaymentCardView: View {
let context: CardPaymentContext let context: CardPaymentContext
var onPaymentConfirmed: (() -> Void)? = nil
var onOpenTracking: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var cardHolderName = "" @State var cardHolderName = ""
@@ -723,6 +825,7 @@ struct PaymentCardView: View {
PrimaryButton(title: "Salvar e Pagar") { PrimaryButton(title: "Salvar e Pagar") {
if latestOrder?.isPaymentConfirmed == true { if latestOrder?.isPaymentConfirmed == true {
onPaymentConfirmed?()
openTrackingOnce() openTrackingOnce()
} else { } else {
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
@@ -731,6 +834,7 @@ struct PaymentCardView: View {
Button("Apenas Pagar") { Button("Apenas Pagar") {
if latestOrder?.isPaymentConfirmed == true { if latestOrder?.isPaymentConfirmed == true {
onPaymentConfirmed?()
openTrackingOnce() openTrackingOnce()
} else { } else {
SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0) SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0)
@@ -752,6 +856,7 @@ struct PaymentCardView: View {
tracker.onOrderUpdated = { updated in tracker.onOrderUpdated = { updated in
latestOrder = updated latestOrder = updated
if updated.isPaymentConfirmed { if updated.isPaymentConfirmed {
onPaymentConfirmed?()
openTrackingOnce() openTrackingOnce()
} }
} }
@@ -766,7 +871,6 @@ struct PaymentCardView: View {
guard hasOpenedTracking == false else { return } guard hasOpenedTracking == false else { return }
hasOpenedTracking = true hasOpenedTracking = true
onOpenTracking?() onOpenTracking?()
dismiss()
} }
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View { private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {

View File

@@ -20,18 +20,29 @@ extension HomeView {
} }
@MainActor @MainActor
func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async { func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async {
let cacheKey = "public-categories"
if forceRefresh == false,
let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) {
categories = cached
return
}
do { do {
let response = try await ApiService().listPublicCategories() let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh)
if response.error == false, let remote = response.result, remote.isEmpty == false { if response.error == false, let remote = response.result, remote.isEmpty == false {
categories = mapPublicCategories(remote) let mapped = mapPublicCategories(remote)
categories = mapped
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
return return
} }
} catch { } catch {
// Fallback handled below. // Fallback handled below.
} }
categories = buildCategories(from: stores) let fallback = buildCategories(from: stores)
categories = fallback
AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours)
} }
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] { func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {

View File

@@ -83,10 +83,6 @@ struct HomeView: View {
.onChange(of: addressCacheScope) { _, _ in .onChange(of: addressCacheScope) { _, _ in
guard hasRequestedLocation else { return } guard hasRequestedLocation else { return }
Task { Task {
AppContentCache.shared.invalidate(prefix: "stores:")
AppContentCache.shared.invalidate(prefix: "store-info:")
AppContentCache.shared.invalidate(prefix: "store-catalog:")
AppImageCache.shared.invalidateAll()
await bootstrapStoresFlow( await bootstrapStoresFlow(
forceLocationRefresh: true, forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory, category: selectedCategory == "all" ? nil : selectedCategory,
@@ -435,7 +431,7 @@ struct HomeView: View {
isLoadingStores = false isLoadingStores = false
stores = cachedStores stores = cachedStores
if refreshCategories || (category == nil && categories.count <= 1) { if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: cachedStores) await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
if categories.contains(where: { $0.id == selectedCategory }) == false { if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all" selectedCategory = "all"
} }
@@ -457,9 +453,9 @@ struct HomeView: View {
} }
let results = response.result ?? [] let results = response.result ?? []
stores = results stores = results
AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180) AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
if refreshCategories || (category == nil && categories.count <= 1) { if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results) await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
if categories.contains(where: { $0.id == selectedCategory }) == false { if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all" selectedCategory = "all"
} }

View File

@@ -1,5 +1,14 @@
import SwiftUI import SwiftUI
private struct TrackingStep: Identifiable {
let id: String
let title: String
let subtitle: String
let time: String?
let isCompleted: Bool
let isActive: Bool
}
struct OrderTrackingView: View { struct OrderTrackingView: View {
let orderId: String let orderId: String
let initialShortId: String? let initialShortId: String?
@@ -8,38 +17,30 @@ struct OrderTrackingView: View {
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil @State var order: PublicOrderResult? = nil
@State var tracker = OrderRealtimeTracker() @State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 16) { VStack(spacing: 16) {
headerCard topHeader
orderTitleSection
if isLoading { statusBanner
ProgressView() timelineSection
.padding(.top, 20) placeholderCard
} contactButton
if let errorMessage, errorMessage.isEmpty == false {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(Color.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
if let order {
timelineCard(order)
}
footerPlaceholderCard
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 14) .padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 24) .padding(.bottom, UIDevice.bottomNotch + 45)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)") .navigationTitle("Pedido \(displayOrderTitle)")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
Text(cancellationReasonText)
}
.task { .task {
await loadInitialOrder() await loadInitialOrder()
tracker.onOrderUpdated = { updated in tracker.onOrderUpdated = { updated in
@@ -54,67 +55,103 @@ struct OrderTrackingView: View {
} }
} }
private var displayOrderTitle: String { private var topHeader: some View {
if let short = order?.shortId, short.isEmpty == false { return "#\(short)" } HStack(spacing: 10) {
if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" } Circle()
return "#\(orderId.prefix(6))" .fill(Color.white.opacity(0.2))
.frame(width: 28, height: 28)
.overlay(
Circle()
.fill(Color.white.opacity(0.35))
.frame(width: 14, height: 14)
)
Text("Acompanhamento em tempo real")
.font(AppTypography.heading2)
.foregroundStyle(Color.white)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
} }
private var headerCard: some View { private var orderTitleSection: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(statusTitle) Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)") Text("Pedido #\(displayOrderTitle)")
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
if let order, order.isInDeliveryRoute, let otp = order.displayOtpCode {
Text("Seu código do pedido: \(otp) - Informe esse número ao motoboy")
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.leading)
.padding(.top, 2)
}
HStack {
Spacer()
Text(statusPill)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
private func timelineCard(_ order: PublicOrderResult) -> some View { @ViewBuilder
let events: [PublicOrderTimelineEvent] = { private var statusBanner: some View {
if order.timeline.isEmpty == false { return order.timeline } if let errorMessage, errorMessage.isEmpty == false {
return [ statusBadge(
PublicOrderTimelineEvent( title: errorMessage,
status: order.status ?? order.paymentStatus ?? "PENDING", fg: Color.red,
message: nil, bg: Color.red.opacity(0.12),
time: order.updatedAt ?? order.createdAt icon: "xmark.octagon.fill"
)
} else if isLoading {
HStack(spacing: 10) {
ProgressView()
Text("Atualizando status do pedido...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if isCanceled {
VStack(alignment: .leading, spacing: 10) {
statusBadge(
title: "Pedido cancelado",
fg: Color.red,
bg: Color.red.opacity(0.12),
icon: "xmark.circle.fill"
) )
] if cancellationReasonText.isEmpty == false {
}() Button("Ver motivo do cancelamento") {
showCancellationReason = true
}
.font(AppTypography.heading3)
.foregroundStyle(Color.red)
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else if isWaitingPayment {
statusBadge(
title: "Aguardando pagamento",
fg: Color(hex: "#A16207"),
bg: Color(hex: "#FDE68A").opacity(0.35),
icon: "clock.fill"
)
} else {
statusBadge(
title: successBannerTitle,
fg: AppColors.primary,
bg: AppColors.brandSoft,
icon: "checkmark.circle.fill"
)
}
}
return VStack(alignment: .leading, spacing: 12) { private var timelineSection: some View {
Text("Acompanhamento") VStack(alignment: .leading, spacing: 14) {
Text("Progresso do Pedido")
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
VStack(alignment: .leading, spacing: 0) { if let order {
ForEach(Array(events.enumerated()), id: \.offset) { index, event in VStack(alignment: .leading, spacing: 0) {
timelineRow(event: event, isLast: index == events.count - 1) ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in
timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1)
}
} }
} }
} }
@@ -124,112 +161,473 @@ struct OrderTrackingView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View { private var placeholderCard: some View {
VStack(spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 28, height: 28)
.overlay(
Image(systemName: summaryStatusIcon)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.primary)
)
Text(summaryStatusTitle)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
}
Group {
if hasTrackingImage {
Image(trackingImageName)
.renderingMode(.original)
.resizable()
.scaledToFit()
} else if hasPlaceholderProductImage {
Image("placeholder-product")
.renderingMode(.original)
.resizable()
.scaledToFit()
} else {
ZStack {
Color.black.opacity(0.08)
Image(systemName: "shippingbox.fill")
.font(.system(size: 52, weight: .bold))
.foregroundStyle(AppColors.primary)
}
}
}
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var contactButton: some View {
Button("CONTATO") {}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 14, weight: .bold))
Text(title)
.font(AppTypography.heading3)
}
.foregroundStyle(fg)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(bg)
.clipShape(Capsule())
.frame(maxWidth: .infinity, alignment: .leading)
}
private func timelineRow(step: TrackingStep, isLast: Bool) -> some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
VStack(spacing: 0) { VStack(spacing: 0) {
Circle() Circle()
.fill(AppColors.tertiary) .fill(stepDotColor(step))
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.overlay( .overlay(
Image(systemName: "checkmark") Group {
.font(.system(size: 11, weight: .bold)) if step.isCompleted {
.foregroundStyle(.white) Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
} else if step.isActive {
Circle()
.fill(.white)
.frame(width: 8, height: 8)
} else {
Circle()
.stroke(Color(hex: "#C5CBD4"), lineWidth: 2)
.frame(width: 8, height: 8)
}
}
) )
if isLast == false { if isLast == false {
Rectangle() Rectangle()
.fill(AppColors.tertiary.opacity(0.45)) .fill(stepLineColor(step))
.frame(width: 2, height: 30) .frame(width: 2, height: 36)
} }
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado")) Text(step.title)
.font(AppTypography.heading3) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(stepTitleColor(step))
Text(formatTime(event.time) ?? "")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
if step.subtitle.isEmpty == false {
Text(step.subtitle)
.font(AppTypography.body)
.foregroundStyle(stepSubtitleColor(step))
}
if let time = step.time, time.isEmpty == false {
Text(time)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer() Spacer()
} }
} }
private var footerPlaceholderCard: some View { private func stepDotColor(_ step: TrackingStep) -> Color {
VStack(spacing: 10) { if isCanceled {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) return Color(hex: "#C5CBD4")
.fill(Color.black.opacity(0.9))
.frame(height: 180)
.overlay(
Image(systemName: "shippingbox.fill")
.font(.system(size: 56, weight: .bold))
.foregroundStyle(AppColors.tertiary)
)
Text("Acompanhe seu pedido em tempo real")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} }
.frame(maxWidth: .infinity) if isWaitingPayment && step.id == "paid" {
.padding(16) return Color(hex: "#F59E0B")
.background(AppColors.surface) }
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#E5E7EB")
} }
private var statusTitle: String { private func stepLineColor(_ step: TrackingStep) -> Color {
humanReadableStatus(order?.status ?? "Aguardando") if isCanceled { return Color(hex: "#E5E7EB") }
if step.isCompleted || step.isActive {
return AppColors.primary.opacity(0.85)
}
return Color(hex: "#E5E7EB")
} }
private var statusPill: String { private func stepTitleColor(_ step: TrackingStep) -> Color {
let value = order?.paymentStatus ?? order?.status ?? "PENDING" if isCanceled { return Color(hex: "#9CA3AF") }
return humanReadableStatus(value) if step.isCompleted || step.isActive {
return AppColors.textPrimary
}
return Color(hex: "#9CA3AF")
} }
private func humanReadableStatus(_ raw: String) -> String { private func stepSubtitleColor(_ step: TrackingStep) -> Color {
let normalized = raw.uppercased() if isCanceled { return Color(hex: "#9CA3AF") }
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { if isWaitingPayment && step.id == "paid" {
return "Aguardando pagamento" return Color(hex: "#A16207")
} }
if normalized.contains("CONFIRMED") { if step.isCompleted || step.isActive {
return "Pagamento confirmado" return AppColors.primary
} }
if normalized.contains("PREPAR") { return Color(hex: "#9CA3AF")
return "Em preparo" }
private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] {
let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP")
let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [
("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]),
("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]),
("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]),
("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]),
("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"])
]
let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps
let currentIndex = currentStepIndex(isPickup: isPickup)
return stepsBase.enumerated().map { index, step in
let event = timelineEvent(for: order, statuses: step.statuses)
let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex)
let isActive = event?.active ?? (isCanceled == false && index == currentIndex)
return TrackingStep(
id: step.id,
title: step.title,
subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label),
time: formatTime(event?.time),
isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)),
isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false
)
} }
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") { }
return "Em rota de entrega"
private func currentStepIndex(isPickup: Bool) -> Int {
let normalizedStatus = normalized(order?.status)
if isCanceled {
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 }
if normalizedStatus.contains("READY") { return 2 }
if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 }
return 0
} }
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
if isWaitingPayment {
return 0
}
if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) {
return timelineIndex
}
if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") {
return isPickup ? 3 : 4
}
if isPickup {
if normalizedStatus.contains("READY") { return 2 }
if normalizedStatus.contains("PREPAR") { return 1 }
return 0
}
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") {
return 3
}
if normalizedStatus.contains("READY") {
return 2
}
if normalizedStatus.contains("PREPAR") {
return 1
}
return 0
}
private func timelineProgressStepIndex(isPickup: Bool) -> Int? {
guard let order else { return nil }
let stepStatuses: [[String]] = isPickup
? [
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
["PREPARING"],
["READY"],
["COMPLETED", "DELIVERED"]
]
: [
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
["PREPARING"],
["READY"],
["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"],
["COMPLETED", "DELIVERED"]
]
var strongestIndex: Int? = nil
var fallbackIndex: Int? = nil
for (index, statuses) in stepStatuses.enumerated() {
let statusSet = Set(statuses.map(normalized))
let events = order.timeline.filter { event in
statusSet.contains(normalized(event.status))
}
guard events.isEmpty == false else { continue }
fallbackIndex = index
if events.contains(where: { $0.active == true || $0.completed == true }) {
strongestIndex = index
}
}
return strongestIndex ?? fallbackIndex
}
private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool {
let terminalIndex = isPickup ? 3 : 4
return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex
}
private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? {
let statusSet = Set(statuses.map(normalized))
return order.timeline.first(where: { statusSet.contains(normalized($0.status)) })
}
private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String {
if stepId == "delivering", customerOtpCode != nil {
return customerOtpSubtitle
}
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
return label
}
return fallback
}
private var customerOtpSubtitle: String {
if let otp = customerOtpCode {
return "Código para o entregador: \(otp)"
}
return "Aguardando saída para entrega"
}
private var customerOtpCode: String? {
let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let digits = raw.filter(\.isNumber)
if digits.count == 4 {
return digits
}
return nil
}
private var displayOrderTitle: String {
if let short = order?.shortId, short.isEmpty == false { return short }
if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue }
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
return String(orderId.prefix(6))
}
private var isCanceled: Bool {
normalized(order?.status).contains("CANCEL")
}
private var isWaitingPayment: Bool {
let paymentStatus = normalized(order?.paymentStatus)
if isOnlinePaymentMethod == false {
return false
}
if paymentStatus == "PENDING" {
return true
}
return order?.isPaymentConfirmed == false
}
private var isOnlinePaymentMethod: Bool {
let code = normalized(order?.paymentMethodCode)
if code == "PIX" || code == "CREDIT_CARD" {
return true
}
return false
}
private var successBannerTitle: String {
if isCompletedOrder {
if isPickupOrder {
return "Pedido retirado"
}
return "Pedido entregue" return "Pedido entregue"
} }
if normalized.contains("CANCEL") { if isOnlinePaymentMethod {
return "Pedido cancelado" return "Pagamento confirmado"
} }
return raw.capitalized return "Pedido confirmado"
} }
private func formatTime(_ isoValue: String?) -> String? { private var summaryStatusTitle: String {
guard let isoValue, isoValue.isEmpty == false else { return nil } if isCanceled {
return "Seu pedido foi cancelado"
}
if isWaitingPayment {
return "Aguardando confirmação de pagamento"
}
if isCompletedOrder {
return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue"
}
return "Seu pedido está em andamento"
}
private var summaryStatusIcon: String {
if isCanceled {
return "xmark"
}
if isWaitingPayment {
return "clock.fill"
}
return "checkmark"
}
private var isPickupOrder: Bool {
normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
}
private var isCompletedOrder: Bool {
let status = normalized(order?.status)
if status.contains("COMPLETED") || status.contains("DELIVERED") {
return true
}
let stepIndex = currentStepIndex(isPickup: isPickupOrder)
let terminalIndex = isPickupOrder ? 3 : 4
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
}
private var cancellationReasonText: String {
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "Sem detalhe informado." : value
}
private var hasTrackingImage: Bool {
imageResourceExists(trackingImageName)
}
private var hasPlaceholderProductImage: Bool {
imageResourceExists("placeholder-product")
}
private func imageResourceExists(_ name: String) -> Bool {
let exts = ["png", "jpg", "jpeg", "webp"]
for ext in exts {
if Bundle.main.url(forResource: name, withExtension: ext) != nil {
return true
}
if Bundle.module.url(forResource: name, withExtension: ext) != nil {
return true
}
}
return false
}
private var trackingImageName: String {
let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
let stepIndex = currentStepIndex(isPickup: isPickup)
if isCanceled {
return "tracking-canceled"
}
if isWaitingPayment {
return "tracking-pending"
}
switch stepIndex {
case 0:
return "tracking-pending"
case 1:
return "tracking-preparing"
case 2:
return isPickup ? "tracking-ready" : "tracking-preparing"
case 3:
return "tracking-delivering"
default:
return "tracking-completed"
}
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.uppercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func formatTime(_ rawValue: String?) -> String? {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty { return nil }
if value.contains("T"), let isoTime = formatISOTime(value) {
return isoTime
}
if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil {
return String(value.prefix(5))
}
return value
}
private func formatISOTime(_ value: String) -> String? {
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: value)
var date: Date? = iso.date(from: isoValue)
if date == nil { if date == nil {
iso.formatOptions = [.withInternetDateTime] iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue) date = iso.date(from: value)
} }
if date == nil {
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "pt_BR")
fallback.dateFormat = "yyyy-MM-dd HH:mm:ss"
date = fallback.date(from: isoValue)
}
guard let date else { return nil } guard let date else { return nil }
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR") formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "HH:mm" formatter.dateFormat = "HH:mm"

View File

@@ -4,6 +4,7 @@ struct OrdersView: View {
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@State var orders: [AppOrderSummary] = [] @State var orders: [AppOrderSummary] = []
@State var hasLoadedOnce = false
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -31,8 +32,14 @@ struct OrdersView: View {
.padding(.top, 24) .padding(.top, 24)
} else { } else {
ForEach(orders) { order in ForEach(orders) { order in
let trackingId = trackingOrderId(for: order)
NavigationLink { NavigationLink {
OrderTrackingView(orderId: order.id, initialShortId: order.shortId) OrderEntryDestinationView(
orderId: trackingId,
initialShortId: order.shortId,
fallbackPaymentMethod: order.paymentMethod,
fallbackTotal: order.total
)
} label: { } label: {
orderRow(order) orderRow(order)
} }
@@ -47,7 +54,10 @@ struct OrdersView: View {
.navigationTitle("Meus Pedidos") .navigationTitle("Meus Pedidos")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
await loadOrders() await loadOrdersIfNeeded()
}
.refreshable {
await loadOrders(force: true)
} }
} }
@@ -66,7 +76,7 @@ struct OrdersView: View {
.font(AppTypography.heading3) .font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente")) Text(humanReadableStatus(for: order))
.font(AppTypography.caption) .font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
@@ -89,11 +99,41 @@ struct OrdersView: View {
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
private func humanReadableStatus(_ raw: String) -> String { private func humanReadableStatus(for order: AppOrderSummary) -> String {
let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if explicitLabel.isEmpty == false {
return explicitLabel
}
let detailed = (order.statusDetailed ?? "").uppercased()
switch detailed {
case "PENDING_PAYMENT":
return "Aguardando pagamento"
case "PENDING_PREPARATION":
return "Pendente de preparo"
case "PREPARING":
return "Em preparo"
case "PENDING_DELIVERY":
return "Pendente de entrega"
case "READY_FOR_PICKUP":
return "Pronto para retirada"
case "IN_DELIVERY":
return "Em rota"
case "COMPLETED":
return "Entregue"
case "CANCELED":
return "Cancelado"
default:
break
}
let raw = order.status ?? order.paymentStatus ?? "Pendente"
let normalized = raw.uppercased() let normalized = raw.uppercased()
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { return "Aguardando pagamento" } if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" }
if normalized.contains("CONFIRMED") { return "Confirmado" } if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" }
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("ROTA") { return "Em rota" } if normalized.contains("PREPARING") { return "Em preparo" }
if normalized.contains("READY") { return "Pendente de entrega" }
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("DELIVERING") || normalized.contains("ROTA") { return "Em rota" }
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" } if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" }
if normalized.contains("CANCEL") { return "Cancelado" } if normalized.contains("CANCEL") { return "Cancelado" }
return raw.capitalized return raw.capitalized
@@ -120,56 +160,192 @@ struct OrdersView: View {
return formatter.string(from: date) return formatter.string(from: date)
} }
private func trackingOrderId(for order: AppOrderSummary) -> String {
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if orderCandidate.isEmpty == false {
return orderCandidate
}
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if candidate.isEmpty == false {
return candidate
}
return order.id
}
@MainActor @MainActor
private func loadOrders() async { private func loadOrdersIfNeeded() async {
guard hasLoadedOnce == false else { return }
await loadOrders(force: false)
}
@MainActor
private func loadOrders(force: Bool) async {
if isLoading { return }
if force == false, hasLoadedOnce { return }
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
let previousOrders = orders
var trackedMapped: [AppOrderSummary] = []
let cachedTracked = SessionStateStore.loadTrackedOrders() let cachedTracked = SessionStateStore.loadTrackedOrders()
if cachedTracked.isEmpty == false { if cachedTracked.isEmpty == false {
let mapped = cachedTracked.map { trackedMapped = cachedTracked.map {
AppOrderSummary.fromTracked($0) AppOrderSummary.fromTracked($0)
} }
orders = mergeOrders(apiOrders: orders, trackedOrders: mapped) // Use tracked-only list as bootstrap data only on first load.
if hasLoadedOnce == false, previousOrders.isEmpty {
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
}
} }
do { do {
let response = try await ApiService().listOrders() let response = try await ApiService().listOrders(forceRefresh: force)
if response.error { if response.error {
if previousOrders.isEmpty == false {
orders = previousOrders
}
errorMessage = response.message ?? "Não foi possível carregar os pedidos." errorMessage = response.message ?? "Não foi possível carregar os pedidos."
} else { } else {
let remote = response.result ?? [] let remote = response.result ?? []
orders = mergeOrders(apiOrders: remote, trackedOrders: orders) orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
} }
} catch { } catch {
if isCancelledRequest(error) {
if previousOrders.isEmpty == false {
orders = previousOrders
}
isLoading = false
return
}
if orders.isEmpty { if orders.isEmpty {
errorMessage = "Não foi possível carregar os pedidos." errorMessage = "Não foi possível carregar os pedidos."
} }
} }
isLoading = false isLoading = false
hasLoadedOnce = true
} }
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] { private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
var map: [String: AppOrderSummary] = [:] var map: [String: AppOrderSummary] = [:]
for item in trackedOrders { map[item.id] = item } var sourceRank: [String: Int] = [:]
for item in apiOrders { map[item.id] = item }
for (index, item) in trackedOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
if sourceRank[key] == nil {
sourceRank[key] = 10_000 + index
}
}
// API order is authoritative for fallback ordering (usually newest first).
for (index, item) in apiOrders.enumerated() {
let key = identityKey(for: item)
map[key] = item
sourceRank[key] = index
}
return map.values.sorted { lhs, rhs in return map.values.sorted { lhs, rhs in
let left = lhs.updatedAt ?? lhs.createdAt ?? "" let leftDate = orderDateSortValue(lhs)
let right = rhs.updatedAt ?? rhs.createdAt ?? "" let rightDate = orderDateSortValue(rhs)
return left > right if leftDate != rightDate {
return leftDate > rightDate
}
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
if leftRank != rightRank {
return leftRank < rightRank
}
let leftNumericId = Int(lhs.id)
let rightNumericId = Int(rhs.id)
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
return leftNumericId > rightNumericId
}
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
} }
} }
private func identityKey(for order: AppOrderSummary) -> String {
let raw = trackingOrderId(for: order)
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
parseDateForSort(order.updatedAt)
?? parseDateForSort(order.createdAt)
?? .distantPast
}
private func parseDateForSort(_ rawValue: String?) -> Date? {
guard let rawValue else { return nil }
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: value) { return date }
iso.formatOptions = [.withInternetDateTime]
if let date = iso.date(from: value) { return date }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
let formats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd HH:mm:ss Z",
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy"
]
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: value) {
return date
}
}
return nil
}
private func isCancelledRequest(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
if let networkError = error as? NetworkError,
case .transportError(let message) = networkError {
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.contains("cancel")
}
if let networkError = error as? NetworkError,
case .cancelled = networkError {
return true
}
return error.localizedDescription.lowercased().contains("cancel")
}
} }
extension AppOrderSummary { extension AppOrderSummary {
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary { static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
AppOrderSummary( AppOrderSummary(
id: tracked.id, id: tracked.id,
orderId: tracked.realId ?? tracked.id,
realId: tracked.realId,
shortId: tracked.shortId, shortId: tracked.shortId,
total: tracked.total, total: tracked.total,
status: tracked.status, status: tracked.status,
statusDetailed: nil,
statusLabel: nil,
nextAction: nil,
paymentStatus: tracked.paymentStatus, paymentStatus: tracked.paymentStatus,
paymentMethod: tracked.paymentMethod, paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType, deliveryType: tracked.deliveryType,
@@ -181,9 +357,14 @@ extension AppOrderSummary {
init( init(
id: String, id: String,
orderId: String?,
realId: String?,
shortId: String?, shortId: String?,
total: Double?, total: Double?,
status: String?, status: String?,
statusDetailed: String?,
statusLabel: String?,
nextAction: String?,
paymentStatus: String?, paymentStatus: String?,
paymentMethod: String?, paymentMethod: String?,
deliveryType: String?, deliveryType: String?,
@@ -192,9 +373,14 @@ extension AppOrderSummary {
updatedAt: String? updatedAt: String?
) { ) {
self.id = id self.id = id
self.orderId = orderId
self.realId = realId
self.shortId = shortId self.shortId = shortId
self.total = total self.total = total
self.status = status self.status = status
self.statusDetailed = statusDetailed
self.statusLabel = statusLabel
self.nextAction = nextAction
self.paymentStatus = paymentStatus self.paymentStatus = paymentStatus
self.paymentMethod = paymentMethod self.paymentMethod = paymentMethod
self.deliveryType = deliveryType self.deliveryType = deliveryType
@@ -203,3 +389,150 @@ extension AppOrderSummary {
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
} }
struct OrderEntryDestinationView: View {
let orderId: String
let initialShortId: String?
let fallbackPaymentMethod: String?
let fallbackTotal: Double?
@State var isResolvingRoute = true
@State var didResolve = false
@State var pixContext: PixPaymentContext? = nil
@State var cardContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
var body: some View {
Group {
if isResolvingRoute {
VStack(spacing: 10) {
ProgressView()
Text("Carregando pedido...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
} else if let pixContext {
PaymentPixView(
context: pixContext,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}
)
} else if let cardContext {
PaymentCardView(
context: cardContext,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
}
)
} else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
}
}
.navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
}
.task {
guard didResolve == false else { return }
didResolve = true
await resolveRoute()
}
}
@MainActor
func resolveRoute() async {
defer { isResolvingRoute = false }
let order = await fetchOrderForRouting()
guard let order else { return }
guard order.isPaymentConfirmed == false else { return }
guard isOnlinePaymentMethod(order) else { return }
let normalizedMethod = normalizePaymentMethod(order)
let normalizedStatus = normalize(order.paymentStatus)
if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false {
return
}
if normalizedMethod.contains("PIX") {
let pixFromPayment = order.payment?.pix
let pixFromPayload = order.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
)
return
}
if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") {
cardContext = CardPaymentContext(
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
}
}
@MainActor
func fetchOrderForRouting() async -> PublicOrderResult? {
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result {
SessionStateStore.saveTrackedOrder(result)
return result
}
} catch {
// Fallback to local cache when remote call fails.
}
return SessionStateStore.loadTrackedOrder(orderId: orderId)
}
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
let method = normalizePaymentMethod(order)
return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD")
}
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
let first = normalize(order.paymentMethodCode)
if first.isEmpty == false {
return first
}
let second = normalize(order.paymentMethod)
if second.isEmpty == false {
return second
}
return normalize(fallbackPaymentMethod)
}
func normalize(_ value: String?) -> String {
(value ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
}
}

View File

@@ -124,9 +124,9 @@ extension StoreDetailView {
categories = catalogResponse.result ?? [] categories = catalogResponse.result ?? []
selectedCategoryId = categories.first?.id selectedCategoryId = categories.first?.id
if let info = infoResponse.result { if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300) AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
} }
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300) AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
isLoading = false isLoading = false
} catch { } catch {
isLoading = false isLoading = false

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB