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(
|
init(
|
||||||
imageURL: String?,
|
imageURL: String?,
|
||||||
ttl: TimeInterval = 6 * 60 * 60,
|
ttl: TimeInterval = AppCacheTTL.twoHours,
|
||||||
@ViewBuilder placeholder: () -> Placeholder
|
@ViewBuilder placeholder: () -> Placeholder
|
||||||
) {
|
) {
|
||||||
self.imageURL = imageURL
|
self.imageURL = imageURL
|
||||||
@@ -100,7 +100,18 @@ final class CachedRemoteImageLoader: ObservableObject {
|
|||||||
guard let normalized, normalized.isEmpty == false else { return }
|
guard let normalized, normalized.isEmpty == false else { return }
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
|
let dataCacheKey = Self.dataURLCacheKey(normalized)
|
||||||
|
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
uiImage = cachedDataImage
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
nsImage = cachedDataImage
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let image = Self.imageFromDataURL(normalized) {
|
if let image = Self.imageFromDataURL(normalized) {
|
||||||
|
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
uiImage = image
|
uiImage = image
|
||||||
#elseif canImport(AppKit)
|
#elseif canImport(AppKit)
|
||||||
@@ -134,6 +145,12 @@ final class CachedRemoteImageLoader: ObservableObject {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func dataURLCacheKey(_ source: String) -> String {
|
||||||
|
let head = String(source.prefix(48))
|
||||||
|
let tail = String(source.suffix(48))
|
||||||
|
return "data-image:\(source.count):\(head):\(tail)"
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
#if canImport(UIKit) || canImport(AppKit)
|
||||||
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
|
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
|
||||||
let lower = source.lowercased()
|
let lower = source.lowercased()
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ struct ContentView: View {
|
|||||||
.onChange(of: appState.address.longitude) { _, _ in
|
.onChange(of: appState.address.longitude) { _, _ in
|
||||||
dismissAddressPickerIfAddressExists()
|
dismissAddressPickerIfAddressExists()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
|
||||||
|
appState.cart = CartState()
|
||||||
|
}
|
||||||
.task(id: root) {
|
.task(id: root) {
|
||||||
await bootstrapSessionStateIfNeeded()
|
await bootstrapSessionStateIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -169,6 +172,7 @@ struct ContentView: View {
|
|||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
SessionStateStore.clearActiveUser()
|
SessionStateStore.clearActiveUser()
|
||||||
SessionStateStore.clearTrackedOrders()
|
SessionStateStore.clearTrackedOrders()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
AppContentCache.shared.invalidate()
|
AppContentCache.shared.invalidate()
|
||||||
AppImageCache.shared.invalidateAll()
|
AppImageCache.shared.invalidateAll()
|
||||||
isBootstrappingSession = false
|
isBootstrappingSession = false
|
||||||
|
|||||||
@@ -78,10 +78,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Acompanhamento" : {
|
"Acompanhamento em tempo real" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Acompanhe seu pedido em tempo real" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Add" : {
|
"Add" : {
|
||||||
@@ -179,6 +176,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Atualizando status do pedido..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Atualize os dados do endereço abaixo." : {
|
"Atualize os dados do endereço abaixo." : {
|
||||||
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
|
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
|
||||||
@@ -233,6 +233,9 @@
|
|||||||
"Cardápio indisponível no momento." : {
|
"Cardápio indisponível no momento." : {
|
||||||
"comment" : "A message displayed when a store's menu is unavailable.",
|
"comment" : "A message displayed when a store's menu is unavailable.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Carregando pedido..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Carregando sua sessão..." : {
|
"Carregando sua sessão..." : {
|
||||||
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
||||||
@@ -250,6 +253,10 @@
|
|||||||
"comment" : "The title of the field that displays the PIX code.",
|
"comment" : "The title of the field that displays the PIX code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Colar código" : {
|
||||||
|
"comment" : "A button that allows the user to paste their OTP code directly into the field.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Concluir" : {
|
"Concluir" : {
|
||||||
"comment" : "The text for a button that confirms and closes a sheet.",
|
"comment" : "The text for a button that confirms and closes a sheet.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -262,6 +269,10 @@
|
|||||||
"comment" : "A button label that translates to \"Confirm and Pay\".",
|
"comment" : "A button label that translates to \"Confirm and Pay\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"CONTATO" : {
|
||||||
|
"comment" : "The text on a button that takes the user to contact support.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Conteúdo da política de privacidade..." : {
|
"Conteúdo da política de privacidade..." : {
|
||||||
"comment" : "A placeholder text describing the content of the privacy policy.",
|
"comment" : "A placeholder text describing the content of the privacy policy.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -397,10 +408,6 @@
|
|||||||
"comment" : "A button label that translates to \"Delete\".",
|
"comment" : "A button label that translates to \"Delete\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Expira em: %@" : {
|
|
||||||
"comment" : "A label displaying the expiration date of a payment code. The text inside the parentheses should be replaced with the actual expiration date.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Favorite" : {
|
"Favorite" : {
|
||||||
"comment" : "Item editor title label for marking the item as a favorite",
|
"comment" : "Item editor title label for marking the item as a favorite",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -525,10 +532,6 @@
|
|||||||
"comment" : "A button label that translates to \"Go to Payment\" in English.",
|
"comment" : "A button label that translates to \"Go to Payment\" in English.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Já realizei o pagamento" : {
|
|
||||||
"comment" : "A button that lets the user know they've completed the payment.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Já tem uma conta?" : {
|
"Já tem uma conta?" : {
|
||||||
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -592,6 +595,9 @@
|
|||||||
"Monte sua pizza" : {
|
"Monte sua pizza" : {
|
||||||
"comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.",
|
"comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Motivo do cancelamento" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"comment" : "Placeholder title for the Name field in a form",
|
"comment" : "Placeholder title for the Name field in a form",
|
||||||
@@ -707,9 +713,6 @@
|
|||||||
},
|
},
|
||||||
"Pedido %@" : {
|
"Pedido %@" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Pedido ID #%@" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"PediFoods" : {
|
"PediFoods" : {
|
||||||
"comment" : "The name of the app.",
|
"comment" : "The name of the app.",
|
||||||
@@ -772,6 +775,9 @@
|
|||||||
"Privacidade" : {
|
"Privacidade" : {
|
||||||
"comment" : "The title of the privacy policy screen.",
|
"comment" : "The title of the privacy policy screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Progresso do Pedido" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Receber Código" : {
|
"Receber Código" : {
|
||||||
"comment" : "A button label that says \"Receive Code\".",
|
"comment" : "A button label that says \"Receive Code\".",
|
||||||
@@ -876,13 +882,13 @@
|
|||||||
"Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : {
|
"Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : {
|
||||||
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
|
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Seu código do pedido: %@ - Informe esse número ao motoboy" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
|
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
|
||||||
"comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one",
|
"comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Seu pedido está em andamento" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Sim" : {
|
"Sim" : {
|
||||||
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",
|
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",
|
||||||
@@ -982,6 +988,9 @@
|
|||||||
"Trocar de loja?" : {
|
"Trocar de loja?" : {
|
||||||
"comment" : "A title for an alert that prompts the user to switch stores.",
|
"comment" : "A title for an alert that prompts the user to switch stores.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ver motivo do cancelamento" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ver Perfil" : {
|
"Ver Perfil" : {
|
||||||
"comment" : "A button label that translates to \"View Profile\" in English.",
|
"comment" : "A button label that translates to \"View Profile\" in English.",
|
||||||
|
|||||||
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 unauthorized(String?)
|
||||||
case decodeError(String?)
|
case decodeError(String?)
|
||||||
case rateLimited(Int?)
|
case rateLimited(Int?)
|
||||||
|
case cancelled
|
||||||
case transportError(String)
|
case transportError(String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
@@ -33,6 +34,8 @@ enum NetworkError: Error, LocalizedError {
|
|||||||
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
|
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
|
||||||
}
|
}
|
||||||
return "Muitas requisicoes. Tente novamente."
|
return "Muitas requisicoes. Tente novamente."
|
||||||
|
case .cancelled:
|
||||||
|
return "Requisicao cancelada"
|
||||||
case .transportError(let message):
|
case .transportError(let message):
|
||||||
return "Erro de rede: \(message)"
|
return "Erro de rede: \(message)"
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,11 @@ final class ApiClient {
|
|||||||
// NOTE:
|
// NOTE:
|
||||||
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
|
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
|
||||||
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
|
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
|
||||||
if request.path == "/api/customer/login" {
|
// Also route order-related polling/listing through URLSession to avoid
|
||||||
|
// intermittent cancellation observed with the shared API bridge.
|
||||||
|
if request.path == "/api/customer/login" ||
|
||||||
|
request.path == "/api/app/orders" ||
|
||||||
|
request.path.hasPrefix("/api/public/orders/") {
|
||||||
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
|
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
|
||||||
print("[ApiClient] /api/customer/login body: \(bodyText)")
|
print("[ApiClient] /api/customer/login body: \(bodyText)")
|
||||||
}
|
}
|
||||||
@@ -161,7 +168,6 @@ private extension ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mapError(_ error: Error) -> NetworkError {
|
func mapError(_ error: Error) -> NetworkError {
|
||||||
printError(title: "httpReqError", msg: error.localizedDescription)
|
|
||||||
if let network = error as? NetworkError {
|
if let network = error as? NetworkError {
|
||||||
return network
|
return network
|
||||||
}
|
}
|
||||||
@@ -176,6 +182,12 @@ private extension ApiClient {
|
|||||||
return .unauthorized(payload?.message ?? apiMessage)
|
return .unauthorized(payload?.message ?? apiMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
|
||||||
|
return .cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
printError(title: "httpReqError", msg: error.localizedDescription)
|
||||||
|
|
||||||
switch nsError.code {
|
switch nsError.code {
|
||||||
case 401, 403:
|
case 401, 403:
|
||||||
return .unauthorized(apiMessage)
|
return .unauthorized(apiMessage)
|
||||||
@@ -209,6 +221,8 @@ private extension ApiClient {
|
|||||||
while attempt <= maxAttempts {
|
while attempt <= maxAttempts {
|
||||||
do {
|
do {
|
||||||
return try await perform(urlRequest, as: T.self)
|
return try await perform(urlRequest, as: T.self)
|
||||||
|
} catch is CancellationError {
|
||||||
|
throw NetworkError.cancelled
|
||||||
} catch let error as NetworkError {
|
} catch let error as NetworkError {
|
||||||
guard shouldRetry(error), attempt < maxAttempts else {
|
guard shouldRetry(error), attempt < maxAttempts else {
|
||||||
throw error
|
throw error
|
||||||
@@ -230,6 +244,9 @@ private extension ApiClient {
|
|||||||
do {
|
do {
|
||||||
(data, response) = try await session.data(for: request)
|
(data, response) = try await session.data(for: request)
|
||||||
} catch {
|
} catch {
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||||
|
throw NetworkError.cancelled
|
||||||
|
}
|
||||||
throw NetworkError.transportError(error.localizedDescription)
|
throw NetworkError.transportError(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +300,7 @@ private extension ApiClient {
|
|||||||
return true
|
return true
|
||||||
case .httpError(let statusCode, _):
|
case .httpError(let statusCode, _):
|
||||||
return statusCode >= 500
|
return statusCode >= 500
|
||||||
case .invalidURL, .invalidResponse, .decodeError, .unauthorized:
|
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ struct CreateOrderResult: Decodable {
|
|||||||
let shortId: String?
|
let shortId: String?
|
||||||
let status: String?
|
let status: String?
|
||||||
let paymentStatus: String?
|
let paymentStatus: String?
|
||||||
|
let paymentConfirmed: Bool?
|
||||||
let paymentMethod: String?
|
let paymentMethod: String?
|
||||||
let paymentPayload: CreateOrderPaymentPayload?
|
let paymentPayload: CreateOrderPaymentPayload?
|
||||||
let payment: CreateOrderPaymentInfo?
|
let payment: CreateOrderPaymentInfo?
|
||||||
@@ -55,6 +56,7 @@ struct CreateOrderResult: Decodable {
|
|||||||
case shortId
|
case shortId
|
||||||
case status
|
case status
|
||||||
case paymentStatus
|
case paymentStatus
|
||||||
|
case paymentConfirmed
|
||||||
case paymentMethod
|
case paymentMethod
|
||||||
case paymentPayload
|
case paymentPayload
|
||||||
case payment
|
case payment
|
||||||
@@ -62,11 +64,12 @@ struct CreateOrderResult: Decodable {
|
|||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try? container.decode(String.self, forKey: .id)
|
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
|
||||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||||
status = try? container.decode(String.self, forKey: .status)
|
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
|
||||||
|
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||||
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
|
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
|
||||||
|
|
||||||
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
|
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
|
||||||
@@ -83,13 +86,13 @@ struct CreateOrderResult: Decodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CreateOrderPaymentInfo: Decodable {
|
struct CreateOrderPaymentInfo: Codable {
|
||||||
let method: String?
|
let method: String?
|
||||||
let status: String?
|
let status: String?
|
||||||
let pix: CreateOrderPaymentPayload?
|
let pix: CreateOrderPaymentPayload?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CreateOrderPaymentPayload: Decodable {
|
struct CreateOrderPaymentPayload: Codable {
|
||||||
let copyPaste: String?
|
let copyPaste: String?
|
||||||
let qrCodeImage: String?
|
let qrCodeImage: String?
|
||||||
let expirationDate: String?
|
let expirationDate: String?
|
||||||
@@ -116,6 +119,13 @@ struct CreateOrderPaymentPayload: Decodable {
|
|||||||
?? (try? container.decode(String.self, forKey: .encodedImage))
|
?? (try? container.decode(String.self, forKey: .encodedImage))
|
||||||
expirationDate = try? container.decode(String.self, forKey: .expirationDate)
|
expirationDate = try? container.decode(String.self, forKey: .expirationDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(copyPaste, forKey: .copyPaste)
|
||||||
|
try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage)
|
||||||
|
try container.encodeIfPresent(expirationDate, forKey: .expirationDate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ValidateDeliveryAddressPayload: Encodable {
|
struct ValidateDeliveryAddressPayload: Encodable {
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import Foundation
|
|||||||
|
|
||||||
struct AppOrderSummary: Decodable, Identifiable {
|
struct AppOrderSummary: Decodable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
|
let orderId: String?
|
||||||
|
let realId: String?
|
||||||
let shortId: String?
|
let shortId: String?
|
||||||
let total: Double?
|
let total: Double?
|
||||||
let status: String?
|
let status: String?
|
||||||
|
let statusDetailed: String?
|
||||||
|
let statusLabel: String?
|
||||||
|
let nextAction: String?
|
||||||
let paymentStatus: String?
|
let paymentStatus: String?
|
||||||
let paymentMethod: String?
|
let paymentMethod: String?
|
||||||
let deliveryType: String?
|
let deliveryType: String?
|
||||||
@@ -14,39 +19,57 @@ struct AppOrderSummary: Decodable, Identifiable {
|
|||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
case orderId
|
||||||
|
case realId
|
||||||
case shortId
|
case shortId
|
||||||
case total
|
case total
|
||||||
case status
|
case status
|
||||||
|
case statusDetailed
|
||||||
|
case statusLabel
|
||||||
|
case nextAction
|
||||||
case paymentStatus
|
case paymentStatus
|
||||||
case paymentMethod
|
case paymentMethod
|
||||||
case deliveryType
|
case deliveryType
|
||||||
case storeName
|
case storeName
|
||||||
|
case date
|
||||||
case createdAt
|
case createdAt
|
||||||
case updatedAt
|
case updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
|
||||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString
|
||||||
|
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
|
||||||
|
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||||
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
||||||
status = try? container.decode(String.self, forKey: .status)
|
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
|
||||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
|
||||||
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
|
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
|
||||||
storeName = try? container.decode(String.self, forKey: .storeName)
|
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||||
createdAt = try? container.decode(String.self, forKey: .createdAt)
|
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||||
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
|
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
|
||||||
|
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||||
|
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
||||||
|
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
|
||||||
|
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PublicOrderResult: Codable, Identifiable {
|
struct PublicOrderResult: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let shortId: String?
|
let shortId: String?
|
||||||
|
let realId: String?
|
||||||
let status: String?
|
let status: String?
|
||||||
let paymentStatus: String?
|
let paymentStatus: String?
|
||||||
|
let paymentConfirmed: Bool?
|
||||||
let paymentMethod: String?
|
let paymentMethod: String?
|
||||||
|
let paymentMethodCode: String?
|
||||||
|
let paymentPayload: CreateOrderPaymentPayload?
|
||||||
|
let payment: CreateOrderPaymentInfo?
|
||||||
let deliveryType: String?
|
let deliveryType: String?
|
||||||
|
let deliveryTypeLabel: String?
|
||||||
let total: Double?
|
let total: Double?
|
||||||
let storeName: String?
|
let storeName: String?
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
@@ -54,16 +77,23 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
let otp: String?
|
let otp: String?
|
||||||
let customerOtp: String?
|
let customerOtp: String?
|
||||||
let confirmOtp: String?
|
let confirmOtp: String?
|
||||||
|
let cancellationReason: String?
|
||||||
let items: [PublicOrderItem]
|
let items: [PublicOrderItem]
|
||||||
let timeline: [PublicOrderTimelineEvent]
|
let timeline: [PublicOrderTimelineEvent]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case shortId
|
case shortId
|
||||||
|
case realId
|
||||||
case status
|
case status
|
||||||
case paymentStatus
|
case paymentStatus
|
||||||
|
case paymentConfirmed
|
||||||
case paymentMethod
|
case paymentMethod
|
||||||
|
case paymentMethodCode
|
||||||
|
case paymentPayload
|
||||||
|
case payment
|
||||||
case deliveryType
|
case deliveryType
|
||||||
|
case deliveryTypeLabel
|
||||||
case total
|
case total
|
||||||
case storeName
|
case storeName
|
||||||
case createdAt
|
case createdAt
|
||||||
@@ -71,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
case otp
|
case otp
|
||||||
case customerOtp
|
case customerOtp
|
||||||
case confirmOtp
|
case confirmOtp
|
||||||
|
case cancellationReason
|
||||||
case items
|
case items
|
||||||
case timeline
|
case timeline
|
||||||
}
|
}
|
||||||
@@ -78,10 +109,16 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
init(
|
init(
|
||||||
id: String,
|
id: String,
|
||||||
shortId: String? = nil,
|
shortId: String? = nil,
|
||||||
|
realId: String? = nil,
|
||||||
status: String? = nil,
|
status: String? = nil,
|
||||||
paymentStatus: String? = nil,
|
paymentStatus: String? = nil,
|
||||||
|
paymentConfirmed: Bool? = nil,
|
||||||
paymentMethod: String? = nil,
|
paymentMethod: String? = nil,
|
||||||
|
paymentMethodCode: String? = nil,
|
||||||
|
paymentPayload: CreateOrderPaymentPayload? = nil,
|
||||||
|
payment: CreateOrderPaymentInfo? = nil,
|
||||||
deliveryType: String? = nil,
|
deliveryType: String? = nil,
|
||||||
|
deliveryTypeLabel: String? = nil,
|
||||||
total: Double? = nil,
|
total: Double? = nil,
|
||||||
storeName: String? = nil,
|
storeName: String? = nil,
|
||||||
createdAt: String? = nil,
|
createdAt: String? = nil,
|
||||||
@@ -89,15 +126,22 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
otp: String? = nil,
|
otp: String? = nil,
|
||||||
customerOtp: String? = nil,
|
customerOtp: String? = nil,
|
||||||
confirmOtp: String? = nil,
|
confirmOtp: String? = nil,
|
||||||
|
cancellationReason: String? = nil,
|
||||||
items: [PublicOrderItem] = [],
|
items: [PublicOrderItem] = [],
|
||||||
timeline: [PublicOrderTimelineEvent] = []
|
timeline: [PublicOrderTimelineEvent] = []
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.shortId = shortId
|
self.shortId = shortId
|
||||||
|
self.realId = realId
|
||||||
self.status = status
|
self.status = status
|
||||||
self.paymentStatus = paymentStatus
|
self.paymentStatus = paymentStatus
|
||||||
|
self.paymentConfirmed = paymentConfirmed
|
||||||
self.paymentMethod = paymentMethod
|
self.paymentMethod = paymentMethod
|
||||||
|
self.paymentMethodCode = paymentMethodCode
|
||||||
|
self.paymentPayload = paymentPayload
|
||||||
|
self.payment = payment
|
||||||
self.deliveryType = deliveryType
|
self.deliveryType = deliveryType
|
||||||
|
self.deliveryTypeLabel = deliveryTypeLabel
|
||||||
self.total = total
|
self.total = total
|
||||||
self.storeName = storeName
|
self.storeName = storeName
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@@ -105,25 +149,43 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
self.otp = otp
|
self.otp = otp
|
||||||
self.customerOtp = customerOtp
|
self.customerOtp = customerOtp
|
||||||
self.confirmOtp = confirmOtp
|
self.confirmOtp = confirmOtp
|
||||||
|
self.cancellationReason = cancellationReason
|
||||||
self.items = items
|
self.items = items
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
|
||||||
shortId = try? container.decode(String.self, forKey: .shortId)
|
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||||
status = try? container.decode(String.self, forKey: .status)
|
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
|
||||||
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
|
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||||
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
|
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||||
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
|
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
|
||||||
|
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
|
||||||
|
paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode])
|
||||||
|
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
|
||||||
|
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
|
||||||
|
paymentPayload = objectPayload
|
||||||
|
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
|
||||||
|
paymentPayload = CreateOrderPaymentPayload(
|
||||||
|
copyPaste: stringPayload,
|
||||||
|
qrCodeImage: nil,
|
||||||
|
expirationDate: nil
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
paymentPayload = nil
|
||||||
|
}
|
||||||
|
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
|
||||||
|
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
|
||||||
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
||||||
storeName = try? container.decode(String.self, forKey: .storeName)
|
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||||
createdAt = try? container.decode(String.self, forKey: .createdAt)
|
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
|
||||||
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
|
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
||||||
otp = try? container.decode(String.self, forKey: .otp)
|
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
|
||||||
customerOtp = try? container.decode(String.self, forKey: .customerOtp)
|
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
|
||||||
confirmOtp = try? container.decode(String.self, forKey: .confirmOtp)
|
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
|
||||||
|
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
|
||||||
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
|
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
|
||||||
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
|
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
|
||||||
}
|
}
|
||||||
@@ -152,6 +214,10 @@ struct PublicOrderResult: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var isPaymentConfirmed: Bool {
|
var isPaymentConfirmed: Bool {
|
||||||
|
if let paymentConfirmed {
|
||||||
|
return paymentConfirmed
|
||||||
|
}
|
||||||
|
|
||||||
let payment = (paymentStatus ?? "").uppercased()
|
let payment = (paymentStatus ?? "").uppercased()
|
||||||
let currentStatus = (status ?? "").uppercased()
|
let currentStatus = (status ?? "").uppercased()
|
||||||
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
|
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
|
||||||
@@ -226,21 +292,38 @@ struct PublicOrderItem: Codable, Identifiable {
|
|||||||
struct PublicOrderTimelineEvent: Codable, Identifiable {
|
struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let status: String?
|
let status: String?
|
||||||
|
let label: String?
|
||||||
|
let active: Bool?
|
||||||
|
let completed: Bool?
|
||||||
let message: String?
|
let message: String?
|
||||||
let time: String?
|
let time: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case status
|
case status
|
||||||
|
case label
|
||||||
|
case active
|
||||||
|
case completed
|
||||||
case message
|
case message
|
||||||
case time
|
case time
|
||||||
case createdAt
|
case createdAt
|
||||||
case updatedAt
|
case updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String = UUID().uuidString, status: String?, message: String?, time: String?) {
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
status: String?,
|
||||||
|
label: String? = nil,
|
||||||
|
active: Bool? = nil,
|
||||||
|
completed: Bool? = nil,
|
||||||
|
message: String?,
|
||||||
|
time: String?
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.label = label
|
||||||
|
self.active = active
|
||||||
|
self.completed = completed
|
||||||
self.message = message
|
self.message = message
|
||||||
self.time = time
|
self.time = time
|
||||||
}
|
}
|
||||||
@@ -249,6 +332,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
|||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||||
status = try? container.decode(String.self, forKey: .status)
|
status = try? container.decode(String.self, forKey: .status)
|
||||||
|
label = try? container.decode(String.self, forKey: .label)
|
||||||
|
active = try? container.decode(Bool.self, forKey: .active)
|
||||||
|
completed = try? container.decode(Bool.self, forKey: .completed)
|
||||||
message = try? container.decode(String.self, forKey: .message)
|
message = try? container.decode(String.self, forKey: .message)
|
||||||
time = (try? container.decode(String.self, forKey: .time))
|
time = (try? container.decode(String.self, forKey: .time))
|
||||||
?? (try? container.decode(String.self, forKey: .createdAt))
|
?? (try? container.decode(String.self, forKey: .createdAt))
|
||||||
@@ -259,6 +345,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
|||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try container.encode(id, forKey: .id)
|
try container.encode(id, forKey: .id)
|
||||||
try container.encodeIfPresent(status, forKey: .status)
|
try container.encodeIfPresent(status, forKey: .status)
|
||||||
|
try container.encodeIfPresent(label, forKey: .label)
|
||||||
|
try container.encodeIfPresent(active, forKey: .active)
|
||||||
|
try container.encodeIfPresent(completed, forKey: .completed)
|
||||||
try container.encodeIfPresent(message, forKey: .message)
|
try container.encodeIfPresent(message, forKey: .message)
|
||||||
try container.encodeIfPresent(time, forKey: .time)
|
try container.encodeIfPresent(time, forKey: .time)
|
||||||
}
|
}
|
||||||
@@ -272,6 +361,27 @@ struct OrderRealtimeUpdate: Decodable {
|
|||||||
let status: String?
|
let status: String?
|
||||||
let paymentStatus: String?
|
let paymentStatus: String?
|
||||||
let updatedAt: String?
|
let updatedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case shortId
|
||||||
|
case storeId
|
||||||
|
case userId
|
||||||
|
case status
|
||||||
|
case paymentStatus
|
||||||
|
case updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
|
||||||
|
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
|
||||||
|
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
|
||||||
|
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
|
||||||
|
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
|
||||||
|
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
|
||||||
|
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CreateOrderResult {
|
extension CreateOrderResult {
|
||||||
@@ -281,7 +391,10 @@ extension CreateOrderResult {
|
|||||||
shortId: shortId,
|
shortId: shortId,
|
||||||
status: status,
|
status: status,
|
||||||
paymentStatus: paymentStatus,
|
paymentStatus: paymentStatus,
|
||||||
paymentMethod: paymentMethod
|
paymentConfirmed: paymentConfirmed,
|
||||||
|
paymentMethod: paymentMethod,
|
||||||
|
paymentPayload: paymentPayload,
|
||||||
|
payment: payment
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ struct ApiEnvelope<T: Decodable>: Decodable {
|
|||||||
final class ApiService {
|
final class ApiService {
|
||||||
private let client: ApiClient
|
private let client: ApiClient
|
||||||
private var tokenStore: TokenStore
|
private var tokenStore: TokenStore
|
||||||
|
private let profileCachePrefix = "api:profile:"
|
||||||
|
private let ordersCachePrefix = "api:orders:"
|
||||||
|
private let publicCategoriesCacheKey = "api:public-categories"
|
||||||
|
|
||||||
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
||||||
self.client = client
|
self.client = client
|
||||||
@@ -63,9 +66,18 @@ final class ApiService {
|
|||||||
|
|
||||||
private func expireSession(_ message: String?) {
|
private func expireSession(_ message: String?) {
|
||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
||||||
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scopedCacheSuffix() -> String {
|
||||||
|
let jwt = tokenStore.jwt ?? "anonymous"
|
||||||
|
if jwt.count <= 16 { return jwt }
|
||||||
|
return String(jwt.prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
|
|
||||||
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
|
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
|
||||||
@@ -121,9 +133,19 @@ final class ApiService {
|
|||||||
return try JSONSerialization.data(withJSONObject: payload, options: [])
|
return try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||||
}
|
}
|
||||||
|
|
||||||
func profile() async throws -> ApiEnvelope<CustomerProfile> {
|
func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
|
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||||
|
if forceRefresh == false,
|
||||||
|
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
|
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
|
||||||
return try await sendEnvelope(req)
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false, envelope.result != nil {
|
||||||
|
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
@@ -131,7 +153,7 @@ final class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
let currentProfile = try await profile()
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
throw NetworkError.invalidResponse
|
throw NetworkError.invalidResponse
|
||||||
}
|
}
|
||||||
@@ -151,7 +173,7 @@ final class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
let currentProfile = try await profile()
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
throw NetworkError.invalidResponse
|
throw NetworkError.invalidResponse
|
||||||
}
|
}
|
||||||
@@ -189,7 +211,14 @@ final class ApiService {
|
|||||||
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
|
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
|
||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
return try await sendEnvelope(req)
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
|
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||||
|
if envelope.error == false, envelope.result != nil {
|
||||||
|
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||||
|
} else {
|
||||||
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
|
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
|
||||||
@@ -205,9 +234,18 @@ final class ApiService {
|
|||||||
|
|
||||||
// MARK: - Stores
|
// MARK: - Stores
|
||||||
|
|
||||||
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
|
func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
|
||||||
|
if forceRefresh == false,
|
||||||
|
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
|
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
|
||||||
return try await sendEnvelope(req)
|
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
|
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
|
||||||
@@ -239,7 +277,11 @@ final class ApiService {
|
|||||||
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
|
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
|
||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
|
||||||
return try await sendEnvelope(req)
|
let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
|
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
|
||||||
@@ -248,9 +290,19 @@ final class ApiService {
|
|||||||
return try await sendEnvelope(req)
|
return try await sendEnvelope(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listOrders() async throws -> ApiEnvelope<[AppOrderSummary]> {
|
func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
|
||||||
|
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
|
||||||
|
if forceRefresh == false,
|
||||||
|
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
|
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
|
||||||
return try await sendEnvelope(req)
|
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
|
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
|
||||||
@@ -259,6 +311,27 @@ final class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
extension ApiService {
|
extension ApiService {
|
||||||
|
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
if let value = try? container.decode(String.self, forKey: key) {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty == false {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let asInt = try? container.decode(Int.self, forKey: key) {
|
||||||
|
return String(asInt)
|
||||||
|
}
|
||||||
|
if let asDouble = try? container.decode(Double.self, forKey: key) {
|
||||||
|
if asDouble.rounded() == asDouble {
|
||||||
|
return String(Int(asDouble))
|
||||||
|
}
|
||||||
|
return String(asDouble)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let value = try? container.decode(Double.self, forKey: key) {
|
if let value = try? container.decode(Double.self, forKey: key) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import AppKit
|
|||||||
typealias PlatformImage = NSImage
|
typealias PlatformImage = NSImage
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
enum AppCacheTTL {
|
||||||
|
static let twoHours: TimeInterval = 2 * 60 * 60
|
||||||
|
static let homeStores: TimeInterval = 5 * 60
|
||||||
|
}
|
||||||
|
|
||||||
final class AppContentCache: @unchecked Sendable {
|
final class AppContentCache: @unchecked Sendable {
|
||||||
static let shared = AppContentCache()
|
static let shared = AppContentCache()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import FoundationNetworking
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OrderRealtimeTracker {
|
final class OrderRealtimeTracker {
|
||||||
|
// Keep realtime tracking on polling to avoid socket.io handshake failures
|
||||||
|
// on environments where websocket upgrade is not available.
|
||||||
|
private let useSocketRealtime = false
|
||||||
private var pollingTask: Task<Void, Never>? = nil
|
private var pollingTask: Task<Void, Never>? = nil
|
||||||
private var socketClient: OrderSocketClient? = nil
|
private var socketClient: OrderSocketClient? = nil
|
||||||
private var activeOrderId: String? = nil
|
private var activeOrderId: String? = nil
|
||||||
@@ -20,7 +23,7 @@ final class OrderRealtimeTracker {
|
|||||||
await self.runPollingLoop(orderId: orderId)
|
await self.runPollingLoop(orderId: orderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let jwt, jwt.isEmpty == false else { return }
|
guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
|
||||||
let socket = OrderSocketClient()
|
let socket = OrderSocketClient()
|
||||||
socket.onOrderUpdate = { [weak self] update in
|
socket.onOrderUpdate = { [weak self] update in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -76,12 +79,42 @@ final class OrderRealtimeTracker {
|
|||||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||||
guard response.error == false, let order = response.result else { return nil }
|
guard response.error == false, let order = response.result else { return nil }
|
||||||
SessionStateStore.saveTrackedOrder(order)
|
SessionStateStore.saveTrackedOrder(order)
|
||||||
|
clearPendingCartIfNeeded(for: order)
|
||||||
onOrderUpdated?(order)
|
onOrderUpdated?(order)
|
||||||
return order
|
return order
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearPendingCartIfNeeded(for order: PublicOrderResult) {
|
||||||
|
guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return }
|
||||||
|
let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalizedPending.isEmpty { return }
|
||||||
|
|
||||||
|
let ids = [order.id, order.realId]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||||
|
guard ids.contains(normalizedPending) else { return }
|
||||||
|
|
||||||
|
if shouldClearCart(for: order) == false { return }
|
||||||
|
|
||||||
|
SessionStateStore.clearCart()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
|
NotificationCenter.default.post(name: .cartDidReset, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldClearCart(for order: PublicOrderResult) -> Bool {
|
||||||
|
if order.isPaymentConfirmed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = (order.status ?? "").uppercased()
|
||||||
|
if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class OrderSocketClient: @unchecked Sendable {
|
final class OrderSocketClient: @unchecked Sendable {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import Foundation
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
static let sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||||
|
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ enum SessionStateStore {
|
|||||||
private static let activeUserKey = "session.active.user.v1"
|
private static let activeUserKey = "session.active.user.v1"
|
||||||
private static let cartKeyPrefix = "session.cart.state.v1."
|
private static let cartKeyPrefix = "session.cart.state.v1."
|
||||||
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
|
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
|
||||||
|
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||||
|
|
||||||
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||||
let id = (profileId ?? "")
|
let id = (profileId ?? "")
|
||||||
@@ -253,6 +254,13 @@ enum SessionStateStore {
|
|||||||
return trackedOrdersKeyPrefix + safe
|
return trackedOrdersKeyPrefix + safe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func pendingCartOrderStorageKey(for userKey: String?) -> String {
|
||||||
|
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let safe = key.replacingOccurrences(of: " ", with: "_")
|
||||||
|
return pendingCartOrderKeyPrefix + safe
|
||||||
|
}
|
||||||
|
|
||||||
static func loadTrackedOrders() -> [PublicOrderResult] {
|
static func loadTrackedOrders() -> [PublicOrderResult] {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let key = trackedOrdersStorageKey(for: nil)
|
let key = trackedOrdersStorageKey(for: nil)
|
||||||
@@ -285,4 +293,26 @@ enum SessionStateStore {
|
|||||||
static func clearTrackedOrders() {
|
static func clearTrackedOrders() {
|
||||||
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
|
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func savePendingCartOrderId(_ orderId: String) {
|
||||||
|
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard clean.isEmpty == false else {
|
||||||
|
clearPendingCartOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadPendingCartOrderId() -> String? {
|
||||||
|
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let value, value.isEmpty == false {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clearPendingCartOrder() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ func userFacingAuthErrorMessage(_ error: Error) -> String {
|
|||||||
return "Não foi possível se conectar ao servidor. Tente novamente."
|
return "Não foi possível se conectar ao servidor. Tente novamente."
|
||||||
case .invalidURL, .invalidResponse, .decodeError:
|
case .invalidURL, .invalidResponse, .decodeError:
|
||||||
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
|
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
|
||||||
|
case .cancelled:
|
||||||
|
return "Cancelado"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct OtpView: View {
|
struct OtpView: View {
|
||||||
private let resendDelaySeconds = 45
|
private let resendDelaySeconds = 45
|
||||||
@@ -66,6 +69,10 @@ struct OtpView: View {
|
|||||||
.frame(height: 204)
|
.frame(height: 204)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isOtpFocused = true
|
isOtpFocused = true
|
||||||
|
autoFillOtpFromClipboardIfAvailable()
|
||||||
|
}
|
||||||
|
.onLongPressGesture {
|
||||||
|
pasteOtpFromClipboard()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, 26)
|
.padding(.top, 26)
|
||||||
@@ -83,6 +90,13 @@ struct OtpView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, 22)
|
.padding(.top, 22)
|
||||||
|
|
||||||
|
Button("Colar código") {
|
||||||
|
pasteOtpFromClipboard()
|
||||||
|
}
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
.padding(.top, 22)
|
||||||
|
|
||||||
PrimaryButton(title: "Verificar e Entrar") {
|
PrimaryButton(title: "Verificar e Entrar") {
|
||||||
validateOtp()
|
validateOtp()
|
||||||
}
|
}
|
||||||
@@ -166,6 +180,34 @@ struct OtpView: View {
|
|||||||
return Array(otp)[index]
|
return Array(otp)[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pasteOtpFromClipboard() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
let raw = UIPasteboard.general.string ?? ""
|
||||||
|
let digits = raw.filter(\.isNumber)
|
||||||
|
let trimmed = String(digits.prefix(8))
|
||||||
|
if trimmed.isEmpty == false {
|
||||||
|
otp = trimmed
|
||||||
|
}
|
||||||
|
#elseif canImport(AppKit)
|
||||||
|
let raw = NSPasteboard.general.string(forType: .string) ?? ""
|
||||||
|
let digits = raw.filter(\.isNumber)
|
||||||
|
let trimmed = String(digits.prefix(8))
|
||||||
|
if trimmed.isEmpty == false {
|
||||||
|
otp = trimmed
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autoFillOtpFromClipboardIfAvailable() {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
guard otp.isEmpty else { return }
|
||||||
|
let raw = UIPasteboard.general.string ?? ""
|
||||||
|
let digits = raw.filter(\.isNumber)
|
||||||
|
guard digits.count >= 8 else { return }
|
||||||
|
otp = String(digits.prefix(8))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func validateOtp() {
|
private func validateOtp() {
|
||||||
let code = otp.filter(\.isNumber)
|
let code = otp.filter(\.isNumber)
|
||||||
guard code.count == 8 else { return }
|
guard code.count == 8 else { return }
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ struct CartView: View {
|
|||||||
@State var couponCode = ""
|
@State var couponCode = ""
|
||||||
@State var appliedCouponCode: String? = nil
|
@State var appliedCouponCode: String? = nil
|
||||||
@State var discountValue: Double = 0
|
@State var discountValue: Double = 0
|
||||||
@State var deliveryFee: Double = 5
|
@State var deliveryFee: Double? = nil
|
||||||
|
@State var selectedCustomerAddress: CustomerAddress? = nil
|
||||||
|
@State var isLoadingDeliveryFee = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -50,6 +52,9 @@ struct CartView: View {
|
|||||||
.navigationDestination(isPresented: $openCheckout) {
|
.navigationDestination(isPresented: $openCheckout) {
|
||||||
CheckoutView(appState: $appState)
|
CheckoutView(appState: $appState)
|
||||||
}
|
}
|
||||||
|
.task(id: deliveryFeeWatchKey) {
|
||||||
|
await refreshDeliveryFee()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subtotalValue: Double {
|
private var subtotalValue: Double {
|
||||||
@@ -57,7 +62,16 @@ struct CartView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var totalValue: Double {
|
private var totalValue: Double {
|
||||||
max(0, subtotalValue + deliveryFee - discountValue)
|
max(0, subtotalValue + (deliveryFee ?? 0) - discountValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deliveryFeeWatchKey: String {
|
||||||
|
let storeId = appState.cart.storeId ?? "nil"
|
||||||
|
let selectedId = appState.address.selectedId ?? "nil"
|
||||||
|
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||||
|
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||||
|
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var couponSection: some View {
|
private var couponSection: some View {
|
||||||
@@ -108,7 +122,7 @@ struct CartView: View {
|
|||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
||||||
summaryRow(title: "Taxa de Entrega", value: formatCurrency(deliveryFee))
|
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
|
||||||
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
|
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
@@ -136,6 +150,16 @@ struct CartView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var deliveryFeeLabel: String {
|
||||||
|
if isLoadingDeliveryFee {
|
||||||
|
return "Calculando..."
|
||||||
|
}
|
||||||
|
if let deliveryFee {
|
||||||
|
return formatCurrency(deliveryFee)
|
||||||
|
}
|
||||||
|
return "Indisponível"
|
||||||
|
}
|
||||||
|
|
||||||
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
|
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
@@ -230,4 +254,88 @@ struct CartView: View {
|
|||||||
private func formatCurrency(_ value: Double) -> String {
|
private func formatCurrency(_ value: Double) -> String {
|
||||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func refreshDeliveryFee() async {
|
||||||
|
guard appState.cart.items.isEmpty == false else {
|
||||||
|
deliveryFee = nil
|
||||||
|
selectedCustomerAddress = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
|
||||||
|
deliveryFee = nil
|
||||||
|
selectedCustomerAddress = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingDeliveryFee = true
|
||||||
|
defer { isLoadingDeliveryFee = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let profileResponse = try await ApiService().profile()
|
||||||
|
let addresses = profileResponse.result?.addressBook ?? []
|
||||||
|
|
||||||
|
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||||
|
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
|
||||||
|
} else {
|
||||||
|
selectedCustomerAddress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedCustomerAddress == nil {
|
||||||
|
let display = appState.address.display
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
if display.isEmpty == false, display != "defina seu endereco" {
|
||||||
|
selectedCustomerAddress = addresses.first {
|
||||||
|
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedCustomerAddress == nil {
|
||||||
|
selectedCustomerAddress = addresses.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let selected = selectedCustomerAddress {
|
||||||
|
appState.address.selectedId = selected.id
|
||||||
|
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if label.isEmpty == false {
|
||||||
|
appState.address.display = label
|
||||||
|
}
|
||||||
|
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
||||||
|
appState.address.latitude = lat
|
||||||
|
appState.address.longitude = lng
|
||||||
|
}
|
||||||
|
SessionStateStore.saveAddress(appState.address)
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
|
||||||
|
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
|
||||||
|
|
||||||
|
let payload = ValidateDeliveryAddressPayload(
|
||||||
|
address: ValidateDeliveryAddressDataPayload(
|
||||||
|
street: selectedCustomerAddress?.address,
|
||||||
|
number: selectedCustomerAddress?.number,
|
||||||
|
neighborhood: selectedCustomerAddress?.neighborhood,
|
||||||
|
city: selectedCustomerAddress?.city,
|
||||||
|
state: selectedCustomerAddress?.state,
|
||||||
|
zip: selectedCustomerAddress?.zipCode,
|
||||||
|
lat: payloadLat,
|
||||||
|
lng: payloadLng
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
|
||||||
|
guard validationResponse.error == false,
|
||||||
|
validationResponse.result?.deliveryAllowed == true,
|
||||||
|
let fee = validationResponse.result?.deliveryFee else {
|
||||||
|
deliveryFee = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveryFee = fee
|
||||||
|
} catch {
|
||||||
|
deliveryFee = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,11 +260,23 @@ extension CheckoutView {
|
|||||||
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
||||||
|
|
||||||
let orderId = result.id ?? UUID().uuidString
|
let orderId = result.id ?? UUID().uuidString
|
||||||
if useInAppPayment == false {
|
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
|
||||||
|
if useInAppPayment == false || isInAppMethod == false {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if orderSnapshot.isPaymentConfirmed {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
|
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStateStore.savePendingCartOrderId(orderId)
|
||||||
|
|
||||||
if effectivePaymentMethod == .creditCard {
|
if effectivePaymentMethod == .creditCard {
|
||||||
cardPaymentContext = CardPaymentContext(
|
cardPaymentContext = CardPaymentContext(
|
||||||
orderId: orderId,
|
orderId: orderId,
|
||||||
|
|||||||
@@ -152,12 +152,28 @@ struct CheckoutView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $pixPaymentContext) { context in
|
.navigationDestination(item: $pixPaymentContext) { context in
|
||||||
PaymentPixView(context: context) {
|
PaymentPixView(
|
||||||
|
context: context,
|
||||||
|
onPaymentConfirmed: {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $cardPaymentContext) { context in
|
.navigationDestination(item: $cardPaymentContext) { context in
|
||||||
PaymentCardView(context: context) {
|
PaymentCardView(
|
||||||
|
context: context,
|
||||||
|
onPaymentConfirmed: {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
appState.cart.clear()
|
||||||
|
SessionStateStore.clearPendingCartOrder()
|
||||||
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable {
|
|||||||
|
|
||||||
struct PaymentPixView: View {
|
struct PaymentPixView: View {
|
||||||
let context: PixPaymentContext
|
let context: PixPaymentContext
|
||||||
|
var onPaymentConfirmed: (() -> Void)? = nil
|
||||||
var onOpenTracking: (() -> Void)? = nil
|
var onOpenTracking: (() -> Void)? = nil
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State var tracker = OrderRealtimeTracker()
|
@State var tracker = OrderRealtimeTracker()
|
||||||
@State var latestOrder: PublicOrderResult? = nil
|
@State var latestOrder: PublicOrderResult? = nil
|
||||||
@State var hasOpenedTracking = false
|
@State var hasOpenedTracking = false
|
||||||
|
@State var hasShownPixExpiredSnackbar = false
|
||||||
|
@State var currentTime = Date()
|
||||||
|
|
||||||
private var qrImageSource: String? {
|
private var qrImageSource: String? {
|
||||||
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
@@ -590,51 +609,51 @@ struct PaymentPixView: View {
|
|||||||
Text("AGUARDANDO PAGAMENTO")
|
Text("AGUARDANDO PAGAMENTO")
|
||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(AppColors.brandSoft)
|
.background(AppColors.brandSoft)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, minHeight: 360)
|
.frame(maxWidth: .infinity, minHeight: 380)
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
|
||||||
.fill(AppColors.surface)
|
|
||||||
.overlay(
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text("Código PIX")
|
Text("Código PIX")
|
||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
||||||
|
.fill(AppColors.surface)
|
||||||
|
.frame(height: 20)
|
||||||
|
.overlay(
|
||||||
|
VStack(spacing: 8) {
|
||||||
Text(context.copyPaste)
|
Text(context.copyPaste)
|
||||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
.lineLimit(3)
|
.lineLimit(0)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 10)
|
||||||
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
|
||||||
Text("Expira em: \(expirationDate)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(AppColors.textMuted)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
||||||
|
Text(expirationLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
PrimaryButton(title: "Copiar Código PIX") {
|
PrimaryButton(title: "Copiar Código PIX") {
|
||||||
|
if isPixExpired {
|
||||||
|
showPixExpiredSnackbar()
|
||||||
|
return
|
||||||
|
}
|
||||||
copyToClipboard(context.copyPaste)
|
copyToClipboard(context.copyPaste)
|
||||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
||||||
}
|
}
|
||||||
|
.disabled(isPixExpired)
|
||||||
Button("Já realizei o pagamento") {
|
.opacity(isPixExpired ? 0.5 : 1.0)
|
||||||
if latestOrder?.isPaymentConfirmed == true {
|
.padding(.top, 10)
|
||||||
openTrackingOnce()
|
|
||||||
} else {
|
|
||||||
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(AppTypography.heading3)
|
|
||||||
.foregroundStyle(AppColors.textMuted)
|
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
@@ -645,11 +664,22 @@ struct PaymentPixView: View {
|
|||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
latestOrder = updated
|
latestOrder = updated
|
||||||
if updated.isPaymentConfirmed {
|
if updated.isPaymentConfirmed {
|
||||||
|
onPaymentConfirmed?()
|
||||||
openTrackingOnce()
|
openTrackingOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
|
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
while Task.isCancelled == false {
|
||||||
|
currentTime = Date()
|
||||||
|
if isPixExpired {
|
||||||
|
showPixExpiredSnackbar()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
tracker.stop()
|
tracker.stop()
|
||||||
}
|
}
|
||||||
@@ -659,7 +689,6 @@ struct PaymentPixView: View {
|
|||||||
guard hasOpenedTracking == false else { return }
|
guard hasOpenedTracking == false else { return }
|
||||||
hasOpenedTracking = true
|
hasOpenedTracking = true
|
||||||
onOpenTracking?()
|
onOpenTracking?()
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyToClipboard(_ value: String) {
|
private func copyToClipboard(_ value: String) {
|
||||||
@@ -670,10 +699,83 @@ struct PaymentPixView: View {
|
|||||||
NSPasteboard.general.setString(value, forType: .string)
|
NSPasteboard.general.setString(value, forType: .string)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var parsedExpirationDate: Date? {
|
||||||
|
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard raw.isEmpty == false else { return nil }
|
||||||
|
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = iso.date(from: raw) { return date }
|
||||||
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
|
if let date = iso.date(from: raw) { return date }
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "pt_BR")
|
||||||
|
let formats = [
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||||
|
"dd/MM/yyyy HH:mm:ss",
|
||||||
|
"dd/MM/yyyy HH:mm"
|
||||||
|
]
|
||||||
|
for format in formats {
|
||||||
|
formatter.dateFormat = format
|
||||||
|
if let date = formatter.date(from: raw) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPixExpired: Bool {
|
||||||
|
guard let parsedExpirationDate else { return false }
|
||||||
|
return currentTime >= parsedExpirationDate
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expirationLabel: String {
|
||||||
|
guard let parsedExpirationDate else {
|
||||||
|
return "Expira em: --"
|
||||||
|
}
|
||||||
|
if isPixExpired {
|
||||||
|
return "Expirado"
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime)))
|
||||||
|
let day = 24 * 60 * 60
|
||||||
|
let hour = 60 * 60
|
||||||
|
|
||||||
|
if remaining >= day {
|
||||||
|
let days = remaining / day
|
||||||
|
return "Expira em: \(days) dia(s)"
|
||||||
|
}
|
||||||
|
if remaining >= hour {
|
||||||
|
let hours = remaining / hour
|
||||||
|
return "Expira em: \(hours) hora(s)"
|
||||||
|
}
|
||||||
|
if remaining >= 60 {
|
||||||
|
let minutes = remaining / 60
|
||||||
|
return "Expira em: \(minutes) min"
|
||||||
|
}
|
||||||
|
return "Vai expirar em \(remaining) segundos"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPixExpiredSnackbar() {
|
||||||
|
guard hasShownPixExpiredSnackbar == false else { return }
|
||||||
|
hasShownPixExpiredSnackbar = true
|
||||||
|
SnackbarCenter.shared.show(
|
||||||
|
title: "PIX expirou. Gere um novo pedido para continuar.",
|
||||||
|
style: .warning,
|
||||||
|
icon: "clock.badge.xmark.fill",
|
||||||
|
duration: 4.0
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PaymentCardView: View {
|
struct PaymentCardView: View {
|
||||||
let context: CardPaymentContext
|
let context: CardPaymentContext
|
||||||
|
var onPaymentConfirmed: (() -> Void)? = nil
|
||||||
var onOpenTracking: (() -> Void)? = nil
|
var onOpenTracking: (() -> Void)? = nil
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State var cardHolderName = ""
|
@State var cardHolderName = ""
|
||||||
@@ -723,6 +825,7 @@ struct PaymentCardView: View {
|
|||||||
|
|
||||||
PrimaryButton(title: "Salvar e Pagar") {
|
PrimaryButton(title: "Salvar e Pagar") {
|
||||||
if latestOrder?.isPaymentConfirmed == true {
|
if latestOrder?.isPaymentConfirmed == true {
|
||||||
|
onPaymentConfirmed?()
|
||||||
openTrackingOnce()
|
openTrackingOnce()
|
||||||
} else {
|
} else {
|
||||||
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
|
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
|
||||||
@@ -731,6 +834,7 @@ struct PaymentCardView: View {
|
|||||||
|
|
||||||
Button("Apenas Pagar") {
|
Button("Apenas Pagar") {
|
||||||
if latestOrder?.isPaymentConfirmed == true {
|
if latestOrder?.isPaymentConfirmed == true {
|
||||||
|
onPaymentConfirmed?()
|
||||||
openTrackingOnce()
|
openTrackingOnce()
|
||||||
} else {
|
} else {
|
||||||
SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0)
|
SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0)
|
||||||
@@ -752,6 +856,7 @@ struct PaymentCardView: View {
|
|||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
latestOrder = updated
|
latestOrder = updated
|
||||||
if updated.isPaymentConfirmed {
|
if updated.isPaymentConfirmed {
|
||||||
|
onPaymentConfirmed?()
|
||||||
openTrackingOnce()
|
openTrackingOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -766,7 +871,6 @@ struct PaymentCardView: View {
|
|||||||
guard hasOpenedTracking == false else { return }
|
guard hasOpenedTracking == false else { return }
|
||||||
hasOpenedTracking = true
|
hasOpenedTracking = true
|
||||||
onOpenTracking?()
|
onOpenTracking?()
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
|
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
|
||||||
|
|||||||
@@ -20,18 +20,29 @@ extension HomeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async {
|
func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async {
|
||||||
|
let cacheKey = "public-categories"
|
||||||
|
if forceRefresh == false,
|
||||||
|
let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) {
|
||||||
|
categories = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().listPublicCategories()
|
let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh)
|
||||||
if response.error == false, let remote = response.result, remote.isEmpty == false {
|
if response.error == false, let remote = response.result, remote.isEmpty == false {
|
||||||
categories = mapPublicCategories(remote)
|
let mapped = mapPublicCategories(remote)
|
||||||
|
categories = mapped
|
||||||
|
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback handled below.
|
// Fallback handled below.
|
||||||
}
|
}
|
||||||
|
|
||||||
categories = buildCategories(from: stores)
|
let fallback = buildCategories(from: stores)
|
||||||
|
categories = fallback
|
||||||
|
AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
|
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ struct HomeView: View {
|
|||||||
.onChange(of: addressCacheScope) { _, _ in
|
.onChange(of: addressCacheScope) { _, _ in
|
||||||
guard hasRequestedLocation else { return }
|
guard hasRequestedLocation else { return }
|
||||||
Task {
|
Task {
|
||||||
AppContentCache.shared.invalidate(prefix: "stores:")
|
|
||||||
AppContentCache.shared.invalidate(prefix: "store-info:")
|
|
||||||
AppContentCache.shared.invalidate(prefix: "store-catalog:")
|
|
||||||
AppImageCache.shared.invalidateAll()
|
|
||||||
await bootstrapStoresFlow(
|
await bootstrapStoresFlow(
|
||||||
forceLocationRefresh: true,
|
forceLocationRefresh: true,
|
||||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
category: selectedCategory == "all" ? nil : selectedCategory,
|
||||||
@@ -435,7 +431,7 @@ struct HomeView: View {
|
|||||||
isLoadingStores = false
|
isLoadingStores = false
|
||||||
stores = cachedStores
|
stores = cachedStores
|
||||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||||
await loadHomeCategories(withFallbackStores: cachedStores)
|
await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
|
||||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||||
selectedCategory = "all"
|
selectedCategory = "all"
|
||||||
}
|
}
|
||||||
@@ -457,9 +453,9 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
let results = response.result ?? []
|
let results = response.result ?? []
|
||||||
stores = results
|
stores = results
|
||||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180)
|
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
||||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||||
await loadHomeCategories(withFallbackStores: results)
|
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
|
||||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||||
selectedCategory = "all"
|
selectedCategory = "all"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private struct TrackingStep: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let time: String?
|
||||||
|
let isCompleted: Bool
|
||||||
|
let isActive: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct OrderTrackingView: View {
|
struct OrderTrackingView: View {
|
||||||
let orderId: String
|
let orderId: String
|
||||||
let initialShortId: String?
|
let initialShortId: String?
|
||||||
@@ -8,38 +17,30 @@ struct OrderTrackingView: View {
|
|||||||
@State var errorMessage: String? = nil
|
@State var errorMessage: String? = nil
|
||||||
@State var order: PublicOrderResult? = nil
|
@State var order: PublicOrderResult? = nil
|
||||||
@State var tracker = OrderRealtimeTracker()
|
@State var tracker = OrderRealtimeTracker()
|
||||||
|
@State var showCancellationReason = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
headerCard
|
topHeader
|
||||||
|
orderTitleSection
|
||||||
if isLoading {
|
statusBanner
|
||||||
ProgressView()
|
timelineSection
|
||||||
.padding(.top, 20)
|
placeholderCard
|
||||||
}
|
contactButton
|
||||||
|
|
||||||
if let errorMessage, errorMessage.isEmpty == false {
|
|
||||||
Text(errorMessage)
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.foregroundStyle(Color.red)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let order {
|
|
||||||
timelineCard(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
footerPlaceholderCard
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
.padding(.bottom, UIDevice.bottomNotch + 45)
|
||||||
}
|
}
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||||
|
Button("Fechar", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(cancellationReasonText)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await loadInitialOrder()
|
await loadInitialOrder()
|
||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
@@ -54,67 +55,103 @@ struct OrderTrackingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var displayOrderTitle: String {
|
private var topHeader: some View {
|
||||||
if let short = order?.shortId, short.isEmpty == false { return "#\(short)" }
|
HStack(spacing: 10) {
|
||||||
if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" }
|
Circle()
|
||||||
return "#\(orderId.prefix(6))"
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.35))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
)
|
||||||
|
Text("Acompanhamento em tempo real")
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background(AppColors.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var headerCard: some View {
|
private var orderTitleSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(statusTitle)
|
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
|
||||||
.font(AppTypography.heading1)
|
.font(AppTypography.heading1)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)")
|
Text("Pedido #\(displayOrderTitle)")
|
||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
if let order, order.isInDeliveryRoute, let otp = order.displayOtpCode {
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
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 {
|
@ViewBuilder
|
||||||
Spacer()
|
private var statusBanner: some View {
|
||||||
Text(statusPill)
|
if let errorMessage, errorMessage.isEmpty == false {
|
||||||
.font(.system(size: 13, weight: .bold))
|
statusBadge(
|
||||||
.foregroundStyle(AppColors.primary)
|
title: errorMessage,
|
||||||
.padding(.horizontal, 12)
|
fg: Color.red,
|
||||||
.padding(.vertical, 6)
|
bg: Color.red.opacity(0.12),
|
||||||
.background(AppColors.brandSoft)
|
icon: "xmark.octagon.fill"
|
||||||
.clipShape(Capsule())
|
)
|
||||||
|
} 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)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(16)
|
} else if isWaitingPayment {
|
||||||
.background(AppColors.surface)
|
statusBadge(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timelineCard(_ order: PublicOrderResult) -> some View {
|
private var timelineSection: some View {
|
||||||
let events: [PublicOrderTimelineEvent] = {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
if order.timeline.isEmpty == false { return order.timeline }
|
Text("Progresso do Pedido")
|
||||||
return [
|
|
||||||
PublicOrderTimelineEvent(
|
|
||||||
status: order.status ?? order.paymentStatus ?? "PENDING",
|
|
||||||
message: nil,
|
|
||||||
time: order.updatedAt ?? order.createdAt
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}()
|
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Acompanhamento")
|
|
||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
if let order {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(Array(events.enumerated()), id: \.offset) { index, event in
|
ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in
|
||||||
timelineRow(event: event, isLast: index == events.count - 1)
|
timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,112 +161,473 @@ struct OrderTrackingView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View {
|
private var placeholderCard: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(AppColors.brandSoft)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: summaryStatusIcon)
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
)
|
||||||
|
Text(summaryStatusTitle)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if hasTrackingImage {
|
||||||
|
Image(trackingImageName)
|
||||||
|
.renderingMode(.original)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else if hasPlaceholderProductImage {
|
||||||
|
Image("placeholder-product")
|
||||||
|
.renderingMode(.original)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.08)
|
||||||
|
Image(systemName: "shippingbox.fill")
|
||||||
|
.font(.system(size: 52, weight: .bold))
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 220)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(16)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contactButton: some View {
|
||||||
|
Button("CONTATO") {}
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 56)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
Text(title)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
}
|
||||||
|
.foregroundStyle(fg)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(bg)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timelineRow(step: TrackingStep, isLast: Bool) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(AppColors.tertiary)
|
.fill(stepDotColor(step))
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
.overlay(
|
.overlay(
|
||||||
|
Group {
|
||||||
|
if step.isCompleted {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
.font(.system(size: 11, weight: .bold))
|
.font(.system(size: 11, weight: .bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
} else if step.isActive {
|
||||||
|
Circle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color(hex: "#C5CBD4"), lineWidth: 2)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if isLast == false {
|
if isLast == false {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppColors.tertiary.opacity(0.45))
|
.fill(stepLineColor(step))
|
||||||
.frame(width: 2, height: 30)
|
.frame(width: 2, height: 36)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado"))
|
Text(step.title)
|
||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading2)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(stepTitleColor(step))
|
||||||
Text(formatTime(event.time) ?? "")
|
|
||||||
|
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)
|
.font(AppTypography.caption)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footerPlaceholderCard: some View {
|
private func stepDotColor(_ step: TrackingStep) -> Color {
|
||||||
VStack(spacing: 10) {
|
if isCanceled {
|
||||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
return Color(hex: "#C5CBD4")
|
||||||
.fill(Color.black.opacity(0.9))
|
}
|
||||||
.frame(height: 180)
|
if isWaitingPayment && step.id == "paid" {
|
||||||
.overlay(
|
return Color(hex: "#F59E0B")
|
||||||
Image(systemName: "shippingbox.fill")
|
}
|
||||||
.font(.system(size: 56, weight: .bold))
|
if step.isCompleted || step.isActive {
|
||||||
.foregroundStyle(AppColors.tertiary)
|
return AppColors.primary
|
||||||
|
}
|
||||||
|
return Color(hex: "#E5E7EB")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 stepSubtitleColor(_ step: TrackingStep) -> Color {
|
||||||
|
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||||
|
if isWaitingPayment && step.id == "paid" {
|
||||||
|
return Color(hex: "#A16207")
|
||||||
|
}
|
||||||
|
if step.isCompleted || step.isActive {
|
||||||
|
return AppColors.primary
|
||||||
|
}
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
Text("Acompanhe seu pedido em tempo real")
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.foregroundStyle(AppColors.textMuted)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
.background(AppColors.surface)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusTitle: String {
|
private func currentStepIndex(isPickup: Bool) -> Int {
|
||||||
humanReadableStatus(order?.status ?? "Aguardando")
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusPill: String {
|
if isWaitingPayment {
|
||||||
let value = order?.paymentStatus ?? order?.status ?? "PENDING"
|
return 0
|
||||||
return humanReadableStatus(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func humanReadableStatus(_ raw: String) -> String {
|
if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) {
|
||||||
let normalized = raw.uppercased()
|
return timelineIndex
|
||||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" {
|
|
||||||
return "Aguardando pagamento"
|
|
||||||
}
|
}
|
||||||
if normalized.contains("CONFIRMED") {
|
|
||||||
return "Pagamento confirmado"
|
if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") {
|
||||||
|
return isPickup ? 3 : 4
|
||||||
}
|
}
|
||||||
if normalized.contains("PREPAR") {
|
if isPickup {
|
||||||
return "Em preparo"
|
if normalizedStatus.contains("READY") { return 2 }
|
||||||
|
if normalizedStatus.contains("PREPAR") { return 1 }
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") {
|
|
||||||
return "Em rota de entrega"
|
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"
|
||||||
}
|
}
|
||||||
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
|
|
||||||
return "Pedido entregue"
|
return "Pedido entregue"
|
||||||
}
|
}
|
||||||
if normalized.contains("CANCEL") {
|
if isOnlinePaymentMethod {
|
||||||
return "Pedido cancelado"
|
return "Pagamento confirmado"
|
||||||
}
|
}
|
||||||
return raw.capitalized
|
return "Pedido confirmado"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTime(_ isoValue: String?) -> String? {
|
private var summaryStatusTitle: String {
|
||||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
if isCanceled {
|
||||||
|
return "Seu pedido foi cancelado"
|
||||||
|
}
|
||||||
|
if isWaitingPayment {
|
||||||
|
return "Aguardando confirmação de pagamento"
|
||||||
|
}
|
||||||
|
if isCompletedOrder {
|
||||||
|
return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue"
|
||||||
|
}
|
||||||
|
return "Seu pedido está em andamento"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryStatusIcon: String {
|
||||||
|
if isCanceled {
|
||||||
|
return "xmark"
|
||||||
|
}
|
||||||
|
if isWaitingPayment {
|
||||||
|
return "clock.fill"
|
||||||
|
}
|
||||||
|
return "checkmark"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPickupOrder: Bool {
|
||||||
|
normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCompletedOrder: Bool {
|
||||||
|
let status = normalized(order?.status)
|
||||||
|
if status.contains("COMPLETED") || status.contains("DELIVERED") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let stepIndex = currentStepIndex(isPickup: isPickupOrder)
|
||||||
|
let terminalIndex = isPickupOrder ? 3 : 4
|
||||||
|
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancellationReasonText: String {
|
||||||
|
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? "Sem detalhe informado." : value
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasTrackingImage: Bool {
|
||||||
|
imageResourceExists(trackingImageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasPlaceholderProductImage: Bool {
|
||||||
|
imageResourceExists("placeholder-product")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func imageResourceExists(_ name: String) -> Bool {
|
||||||
|
let exts = ["png", "jpg", "jpeg", "webp"]
|
||||||
|
for ext in exts {
|
||||||
|
if Bundle.main.url(forResource: name, withExtension: ext) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if Bundle.module.url(forResource: name, withExtension: ext) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trackingImageName: String {
|
||||||
|
let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||||
|
let stepIndex = currentStepIndex(isPickup: isPickup)
|
||||||
|
|
||||||
|
if isCanceled {
|
||||||
|
return "tracking-canceled"
|
||||||
|
}
|
||||||
|
if isWaitingPayment {
|
||||||
|
return "tracking-pending"
|
||||||
|
}
|
||||||
|
switch stepIndex {
|
||||||
|
case 0:
|
||||||
|
return "tracking-pending"
|
||||||
|
case 1:
|
||||||
|
return "tracking-preparing"
|
||||||
|
case 2:
|
||||||
|
return isPickup ? "tracking-ready" : "tracking-preparing"
|
||||||
|
case 3:
|
||||||
|
return "tracking-delivering"
|
||||||
|
default:
|
||||||
|
return "tracking-completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalized(_ value: String?) -> String {
|
||||||
|
(value ?? "")
|
||||||
|
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||||
|
.uppercased()
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ rawValue: String?) -> String? {
|
||||||
|
guard let rawValue else { return nil }
|
||||||
|
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value.isEmpty { return nil }
|
||||||
|
|
||||||
|
if value.contains("T"), let isoTime = formatISOTime(value) {
|
||||||
|
return isoTime
|
||||||
|
}
|
||||||
|
if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil {
|
||||||
|
return String(value.prefix(5))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatISOTime(_ value: String) -> String? {
|
||||||
let iso = ISO8601DateFormatter()
|
let iso = ISO8601DateFormatter()
|
||||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
var date = iso.date(from: value)
|
||||||
var date: Date? = iso.date(from: isoValue)
|
|
||||||
if date == nil {
|
if date == nil {
|
||||||
iso.formatOptions = [.withInternetDateTime]
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
date = iso.date(from: isoValue)
|
date = iso.date(from: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if date == nil {
|
|
||||||
let fallback = DateFormatter()
|
|
||||||
fallback.locale = Locale(identifier: "pt_BR")
|
|
||||||
fallback.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
date = fallback.date(from: isoValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let date else { return nil }
|
guard let date else { return nil }
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "pt_BR")
|
formatter.locale = Locale(identifier: "pt_BR")
|
||||||
formatter.dateFormat = "HH:mm"
|
formatter.dateFormat = "HH:mm"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ struct OrdersView: View {
|
|||||||
@State var isLoading = false
|
@State var isLoading = false
|
||||||
@State var errorMessage: String? = nil
|
@State var errorMessage: String? = nil
|
||||||
@State var orders: [AppOrderSummary] = []
|
@State var orders: [AppOrderSummary] = []
|
||||||
|
@State var hasLoadedOnce = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -31,8 +32,14 @@ struct OrdersView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
} else {
|
} else {
|
||||||
ForEach(orders) { order in
|
ForEach(orders) { order in
|
||||||
|
let trackingId = trackingOrderId(for: order)
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
OrderTrackingView(orderId: order.id, initialShortId: order.shortId)
|
OrderEntryDestinationView(
|
||||||
|
orderId: trackingId,
|
||||||
|
initialShortId: order.shortId,
|
||||||
|
fallbackPaymentMethod: order.paymentMethod,
|
||||||
|
fallbackTotal: order.total
|
||||||
|
)
|
||||||
} label: {
|
} label: {
|
||||||
orderRow(order)
|
orderRow(order)
|
||||||
}
|
}
|
||||||
@@ -47,7 +54,10 @@ struct OrdersView: View {
|
|||||||
.navigationTitle("Meus Pedidos")
|
.navigationTitle("Meus Pedidos")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task {
|
.task {
|
||||||
await loadOrders()
|
await loadOrdersIfNeeded()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await loadOrders(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ struct OrdersView: View {
|
|||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente"))
|
Text(humanReadableStatus(for: order))
|
||||||
.font(AppTypography.caption)
|
.font(AppTypography.caption)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
@@ -89,11 +99,41 @@ struct OrdersView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func humanReadableStatus(_ raw: String) -> String {
|
private func humanReadableStatus(for order: AppOrderSummary) -> String {
|
||||||
|
let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if explicitLabel.isEmpty == false {
|
||||||
|
return explicitLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
let detailed = (order.statusDetailed ?? "").uppercased()
|
||||||
|
switch detailed {
|
||||||
|
case "PENDING_PAYMENT":
|
||||||
|
return "Aguardando pagamento"
|
||||||
|
case "PENDING_PREPARATION":
|
||||||
|
return "Pendente de preparo"
|
||||||
|
case "PREPARING":
|
||||||
|
return "Em preparo"
|
||||||
|
case "PENDING_DELIVERY":
|
||||||
|
return "Pendente de entrega"
|
||||||
|
case "READY_FOR_PICKUP":
|
||||||
|
return "Pronto para retirada"
|
||||||
|
case "IN_DELIVERY":
|
||||||
|
return "Em rota"
|
||||||
|
case "COMPLETED":
|
||||||
|
return "Entregue"
|
||||||
|
case "CANCELED":
|
||||||
|
return "Cancelado"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = order.status ?? order.paymentStatus ?? "Pendente"
|
||||||
let normalized = raw.uppercased()
|
let normalized = raw.uppercased()
|
||||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { return "Aguardando pagamento" }
|
if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" }
|
||||||
if normalized.contains("CONFIRMED") { return "Confirmado" }
|
if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" }
|
||||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("ROTA") { return "Em rota" }
|
if normalized.contains("PREPARING") { return "Em preparo" }
|
||||||
|
if normalized.contains("READY") { return "Pendente de entrega" }
|
||||||
|
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("DELIVERING") || normalized.contains("ROTA") { return "Em rota" }
|
||||||
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" }
|
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" }
|
||||||
if normalized.contains("CANCEL") { return "Cancelado" }
|
if normalized.contains("CANCEL") { return "Cancelado" }
|
||||||
return raw.capitalized
|
return raw.capitalized
|
||||||
@@ -120,46 +160,177 @@ struct OrdersView: View {
|
|||||||
return formatter.string(from: date)
|
return formatter.string(from: date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func trackingOrderId(for order: AppOrderSummary) -> String {
|
||||||
|
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if orderCandidate.isEmpty == false {
|
||||||
|
return orderCandidate
|
||||||
|
}
|
||||||
|
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if candidate.isEmpty == false {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return order.id
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadOrders() async {
|
private func loadOrdersIfNeeded() async {
|
||||||
|
guard hasLoadedOnce == false else { return }
|
||||||
|
await loadOrders(force: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadOrders(force: Bool) async {
|
||||||
|
if isLoading { return }
|
||||||
|
if force == false, hasLoadedOnce { return }
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
let previousOrders = orders
|
||||||
|
|
||||||
|
var trackedMapped: [AppOrderSummary] = []
|
||||||
let cachedTracked = SessionStateStore.loadTrackedOrders()
|
let cachedTracked = SessionStateStore.loadTrackedOrders()
|
||||||
if cachedTracked.isEmpty == false {
|
if cachedTracked.isEmpty == false {
|
||||||
let mapped = cachedTracked.map {
|
trackedMapped = cachedTracked.map {
|
||||||
AppOrderSummary.fromTracked($0)
|
AppOrderSummary.fromTracked($0)
|
||||||
}
|
}
|
||||||
orders = mergeOrders(apiOrders: orders, trackedOrders: mapped)
|
// Use tracked-only list as bootstrap data only on first load.
|
||||||
|
if hasLoadedOnce == false, previousOrders.isEmpty {
|
||||||
|
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await ApiService().listOrders()
|
let response = try await ApiService().listOrders(forceRefresh: force)
|
||||||
if response.error {
|
if response.error {
|
||||||
|
if previousOrders.isEmpty == false {
|
||||||
|
orders = previousOrders
|
||||||
|
}
|
||||||
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
|
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
|
||||||
} else {
|
} else {
|
||||||
let remote = response.result ?? []
|
let remote = response.result ?? []
|
||||||
orders = mergeOrders(apiOrders: remote, trackedOrders: orders)
|
orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if isCancelledRequest(error) {
|
||||||
|
if previousOrders.isEmpty == false {
|
||||||
|
orders = previousOrders
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if orders.isEmpty {
|
if orders.isEmpty {
|
||||||
errorMessage = "Não foi possível carregar os pedidos."
|
errorMessage = "Não foi possível carregar os pedidos."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
hasLoadedOnce = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
|
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
|
||||||
var map: [String: AppOrderSummary] = [:]
|
var map: [String: AppOrderSummary] = [:]
|
||||||
for item in trackedOrders { map[item.id] = item }
|
var sourceRank: [String: Int] = [:]
|
||||||
for item in apiOrders { map[item.id] = item }
|
|
||||||
|
for (index, item) in trackedOrders.enumerated() {
|
||||||
|
let key = identityKey(for: item)
|
||||||
|
map[key] = item
|
||||||
|
if sourceRank[key] == nil {
|
||||||
|
sourceRank[key] = 10_000 + index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API order is authoritative for fallback ordering (usually newest first).
|
||||||
|
for (index, item) in apiOrders.enumerated() {
|
||||||
|
let key = identityKey(for: item)
|
||||||
|
map[key] = item
|
||||||
|
sourceRank[key] = index
|
||||||
|
}
|
||||||
|
|
||||||
return map.values.sorted { lhs, rhs in
|
return map.values.sorted { lhs, rhs in
|
||||||
let left = lhs.updatedAt ?? lhs.createdAt ?? ""
|
let leftDate = orderDateSortValue(lhs)
|
||||||
let right = rhs.updatedAt ?? rhs.createdAt ?? ""
|
let rightDate = orderDateSortValue(rhs)
|
||||||
return left > right
|
if leftDate != rightDate {
|
||||||
|
return leftDate > rightDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
|
||||||
|
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
|
||||||
|
if leftRank != rightRank {
|
||||||
|
return leftRank < rightRank
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftNumericId = Int(lhs.id)
|
||||||
|
let rightNumericId = Int(rhs.id)
|
||||||
|
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
|
||||||
|
return leftNumericId > rightNumericId
|
||||||
|
}
|
||||||
|
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func identityKey(for order: AppOrderSummary) -> String {
|
||||||
|
let raw = trackingOrderId(for: order)
|
||||||
|
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
|
||||||
|
parseDateForSort(order.updatedAt)
|
||||||
|
?? parseDateForSort(order.createdAt)
|
||||||
|
?? .distantPast
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseDateForSort(_ rawValue: String?) -> Date? {
|
||||||
|
guard let rawValue else { return nil }
|
||||||
|
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value.isEmpty { return nil }
|
||||||
|
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = iso.date(from: value) { return date }
|
||||||
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
|
if let date = iso.date(from: value) { return date }
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "pt_BR")
|
||||||
|
|
||||||
|
let formats = [
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||||
|
"yyyy-MM-dd HH:mm:ss Z",
|
||||||
|
"dd/MM/yyyy HH:mm:ss",
|
||||||
|
"dd/MM/yyyy HH:mm",
|
||||||
|
"dd/MM/yyyy"
|
||||||
|
]
|
||||||
|
for format in formats {
|
||||||
|
formatter.dateFormat = format
|
||||||
|
if let date = formatter.date(from: value) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||||
|
if error is CancellationError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let networkError = error as? NetworkError,
|
||||||
|
case .transportError(let message) = networkError {
|
||||||
|
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return normalized.contains("cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let networkError = error as? NetworkError,
|
||||||
|
case .cancelled = networkError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.localizedDescription.lowercased().contains("cancel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +338,14 @@ extension AppOrderSummary {
|
|||||||
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
|
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
|
||||||
AppOrderSummary(
|
AppOrderSummary(
|
||||||
id: tracked.id,
|
id: tracked.id,
|
||||||
|
orderId: tracked.realId ?? tracked.id,
|
||||||
|
realId: tracked.realId,
|
||||||
shortId: tracked.shortId,
|
shortId: tracked.shortId,
|
||||||
total: tracked.total,
|
total: tracked.total,
|
||||||
status: tracked.status,
|
status: tracked.status,
|
||||||
|
statusDetailed: nil,
|
||||||
|
statusLabel: nil,
|
||||||
|
nextAction: nil,
|
||||||
paymentStatus: tracked.paymentStatus,
|
paymentStatus: tracked.paymentStatus,
|
||||||
paymentMethod: tracked.paymentMethod,
|
paymentMethod: tracked.paymentMethod,
|
||||||
deliveryType: tracked.deliveryType,
|
deliveryType: tracked.deliveryType,
|
||||||
@@ -181,9 +357,14 @@ extension AppOrderSummary {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
id: String,
|
id: String,
|
||||||
|
orderId: String?,
|
||||||
|
realId: String?,
|
||||||
shortId: String?,
|
shortId: String?,
|
||||||
total: Double?,
|
total: Double?,
|
||||||
status: String?,
|
status: String?,
|
||||||
|
statusDetailed: String?,
|
||||||
|
statusLabel: String?,
|
||||||
|
nextAction: String?,
|
||||||
paymentStatus: String?,
|
paymentStatus: String?,
|
||||||
paymentMethod: String?,
|
paymentMethod: String?,
|
||||||
deliveryType: String?,
|
deliveryType: String?,
|
||||||
@@ -192,9 +373,14 @@ extension AppOrderSummary {
|
|||||||
updatedAt: String?
|
updatedAt: String?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.orderId = orderId
|
||||||
|
self.realId = realId
|
||||||
self.shortId = shortId
|
self.shortId = shortId
|
||||||
self.total = total
|
self.total = total
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.statusDetailed = statusDetailed
|
||||||
|
self.statusLabel = statusLabel
|
||||||
|
self.nextAction = nextAction
|
||||||
self.paymentStatus = paymentStatus
|
self.paymentStatus = paymentStatus
|
||||||
self.paymentMethod = paymentMethod
|
self.paymentMethod = paymentMethod
|
||||||
self.deliveryType = deliveryType
|
self.deliveryType = deliveryType
|
||||||
@@ -203,3 +389,150 @@ extension AppOrderSummary {
|
|||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct OrderEntryDestinationView: View {
|
||||||
|
let orderId: String
|
||||||
|
let initialShortId: String?
|
||||||
|
let fallbackPaymentMethod: String?
|
||||||
|
let fallbackTotal: Double?
|
||||||
|
|
||||||
|
@State var isResolvingRoute = true
|
||||||
|
@State var didResolve = false
|
||||||
|
@State var pixContext: PixPaymentContext? = nil
|
||||||
|
@State var cardContext: CardPaymentContext? = nil
|
||||||
|
@State var orderTrackingContext: OrderTrackingContext? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isResolvingRoute {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Carregando pedido...")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppColors.backgroundLight)
|
||||||
|
} else if let pixContext {
|
||||||
|
PaymentPixView(
|
||||||
|
context: pixContext,
|
||||||
|
onPaymentConfirmed: {
|
||||||
|
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||||
|
},
|
||||||
|
onOpenTracking: {
|
||||||
|
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if let cardContext {
|
||||||
|
PaymentCardView(
|
||||||
|
context: cardContext,
|
||||||
|
onPaymentConfirmed: {
|
||||||
|
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||||
|
},
|
||||||
|
onOpenTracking: {
|
||||||
|
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $orderTrackingContext) { context in
|
||||||
|
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard didResolve == false else { return }
|
||||||
|
didResolve = true
|
||||||
|
await resolveRoute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resolveRoute() async {
|
||||||
|
defer { isResolvingRoute = false }
|
||||||
|
|
||||||
|
let order = await fetchOrderForRouting()
|
||||||
|
guard let order else { return }
|
||||||
|
guard order.isPaymentConfirmed == false else { return }
|
||||||
|
guard isOnlinePaymentMethod(order) else { return }
|
||||||
|
|
||||||
|
let normalizedMethod = normalizePaymentMethod(order)
|
||||||
|
let normalizedStatus = normalize(order.paymentStatus)
|
||||||
|
if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedMethod.contains("PIX") {
|
||||||
|
let pixFromPayment = order.payment?.pix
|
||||||
|
let pixFromPayload = order.paymentPayload
|
||||||
|
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? pixFromPayment?.copyPaste
|
||||||
|
: pixFromPayload?.copyPaste
|
||||||
|
|
||||||
|
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? pixFromPayment?.qrCodeImage
|
||||||
|
: pixFromPayload?.qrCodeImage
|
||||||
|
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? pixFromPayment?.expirationDate
|
||||||
|
: pixFromPayload?.expirationDate
|
||||||
|
|
||||||
|
pixContext = PixPaymentContext(
|
||||||
|
id: order.id,
|
||||||
|
orderId: order.id,
|
||||||
|
shortId: order.shortId ?? initialShortId,
|
||||||
|
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||||
|
? (copyPaste ?? "")
|
||||||
|
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||||
|
qrCodeImageBase64: qrCodeImage,
|
||||||
|
expirationDate: expirationDate
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") {
|
||||||
|
cardContext = CardPaymentContext(
|
||||||
|
orderId: order.id,
|
||||||
|
shortId: order.shortId ?? initialShortId,
|
||||||
|
total: order.total ?? fallbackTotal ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func fetchOrderForRouting() async -> PublicOrderResult? {
|
||||||
|
do {
|
||||||
|
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||||
|
if response.error == false, let result = response.result {
|
||||||
|
SessionStateStore.saveTrackedOrder(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to local cache when remote call fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return SessionStateStore.loadTrackedOrder(orderId: orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
|
||||||
|
let method = normalizePaymentMethod(order)
|
||||||
|
return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
|
||||||
|
let first = normalize(order.paymentMethodCode)
|
||||||
|
if first.isEmpty == false {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
let second = normalize(order.paymentMethod)
|
||||||
|
if second.isEmpty == false {
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
return normalize(fallbackPaymentMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(_ value: String?) -> String {
|
||||||
|
(value ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ extension StoreDetailView {
|
|||||||
categories = catalogResponse.result ?? []
|
categories = catalogResponse.result ?? []
|
||||||
selectedCategoryId = categories.first?.id
|
selectedCategoryId = categories.first?.id
|
||||||
if let info = infoResponse.result {
|
if let info = infoResponse.result {
|
||||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300)
|
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
}
|
}
|
||||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300)
|
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
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 |