payment and list
12
pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-canceled.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-canceled.imageset/tracking-canceled.png
vendored
Normal file
|
After Width: | Height: | Size: 406 KiB |
12
pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-completed.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-completed.imageset/tracking-completed.png
vendored
Normal file
|
After Width: | Height: | Size: 924 KiB |
12
pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-delivering.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-delivering.imageset/tracking-delivering.png
vendored
Normal file
|
After Width: | Height: | Size: 951 KiB |
12
pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-pending.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-pending.imageset/tracking-pending.png
vendored
Normal file
|
After Width: | Height: | Size: 940 KiB |
12
pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-preparing.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-preparing.imageset/tracking-preparing.png
vendored
Normal file
|
After Width: | Height: | Size: 926 KiB |
12
pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "tracking-ready.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/tracking-ready.imageset/tracking-ready.png
vendored
Normal file
|
After Width: | Height: | Size: 799 KiB |
@@ -15,7 +15,7 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
||||
|
||||
init(
|
||||
imageURL: String?,
|
||||
ttl: TimeInterval = 6 * 60 * 60,
|
||||
ttl: TimeInterval = AppCacheTTL.twoHours,
|
||||
@ViewBuilder placeholder: () -> Placeholder
|
||||
) {
|
||||
self.imageURL = imageURL
|
||||
@@ -100,7 +100,18 @@ final class CachedRemoteImageLoader: ObservableObject {
|
||||
guard let normalized, normalized.isEmpty == false else { return }
|
||||
|
||||
#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) {
|
||||
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
|
||||
#if canImport(UIKit)
|
||||
uiImage = image
|
||||
#elseif canImport(AppKit)
|
||||
@@ -134,6 +145,12 @@ final class CachedRemoteImageLoader: ObservableObject {
|
||||
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)
|
||||
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
|
||||
let lower = source.lowercased()
|
||||
|
||||
@@ -52,6 +52,9 @@ struct ContentView: View {
|
||||
.onChange(of: appState.address.longitude) { _, _ in
|
||||
dismissAddressPickerIfAddressExists()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
|
||||
appState.cart = CartState()
|
||||
}
|
||||
.task(id: root) {
|
||||
await bootstrapSessionStateIfNeeded()
|
||||
}
|
||||
@@ -169,6 +172,7 @@ struct ContentView: View {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
SessionStateStore.clearTrackedOrders()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
isBootstrappingSession = false
|
||||
|
||||
@@ -78,10 +78,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Acompanhamento" : {
|
||||
|
||||
},
|
||||
"Acompanhe seu pedido em tempo real" : {
|
||||
"Acompanhamento em tempo real" : {
|
||||
|
||||
},
|
||||
"Add" : {
|
||||
@@ -179,6 +176,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Atualizando status do pedido..." : {
|
||||
|
||||
},
|
||||
"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.",
|
||||
@@ -233,6 +233,9 @@
|
||||
"Cardápio indisponível no momento." : {
|
||||
"comment" : "A message displayed when a store's menu is unavailable.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Carregando pedido..." : {
|
||||
|
||||
},
|
||||
"Carregando sua sessão..." : {
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Colar código" : {
|
||||
"comment" : "A button that allows the user to paste their OTP code directly into the field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Concluir" : {
|
||||
"comment" : "The text for a button that confirms and closes a sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -262,6 +269,10 @@
|
||||
"comment" : "A button label that translates to \"Confirm and Pay\".",
|
||||
"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..." : {
|
||||
"comment" : "A placeholder text describing the content of the privacy policy.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -397,10 +408,6 @@
|
||||
"comment" : "A button label that translates to \"Delete\".",
|
||||
"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" : {
|
||||
"comment" : "Item editor title label for marking the item as a favorite",
|
||||
"extractionState" : "stale",
|
||||
@@ -525,10 +532,6 @@
|
||||
"comment" : "A button label that translates to \"Go to Payment\" in English.",
|
||||
"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?" : {
|
||||
"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
|
||||
@@ -592,6 +595,9 @@
|
||||
"Monte sua pizza" : {
|
||||
"comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Motivo do cancelamento" : {
|
||||
|
||||
},
|
||||
"Name" : {
|
||||
"comment" : "Placeholder title for the Name field in a form",
|
||||
@@ -707,9 +713,6 @@
|
||||
},
|
||||
"Pedido %@" : {
|
||||
|
||||
},
|
||||
"Pedido ID #%@" : {
|
||||
|
||||
},
|
||||
"PediFoods" : {
|
||||
"comment" : "The name of the app.",
|
||||
@@ -772,6 +775,9 @@
|
||||
"Privacidade" : {
|
||||
"comment" : "The title of the privacy policy screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Progresso do Pedido" : {
|
||||
|
||||
},
|
||||
"Receber Código" : {
|
||||
"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?" : {
|
||||
"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
|
||||
},
|
||||
"Seu código do pedido: %@ - Informe esse número ao motoboy" : {
|
||||
|
||||
},
|
||||
"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",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Seu pedido está em andamento" : {
|
||||
|
||||
},
|
||||
"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.",
|
||||
@@ -982,6 +988,9 @@
|
||||
"Trocar de loja?" : {
|
||||
"comment" : "A title for an alert that prompts the user to switch stores.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ver motivo do cancelamento" : {
|
||||
|
||||
},
|
||||
"Ver Perfil" : {
|
||||
"comment" : "A button label that translates to \"View Profile\" in English.",
|
||||
|
||||
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-canceled.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-completed.png
Normal file
|
After Width: | Height: | Size: 924 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-delivering.png
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-pending.png
Normal file
|
After Width: | Height: | Size: 940 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-preparing.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/tracking-ready.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
@@ -13,6 +13,7 @@ enum NetworkError: Error, LocalizedError {
|
||||
case unauthorized(String?)
|
||||
case decodeError(String?)
|
||||
case rateLimited(Int?)
|
||||
case cancelled
|
||||
case transportError(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."
|
||||
case .cancelled:
|
||||
return "Requisicao cancelada"
|
||||
case .transportError(let message):
|
||||
return "Erro de rede: \(message)"
|
||||
}
|
||||
@@ -82,7 +85,11 @@ final class ApiClient {
|
||||
// NOTE:
|
||||
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
|
||||
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
|
||||
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) {
|
||||
print("[ApiClient] /api/customer/login body: \(bodyText)")
|
||||
}
|
||||
@@ -161,7 +168,6 @@ private extension ApiClient {
|
||||
}
|
||||
|
||||
func mapError(_ error: Error) -> NetworkError {
|
||||
printError(title: "httpReqError", msg: error.localizedDescription)
|
||||
if let network = error as? NetworkError {
|
||||
return network
|
||||
}
|
||||
@@ -176,6 +182,12 @@ private extension ApiClient {
|
||||
return .unauthorized(payload?.message ?? apiMessage)
|
||||
}
|
||||
|
||||
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
|
||||
return .cancelled
|
||||
}
|
||||
|
||||
printError(title: "httpReqError", msg: error.localizedDescription)
|
||||
|
||||
switch nsError.code {
|
||||
case 401, 403:
|
||||
return .unauthorized(apiMessage)
|
||||
@@ -209,6 +221,8 @@ private extension ApiClient {
|
||||
while attempt <= maxAttempts {
|
||||
do {
|
||||
return try await perform(urlRequest, as: T.self)
|
||||
} catch is CancellationError {
|
||||
throw NetworkError.cancelled
|
||||
} catch let error as NetworkError {
|
||||
guard shouldRetry(error), attempt < maxAttempts else {
|
||||
throw error
|
||||
@@ -230,6 +244,9 @@ private extension ApiClient {
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||
throw NetworkError.cancelled
|
||||
}
|
||||
throw NetworkError.transportError(error.localizedDescription)
|
||||
}
|
||||
|
||||
@@ -283,7 +300,7 @@ private extension ApiClient {
|
||||
return true
|
||||
case .httpError(let statusCode, _):
|
||||
return statusCode >= 500
|
||||
case .invalidURL, .invalidResponse, .decodeError, .unauthorized:
|
||||
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ struct CreateOrderResult: Decodable {
|
||||
let shortId: String?
|
||||
let status: String?
|
||||
let paymentStatus: String?
|
||||
let paymentConfirmed: Bool?
|
||||
let paymentMethod: String?
|
||||
let paymentPayload: CreateOrderPaymentPayload?
|
||||
let payment: CreateOrderPaymentInfo?
|
||||
@@ -55,6 +56,7 @@ struct CreateOrderResult: Decodable {
|
||||
case shortId
|
||||
case status
|
||||
case paymentStatus
|
||||
case paymentConfirmed
|
||||
case paymentMethod
|
||||
case paymentPayload
|
||||
case payment
|
||||
@@ -62,11 +64,12 @@ struct CreateOrderResult: Decodable {
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try? container.decode(String.self, forKey: .id)
|
||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
||||
status = try? container.decode(String.self, forKey: .status)
|
||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
||||
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
|
||||
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
|
||||
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
|
||||
|
||||
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 status: String?
|
||||
let pix: CreateOrderPaymentPayload?
|
||||
}
|
||||
|
||||
struct CreateOrderPaymentPayload: Decodable {
|
||||
struct CreateOrderPaymentPayload: Codable {
|
||||
let copyPaste: String?
|
||||
let qrCodeImage: String?
|
||||
let expirationDate: String?
|
||||
@@ -116,6 +119,13 @@ struct CreateOrderPaymentPayload: Decodable {
|
||||
?? (try? container.decode(String.self, forKey: .encodedImage))
|
||||
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 {
|
||||
|
||||
@@ -2,9 +2,14 @@ import Foundation
|
||||
|
||||
struct AppOrderSummary: Decodable, Identifiable {
|
||||
let id: String
|
||||
let orderId: String?
|
||||
let realId: String?
|
||||
let shortId: String?
|
||||
let total: Double?
|
||||
let status: String?
|
||||
let statusDetailed: String?
|
||||
let statusLabel: String?
|
||||
let nextAction: String?
|
||||
let paymentStatus: String?
|
||||
let paymentMethod: String?
|
||||
let deliveryType: String?
|
||||
@@ -14,39 +19,57 @@ struct AppOrderSummary: Decodable, Identifiable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderId
|
||||
case realId
|
||||
case shortId
|
||||
case total
|
||||
case status
|
||||
case statusDetailed
|
||||
case statusLabel
|
||||
case nextAction
|
||||
case paymentStatus
|
||||
case paymentMethod
|
||||
case deliveryType
|
||||
case storeName
|
||||
case date
|
||||
case createdAt
|
||||
case updatedAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
||||
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
|
||||
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])
|
||||
status = try? container.decode(String.self, forKey: .status)
|
||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
||||
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
|
||||
storeName = try? container.decode(String.self, forKey: .storeName)
|
||||
createdAt = try? container.decode(String.self, forKey: .createdAt)
|
||||
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
|
||||
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||
statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
|
||||
statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
|
||||
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
|
||||
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||
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 {
|
||||
let id: String
|
||||
let shortId: String?
|
||||
let realId: String?
|
||||
let status: String?
|
||||
let paymentStatus: String?
|
||||
let paymentConfirmed: Bool?
|
||||
let paymentMethod: String?
|
||||
let paymentMethodCode: String?
|
||||
let paymentPayload: CreateOrderPaymentPayload?
|
||||
let payment: CreateOrderPaymentInfo?
|
||||
let deliveryType: String?
|
||||
let deliveryTypeLabel: String?
|
||||
let total: Double?
|
||||
let storeName: String?
|
||||
let createdAt: String?
|
||||
@@ -54,16 +77,23 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
let otp: String?
|
||||
let customerOtp: String?
|
||||
let confirmOtp: String?
|
||||
let cancellationReason: String?
|
||||
let items: [PublicOrderItem]
|
||||
let timeline: [PublicOrderTimelineEvent]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case shortId
|
||||
case realId
|
||||
case status
|
||||
case paymentStatus
|
||||
case paymentConfirmed
|
||||
case paymentMethod
|
||||
case paymentMethodCode
|
||||
case paymentPayload
|
||||
case payment
|
||||
case deliveryType
|
||||
case deliveryTypeLabel
|
||||
case total
|
||||
case storeName
|
||||
case createdAt
|
||||
@@ -71,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
case otp
|
||||
case customerOtp
|
||||
case confirmOtp
|
||||
case cancellationReason
|
||||
case items
|
||||
case timeline
|
||||
}
|
||||
@@ -78,10 +109,16 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
init(
|
||||
id: String,
|
||||
shortId: String? = nil,
|
||||
realId: String? = nil,
|
||||
status: String? = nil,
|
||||
paymentStatus: String? = nil,
|
||||
paymentConfirmed: Bool? = nil,
|
||||
paymentMethod: String? = nil,
|
||||
paymentMethodCode: String? = nil,
|
||||
paymentPayload: CreateOrderPaymentPayload? = nil,
|
||||
payment: CreateOrderPaymentInfo? = nil,
|
||||
deliveryType: String? = nil,
|
||||
deliveryTypeLabel: String? = nil,
|
||||
total: Double? = nil,
|
||||
storeName: String? = nil,
|
||||
createdAt: String? = nil,
|
||||
@@ -89,15 +126,22 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
otp: String? = nil,
|
||||
customerOtp: String? = nil,
|
||||
confirmOtp: String? = nil,
|
||||
cancellationReason: String? = nil,
|
||||
items: [PublicOrderItem] = [],
|
||||
timeline: [PublicOrderTimelineEvent] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.shortId = shortId
|
||||
self.realId = realId
|
||||
self.status = status
|
||||
self.paymentStatus = paymentStatus
|
||||
self.paymentConfirmed = paymentConfirmed
|
||||
self.paymentMethod = paymentMethod
|
||||
self.paymentMethodCode = paymentMethodCode
|
||||
self.paymentPayload = paymentPayload
|
||||
self.payment = payment
|
||||
self.deliveryType = deliveryType
|
||||
self.deliveryTypeLabel = deliveryTypeLabel
|
||||
self.total = total
|
||||
self.storeName = storeName
|
||||
self.createdAt = createdAt
|
||||
@@ -105,25 +149,43 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
self.otp = otp
|
||||
self.customerOtp = customerOtp
|
||||
self.confirmOtp = confirmOtp
|
||||
self.cancellationReason = cancellationReason
|
||||
self.items = items
|
||||
self.timeline = timeline
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
||||
status = try? container.decode(String.self, forKey: .status)
|
||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
||||
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
|
||||
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
|
||||
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
|
||||
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||
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])
|
||||
storeName = try? container.decode(String.self, forKey: .storeName)
|
||||
createdAt = try? container.decode(String.self, forKey: .createdAt)
|
||||
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
|
||||
otp = try? container.decode(String.self, forKey: .otp)
|
||||
customerOtp = try? container.decode(String.self, forKey: .customerOtp)
|
||||
confirmOtp = try? container.decode(String.self, forKey: .confirmOtp)
|
||||
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
|
||||
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
||||
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
|
||||
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
|
||||
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
|
||||
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
|
||||
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
|
||||
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
|
||||
}
|
||||
@@ -152,6 +214,10 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
}
|
||||
|
||||
var isPaymentConfirmed: Bool {
|
||||
if let paymentConfirmed {
|
||||
return paymentConfirmed
|
||||
}
|
||||
|
||||
let payment = (paymentStatus ?? "").uppercased()
|
||||
let currentStatus = (status ?? "").uppercased()
|
||||
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
|
||||
@@ -226,21 +292,38 @@ struct PublicOrderItem: Codable, Identifiable {
|
||||
struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
let id: String
|
||||
let status: String?
|
||||
let label: String?
|
||||
let active: Bool?
|
||||
let completed: Bool?
|
||||
let message: String?
|
||||
let time: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case status
|
||||
case label
|
||||
case active
|
||||
case completed
|
||||
case message
|
||||
case time
|
||||
case createdAt
|
||||
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.status = status
|
||||
self.label = label
|
||||
self.active = active
|
||||
self.completed = completed
|
||||
self.message = message
|
||||
self.time = time
|
||||
}
|
||||
@@ -249,6 +332,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||
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)
|
||||
time = (try? container.decode(String.self, forKey: .time))
|
||||
?? (try? container.decode(String.self, forKey: .createdAt))
|
||||
@@ -259,6 +345,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
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(time, forKey: .time)
|
||||
}
|
||||
@@ -272,6 +361,27 @@ struct OrderRealtimeUpdate: Decodable {
|
||||
let status: String?
|
||||
let paymentStatus: 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 {
|
||||
@@ -281,7 +391,10 @@ extension CreateOrderResult {
|
||||
shortId: shortId,
|
||||
status: status,
|
||||
paymentStatus: paymentStatus,
|
||||
paymentMethod: paymentMethod
|
||||
paymentConfirmed: paymentConfirmed,
|
||||
paymentMethod: paymentMethod,
|
||||
paymentPayload: paymentPayload,
|
||||
payment: payment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ struct ApiEnvelope<T: Decodable>: Decodable {
|
||||
final class ApiService {
|
||||
private let client: ApiClient
|
||||
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()) {
|
||||
self.client = client
|
||||
@@ -63,9 +66,18 @@ final class ApiService {
|
||||
|
||||
private func expireSession(_ message: String?) {
|
||||
tokenStore.clear()
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
||||
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
|
||||
|
||||
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: [])
|
||||
}
|
||||
|
||||
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)
|
||||
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> {
|
||||
@@ -131,7 +153,7 @@ final class ApiService {
|
||||
}
|
||||
|
||||
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 {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
@@ -151,7 +173,7 @@ final class ApiService {
|
||||
}
|
||||
|
||||
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 {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
@@ -189,7 +211,14 @@ final class ApiService {
|
||||
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
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 {
|
||||
@@ -205,9 +234,18 @@ final class ApiService {
|
||||
|
||||
// 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)
|
||||
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]> {
|
||||
@@ -239,7 +277,11 @@ final class ApiService {
|
||||
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
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> {
|
||||
@@ -248,9 +290,19 @@ final class ApiService {
|
||||
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)
|
||||
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> {
|
||||
@@ -259,6 +311,27 @@ final class 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? {
|
||||
for key in keys {
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
|
||||
@@ -7,6 +7,11 @@ import AppKit
|
||||
typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
enum AppCacheTTL {
|
||||
static let twoHours: TimeInterval = 2 * 60 * 60
|
||||
static let homeStores: TimeInterval = 5 * 60
|
||||
}
|
||||
|
||||
final class AppContentCache: @unchecked Sendable {
|
||||
static let shared = AppContentCache()
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import FoundationNetworking
|
||||
|
||||
@MainActor
|
||||
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 socketClient: OrderSocketClient? = nil
|
||||
private var activeOrderId: String? = nil
|
||||
@@ -20,7 +23,7 @@ final class OrderRealtimeTracker {
|
||||
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()
|
||||
socket.onOrderUpdate = { [weak self] update in
|
||||
guard let self else { return }
|
||||
@@ -76,12 +79,42 @@ final class OrderRealtimeTracker {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
guard response.error == false, let order = response.result else { return nil }
|
||||
SessionStateStore.saveTrackedOrder(order)
|
||||
clearPendingCartIfNeeded(for: order)
|
||||
onOrderUpdated?(order)
|
||||
return order
|
||||
} catch {
|
||||
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 {
|
||||
|
||||
@@ -2,5 +2,5 @@ import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ enum SessionStateStore {
|
||||
private static let activeUserKey = "session.active.user.v1"
|
||||
private static let cartKeyPrefix = "session.cart.state.v1."
|
||||
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
|
||||
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||
|
||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||
let id = (profileId ?? "")
|
||||
@@ -253,6 +254,13 @@ enum SessionStateStore {
|
||||
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] {
|
||||
let defaults = UserDefaults.standard
|
||||
let key = trackedOrdersStorageKey(for: nil)
|
||||
@@ -285,4 +293,26 @@ enum SessionStateStore {
|
||||
static func clearTrackedOrders() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ func userFacingAuthErrorMessage(_ error: Error) -> String {
|
||||
return "Não foi possível se conectar ao servidor. Tente novamente."
|
||||
case .invalidURL, .invalidResponse, .decodeError:
|
||||
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
|
||||
case .cancelled:
|
||||
return "Cancelado"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct OtpView: View {
|
||||
private let resendDelaySeconds = 45
|
||||
@@ -66,6 +69,10 @@ struct OtpView: View {
|
||||
.frame(height: 204)
|
||||
.onTapGesture {
|
||||
isOtpFocused = true
|
||||
autoFillOtpFromClipboardIfAvailable()
|
||||
}
|
||||
.onLongPressGesture {
|
||||
pasteOtpFromClipboard()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 26)
|
||||
@@ -83,6 +90,13 @@ struct OtpView: View {
|
||||
}
|
||||
.padding(.top, 22)
|
||||
|
||||
Button("Colar código") {
|
||||
pasteOtpFromClipboard()
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.top, 22)
|
||||
|
||||
PrimaryButton(title: "Verificar e Entrar") {
|
||||
validateOtp()
|
||||
}
|
||||
@@ -166,6 +180,34 @@ struct OtpView: View {
|
||||
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() {
|
||||
let code = otp.filter(\.isNumber)
|
||||
guard code.count == 8 else { return }
|
||||
|
||||
@@ -7,7 +7,9 @@ struct CartView: View {
|
||||
@State var couponCode = ""
|
||||
@State var appliedCouponCode: String? = nil
|
||||
@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 {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -50,6 +52,9 @@ struct CartView: View {
|
||||
.navigationDestination(isPresented: $openCheckout) {
|
||||
CheckoutView(appState: $appState)
|
||||
}
|
||||
.task(id: deliveryFeeWatchKey) {
|
||||
await refreshDeliveryFee()
|
||||
}
|
||||
}
|
||||
|
||||
private var subtotalValue: Double {
|
||||
@@ -57,7 +62,16 @@ struct CartView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -108,7 +122,7 @@ struct CartView: View {
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
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)
|
||||
|
||||
Divider()
|
||||
@@ -136,6 +150,16 @@ struct CartView: View {
|
||||
.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 {
|
||||
HStack {
|
||||
Text(title)
|
||||
@@ -230,4 +254,88 @@ struct CartView: View {
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +260,23 @@ extension CheckoutView {
|
||||
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if orderSnapshot.isPaymentConfirmed {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
return
|
||||
}
|
||||
|
||||
SessionStateStore.savePendingCartOrderId(orderId)
|
||||
|
||||
if effectivePaymentMethod == .creditCard {
|
||||
cardPaymentContext = CardPaymentContext(
|
||||
orderId: orderId,
|
||||
|
||||
@@ -152,12 +152,28 @@ struct CheckoutView: View {
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable {
|
||||
|
||||
struct PaymentPixView: View {
|
||||
let context: PixPaymentContext
|
||||
var onPaymentConfirmed: (() -> Void)? = nil
|
||||
var onOpenTracking: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var latestOrder: PublicOrderResult? = nil
|
||||
@State var hasOpenedTracking = false
|
||||
@State var hasShownPixExpiredSnackbar = false
|
||||
@State var currentTime = Date()
|
||||
|
||||
private var qrImageSource: String? {
|
||||
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -590,51 +609,51 @@ struct PaymentPixView: View {
|
||||
Text("AGUARDANDO PAGAMENTO")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(AppColors.brandSoft)
|
||||
.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)
|
||||
.fill(AppColors.surface)
|
||||
.frame(height: 20)
|
||||
.overlay(
|
||||
VStack(spacing: 8) {
|
||||
Text("Código PIX")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text(context.copyPaste)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(3)
|
||||
.lineLimit(0)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 12)
|
||||
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
||||
Text("Expira em: \(expirationDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
}
|
||||
.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") {
|
||||
if isPixExpired {
|
||||
showPixExpiredSnackbar()
|
||||
return
|
||||
}
|
||||
copyToClipboard(context.copyPaste)
|
||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
||||
}
|
||||
|
||||
Button("Já realizei o pagamento") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
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)
|
||||
.disabled(isPixExpired)
|
||||
.opacity(isPixExpired ? 0.5 : 1.0)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
@@ -645,11 +664,22 @@ struct PaymentPixView: View {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
if updated.isPaymentConfirmed {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
tracker.stop()
|
||||
}
|
||||
@@ -659,7 +689,6 @@ struct PaymentPixView: View {
|
||||
guard hasOpenedTracking == false else { return }
|
||||
hasOpenedTracking = true
|
||||
onOpenTracking?()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ value: String) {
|
||||
@@ -670,10 +699,83 @@ struct PaymentPixView: View {
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
#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 {
|
||||
let context: CardPaymentContext
|
||||
var onPaymentConfirmed: (() -> Void)? = nil
|
||||
var onOpenTracking: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var cardHolderName = ""
|
||||
@@ -723,6 +825,7 @@ struct PaymentCardView: View {
|
||||
|
||||
PrimaryButton(title: "Salvar e Pagar") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
} else {
|
||||
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") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
} else {
|
||||
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
|
||||
latestOrder = updated
|
||||
if updated.isPaymentConfirmed {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
}
|
||||
}
|
||||
@@ -766,7 +871,6 @@ struct PaymentCardView: View {
|
||||
guard hasOpenedTracking == false else { return }
|
||||
hasOpenedTracking = true
|
||||
onOpenTracking?()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
|
||||
@@ -20,18 +20,29 @@ extension HomeView {
|
||||
}
|
||||
|
||||
@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 {
|
||||
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 {
|
||||
categories = mapPublicCategories(remote)
|
||||
let mapped = mapPublicCategories(remote)
|
||||
categories = mapped
|
||||
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 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] {
|
||||
|
||||
@@ -83,10 +83,6 @@ struct HomeView: View {
|
||||
.onChange(of: addressCacheScope) { _, _ in
|
||||
guard hasRequestedLocation else { return }
|
||||
Task {
|
||||
AppContentCache.shared.invalidate(prefix: "stores:")
|
||||
AppContentCache.shared.invalidate(prefix: "store-info:")
|
||||
AppContentCache.shared.invalidate(prefix: "store-catalog:")
|
||||
AppImageCache.shared.invalidateAll()
|
||||
await bootstrapStoresFlow(
|
||||
forceLocationRefresh: true,
|
||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
||||
@@ -435,7 +431,7 @@ struct HomeView: View {
|
||||
isLoadingStores = false
|
||||
stores = cachedStores
|
||||
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 {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
@@ -457,9 +453,9 @@ struct HomeView: View {
|
||||
}
|
||||
let results = response.result ?? []
|
||||
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) {
|
||||
await loadHomeCategories(withFallbackStores: results)
|
||||
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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 {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
@@ -8,38 +17,30 @@ struct OrderTrackingView: View {
|
||||
@State var errorMessage: String? = nil
|
||||
@State var order: PublicOrderResult? = nil
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var showCancellationReason = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
headerCard
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding(.top, 20)
|
||||
}
|
||||
|
||||
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
|
||||
topHeader
|
||||
orderTitleSection
|
||||
statusBanner
|
||||
timelineSection
|
||||
placeholderCard
|
||||
contactButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 45)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||
Button("Fechar", role: .cancel) {}
|
||||
} message: {
|
||||
Text(cancellationReasonText)
|
||||
}
|
||||
.task {
|
||||
await loadInitialOrder()
|
||||
tracker.onOrderUpdated = { updated in
|
||||
@@ -54,67 +55,103 @@ struct OrderTrackingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var displayOrderTitle: String {
|
||||
if let short = order?.shortId, short.isEmpty == false { return "#\(short)" }
|
||||
if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" }
|
||||
return "#\(orderId.prefix(6))"
|
||||
private var topHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.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) {
|
||||
Text(statusTitle)
|
||||
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)")
|
||||
Text("Pedido #\(displayOrderTitle)")
|
||||
.font(AppTypography.body)
|
||||
.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)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func timelineCard(_ order: PublicOrderResult) -> some View {
|
||||
let events: [PublicOrderTimelineEvent] = {
|
||||
if order.timeline.isEmpty == false { return order.timeline }
|
||||
return [
|
||||
PublicOrderTimelineEvent(
|
||||
status: order.status ?? order.paymentStatus ?? "PENDING",
|
||||
message: nil,
|
||||
time: order.updatedAt ?? order.createdAt
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let errorMessage, errorMessage.isEmpty == false {
|
||||
statusBadge(
|
||||
title: errorMessage,
|
||||
fg: Color.red,
|
||||
bg: Color.red.opacity(0.12),
|
||||
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) {
|
||||
Text("Acompanhamento")
|
||||
private var timelineSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Progresso do Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(events.enumerated()), id: \.offset) { index, event in
|
||||
timelineRow(event: event, isLast: index == events.count - 1)
|
||||
if let order {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
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))
|
||||
}
|
||||
|
||||
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) {
|
||||
VStack(spacing: 0) {
|
||||
Circle()
|
||||
.fill(AppColors.tertiary)
|
||||
.fill(stepDotColor(step))
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
Group {
|
||||
if step.isCompleted {
|
||||
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 {
|
||||
Rectangle()
|
||||
.fill(AppColors.tertiary.opacity(0.45))
|
||||
.frame(width: 2, height: 30)
|
||||
.fill(stepLineColor(step))
|
||||
.frame(width: 2, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado"))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text(formatTime(event.time) ?? "")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
Text(step.title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(stepTitleColor(step))
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private var footerPlaceholderCard: some View {
|
||||
VStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
||||
.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)
|
||||
private func stepDotColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled {
|
||||
return Color(hex: "#C5CBD4")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#F59E0B")
|
||||
}
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
return Color(hex: "#E5E7EB")
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
humanReadableStatus(order?.status ?? "Aguardando")
|
||||
private func stepLineColor(_ step: TrackingStep) -> Color {
|
||||
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 {
|
||||
let value = order?.paymentStatus ?? order?.status ?? "PENDING"
|
||||
return humanReadableStatus(value)
|
||||
private func stepTitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.textPrimary
|
||||
}
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
private func humanReadableStatus(_ raw: String) -> String {
|
||||
let normalized = raw.uppercased()
|
||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" {
|
||||
return "Aguardando pagamento"
|
||||
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#A16207")
|
||||
}
|
||||
if normalized.contains("CONFIRMED") {
|
||||
return "Pagamento confirmado"
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
if normalized.contains("PREPAR") {
|
||||
return "Em preparo"
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
if normalized.contains("CANCEL") {
|
||||
return "Pedido cancelado"
|
||||
if isOnlinePaymentMethod {
|
||||
return "Pagamento confirmado"
|
||||
}
|
||||
return raw.capitalized
|
||||
return "Pedido confirmado"
|
||||
}
|
||||
|
||||
private func formatTime(_ isoValue: String?) -> String? {
|
||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
||||
private var summaryStatusTitle: String {
|
||||
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()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
var date: Date? = iso.date(from: isoValue)
|
||||
var date = iso.date(from: value)
|
||||
if date == nil {
|
||||
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 }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
|
||||
@@ -4,6 +4,7 @@ struct OrdersView: View {
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String? = nil
|
||||
@State var orders: [AppOrderSummary] = []
|
||||
@State var hasLoadedOnce = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -31,8 +32,14 @@ struct OrdersView: View {
|
||||
.padding(.top, 24)
|
||||
} else {
|
||||
ForEach(orders) { order in
|
||||
let trackingId = trackingOrderId(for: order)
|
||||
NavigationLink {
|
||||
OrderTrackingView(orderId: order.id, initialShortId: order.shortId)
|
||||
OrderEntryDestinationView(
|
||||
orderId: trackingId,
|
||||
initialShortId: order.shortId,
|
||||
fallbackPaymentMethod: order.paymentMethod,
|
||||
fallbackTotal: order.total
|
||||
)
|
||||
} label: {
|
||||
orderRow(order)
|
||||
}
|
||||
@@ -47,7 +54,10 @@ struct OrdersView: View {
|
||||
.navigationTitle("Meus Pedidos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadOrders()
|
||||
await loadOrdersIfNeeded()
|
||||
}
|
||||
.refreshable {
|
||||
await loadOrders(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +76,7 @@ struct OrdersView: View {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente"))
|
||||
Text(humanReadableStatus(for: order))
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
@@ -89,11 +99,41 @@ struct OrdersView: View {
|
||||
.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()
|
||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { return "Aguardando pagamento" }
|
||||
if normalized.contains("CONFIRMED") { return "Confirmado" }
|
||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("ROTA") { return "Em rota" }
|
||||
if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" }
|
||||
if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" }
|
||||
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("CANCEL") { return "Cancelado" }
|
||||
return raw.capitalized
|
||||
@@ -120,56 +160,192 @@ struct OrdersView: View {
|
||||
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
|
||||
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
|
||||
errorMessage = nil
|
||||
let previousOrders = orders
|
||||
|
||||
var trackedMapped: [AppOrderSummary] = []
|
||||
let cachedTracked = SessionStateStore.loadTrackedOrders()
|
||||
if cachedTracked.isEmpty == false {
|
||||
let mapped = cachedTracked.map {
|
||||
trackedMapped = cachedTracked.map {
|
||||
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 {
|
||||
let response = try await ApiService().listOrders()
|
||||
let response = try await ApiService().listOrders(forceRefresh: force)
|
||||
if response.error {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
|
||||
} else {
|
||||
let remote = response.result ?? []
|
||||
orders = mergeOrders(apiOrders: remote, trackedOrders: orders)
|
||||
orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
|
||||
}
|
||||
} catch {
|
||||
if isCancelledRequest(error) {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if orders.isEmpty {
|
||||
errorMessage = "Não foi possível carregar os pedidos."
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
hasLoadedOnce = true
|
||||
}
|
||||
|
||||
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
|
||||
var map: [String: AppOrderSummary] = [:]
|
||||
for item in trackedOrders { map[item.id] = item }
|
||||
for item in apiOrders { map[item.id] = item }
|
||||
var sourceRank: [String: Int] = [:]
|
||||
|
||||
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
|
||||
let left = lhs.updatedAt ?? lhs.createdAt ?? ""
|
||||
let right = rhs.updatedAt ?? rhs.createdAt ?? ""
|
||||
return left > right
|
||||
let leftDate = orderDateSortValue(lhs)
|
||||
let rightDate = orderDateSortValue(rhs)
|
||||
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 {
|
||||
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
|
||||
AppOrderSummary(
|
||||
id: tracked.id,
|
||||
orderId: tracked.realId ?? tracked.id,
|
||||
realId: tracked.realId,
|
||||
shortId: tracked.shortId,
|
||||
total: tracked.total,
|
||||
status: tracked.status,
|
||||
statusDetailed: nil,
|
||||
statusLabel: nil,
|
||||
nextAction: nil,
|
||||
paymentStatus: tracked.paymentStatus,
|
||||
paymentMethod: tracked.paymentMethod,
|
||||
deliveryType: tracked.deliveryType,
|
||||
@@ -181,9 +357,14 @@ extension AppOrderSummary {
|
||||
|
||||
init(
|
||||
id: String,
|
||||
orderId: String?,
|
||||
realId: String?,
|
||||
shortId: String?,
|
||||
total: Double?,
|
||||
status: String?,
|
||||
statusDetailed: String?,
|
||||
statusLabel: String?,
|
||||
nextAction: String?,
|
||||
paymentStatus: String?,
|
||||
paymentMethod: String?,
|
||||
deliveryType: String?,
|
||||
@@ -192,9 +373,14 @@ extension AppOrderSummary {
|
||||
updatedAt: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.orderId = orderId
|
||||
self.realId = realId
|
||||
self.shortId = shortId
|
||||
self.total = total
|
||||
self.status = status
|
||||
self.statusDetailed = statusDetailed
|
||||
self.statusLabel = statusLabel
|
||||
self.nextAction = nextAction
|
||||
self.paymentStatus = paymentStatus
|
||||
self.paymentMethod = paymentMethod
|
||||
self.deliveryType = deliveryType
|
||||
@@ -203,3 +389,150 @@ extension AppOrderSummary {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ extension StoreDetailView {
|
||||
categories = catalogResponse.result ?? []
|
||||
selectedCategoryId = categories.first?.id
|
||||
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
|
||||
} catch {
|
||||
isLoading = false
|
||||
|
||||
BIN
reference_img/caracters/canceled_order.png
Normal file
|
After Width: | Height: | Size: 968 KiB |
BIN
reference_img/caracters/converted/tracking-canceled.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
reference_img/caracters/converted/tracking-completed.png
Normal file
|
After Width: | Height: | Size: 924 KiB |
BIN
reference_img/caracters/converted/tracking-delivering.png
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
reference_img/caracters/converted/tracking-pending.png
Normal file
|
After Width: | Height: | Size: 940 KiB |
BIN
reference_img/caracters/converted/tracking-preparing.png
Normal file
|
After Width: | Height: | Size: 926 KiB |
BIN
reference_img/caracters/converted/tracking-ready.png
Normal file
|
After Width: | Height: | Size: 799 KiB |
BIN
reference_img/caracters/delivered_order.png
Normal file
|
After Width: | Height: | Size: 982 KiB |
BIN
reference_img/caracters/delivering_order.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
reference_img/caracters/pending_order.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
reference_img/caracters/preparing_order.png
Normal file
|
After Width: | Height: | Size: 934 KiB |
BIN
reference_img/caracters/ready_order.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |