payment and list
This commit is contained in:
@@ -7,7 +7,9 @@ struct CartView: View {
|
||||
@State var couponCode = ""
|
||||
@State var appliedCouponCode: String? = nil
|
||||
@State var discountValue: Double = 0
|
||||
@State var deliveryFee: Double = 5
|
||||
@State var deliveryFee: Double? = nil
|
||||
@State var selectedCustomerAddress: CustomerAddress? = nil
|
||||
@State var isLoadingDeliveryFee = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -50,6 +52,9 @@ struct CartView: View {
|
||||
.navigationDestination(isPresented: $openCheckout) {
|
||||
CheckoutView(appState: $appState)
|
||||
}
|
||||
.task(id: deliveryFeeWatchKey) {
|
||||
await refreshDeliveryFee()
|
||||
}
|
||||
}
|
||||
|
||||
private var subtotalValue: Double {
|
||||
@@ -57,7 +62,16 @@ struct CartView: View {
|
||||
}
|
||||
|
||||
private var totalValue: Double {
|
||||
max(0, subtotalValue + deliveryFee - discountValue)
|
||||
max(0, subtotalValue + (deliveryFee ?? 0) - discountValue)
|
||||
}
|
||||
|
||||
private var deliveryFeeWatchKey: String {
|
||||
let storeId = appState.cart.storeId ?? "nil"
|
||||
let selectedId = appState.address.selectedId ?? "nil"
|
||||
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
private var couponSection: some View {
|
||||
@@ -108,7 +122,7 @@ struct CartView: View {
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
||||
summaryRow(title: "Taxa de Entrega", value: formatCurrency(deliveryFee))
|
||||
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
|
||||
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
|
||||
|
||||
Divider()
|
||||
@@ -136,6 +150,16 @@ struct CartView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
}
|
||||
|
||||
private var deliveryFeeLabel: String {
|
||||
if isLoadingDeliveryFee {
|
||||
return "Calculando..."
|
||||
}
|
||||
if let deliveryFee {
|
||||
return formatCurrency(deliveryFee)
|
||||
}
|
||||
return "Indisponível"
|
||||
}
|
||||
|
||||
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
@@ -230,4 +254,88 @@ struct CartView: View {
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshDeliveryFee() async {
|
||||
guard appState.cart.items.isEmpty == false else {
|
||||
deliveryFee = nil
|
||||
selectedCustomerAddress = nil
|
||||
return
|
||||
}
|
||||
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
|
||||
deliveryFee = nil
|
||||
selectedCustomerAddress = nil
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingDeliveryFee = true
|
||||
defer { isLoadingDeliveryFee = false }
|
||||
|
||||
do {
|
||||
let profileResponse = try await ApiService().profile()
|
||||
let addresses = profileResponse.result?.addressBook ?? []
|
||||
|
||||
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
|
||||
} else {
|
||||
selectedCustomerAddress = nil
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil {
|
||||
let display = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if display.isEmpty == false, display != "defina seu endereco" {
|
||||
selectedCustomerAddress = addresses.first {
|
||||
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil {
|
||||
selectedCustomerAddress = addresses.first
|
||||
}
|
||||
|
||||
if let selected = selectedCustomerAddress {
|
||||
appState.address.selectedId = selected.id
|
||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
appState.address.display = label
|
||||
}
|
||||
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
||||
appState.address.latitude = lat
|
||||
appState.address.longitude = lng
|
||||
}
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
}
|
||||
|
||||
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
|
||||
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
|
||||
|
||||
let payload = ValidateDeliveryAddressPayload(
|
||||
address: ValidateDeliveryAddressDataPayload(
|
||||
street: selectedCustomerAddress?.address,
|
||||
number: selectedCustomerAddress?.number,
|
||||
neighborhood: selectedCustomerAddress?.neighborhood,
|
||||
city: selectedCustomerAddress?.city,
|
||||
state: selectedCustomerAddress?.state,
|
||||
zip: selectedCustomerAddress?.zipCode,
|
||||
lat: payloadLat,
|
||||
lng: payloadLng
|
||||
)
|
||||
)
|
||||
|
||||
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
|
||||
guard validationResponse.error == false,
|
||||
validationResponse.result?.deliveryAllowed == true,
|
||||
let fee = validationResponse.result?.deliveryFee else {
|
||||
deliveryFee = nil
|
||||
return
|
||||
}
|
||||
|
||||
deliveryFee = fee
|
||||
} catch {
|
||||
deliveryFee = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +260,23 @@ extension CheckoutView {
|
||||
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
||||
|
||||
let orderId = result.id ?? UUID().uuidString
|
||||
if useInAppPayment == false {
|
||||
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
|
||||
if useInAppPayment == false || isInAppMethod == false {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
return
|
||||
}
|
||||
|
||||
if orderSnapshot.isPaymentConfirmed {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
return
|
||||
}
|
||||
|
||||
SessionStateStore.savePendingCartOrderId(orderId)
|
||||
|
||||
if effectivePaymentMethod == .creditCard {
|
||||
cardPaymentContext = CardPaymentContext(
|
||||
orderId: orderId,
|
||||
|
||||
@@ -152,12 +152,28 @@ struct CheckoutView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $pixPaymentContext) { context in
|
||||
PaymentPixView(context: context) {
|
||||
PaymentPixView(
|
||||
context: context,
|
||||
onPaymentConfirmed: {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
}
|
||||
) {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $cardPaymentContext) { context in
|
||||
PaymentCardView(context: context) {
|
||||
PaymentCardView(
|
||||
context: context,
|
||||
onPaymentConfirmed: {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
}
|
||||
) {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
|
||||
}
|
||||
}
|
||||
@@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable {
|
||||
|
||||
struct PaymentPixView: View {
|
||||
let context: PixPaymentContext
|
||||
var onPaymentConfirmed: (() -> Void)? = nil
|
||||
var onOpenTracking: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var latestOrder: PublicOrderResult? = nil
|
||||
@State var hasOpenedTracking = false
|
||||
@State var hasShownPixExpiredSnackbar = false
|
||||
@State var currentTime = Date()
|
||||
|
||||
private var qrImageSource: String? {
|
||||
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -590,51 +609,51 @@ struct PaymentPixView: View {
|
||||
Text("AGUARDANDO PAGAMENTO")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 360)
|
||||
.frame(maxWidth: .infinity, minHeight: 380)
|
||||
|
||||
Text("Código PIX")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
||||
.fill(AppColors.surface)
|
||||
.frame(height: 20)
|
||||
.overlay(
|
||||
VStack(spacing: 8) {
|
||||
Text("Código PIX")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text(context.copyPaste)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(3)
|
||||
.lineLimit(0)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 12)
|
||||
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
||||
Text("Expira em: \(expirationDate)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
)
|
||||
|
||||
|
||||
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
||||
Text(expirationLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
|
||||
}
|
||||
|
||||
PrimaryButton(title: "Copiar Código PIX") {
|
||||
if isPixExpired {
|
||||
showPixExpiredSnackbar()
|
||||
return
|
||||
}
|
||||
copyToClipboard(context.copyPaste)
|
||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
||||
}
|
||||
|
||||
Button("Já realizei o pagamento") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
openTrackingOnce()
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
|
||||
}
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 8)
|
||||
.disabled(isPixExpired)
|
||||
.opacity(isPixExpired ? 0.5 : 1.0)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
@@ -645,11 +664,22 @@ struct PaymentPixView: View {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
if updated.isPaymentConfirmed {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
}
|
||||
}
|
||||
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
|
||||
}
|
||||
.task {
|
||||
while Task.isCancelled == false {
|
||||
currentTime = Date()
|
||||
if isPixExpired {
|
||||
showPixExpiredSnackbar()
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
tracker.stop()
|
||||
}
|
||||
@@ -659,7 +689,6 @@ struct PaymentPixView: View {
|
||||
guard hasOpenedTracking == false else { return }
|
||||
hasOpenedTracking = true
|
||||
onOpenTracking?()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ value: String) {
|
||||
@@ -670,10 +699,83 @@ struct PaymentPixView: View {
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var parsedExpirationDate: Date? {
|
||||
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard raw.isEmpty == false else { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = iso.date(from: raw) { return date }
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
if let date = iso.date(from: raw) { return date }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
let formats = [
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"dd/MM/yyyy HH:mm"
|
||||
]
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: raw) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private var isPixExpired: Bool {
|
||||
guard let parsedExpirationDate else { return false }
|
||||
return currentTime >= parsedExpirationDate
|
||||
}
|
||||
|
||||
private var expirationLabel: String {
|
||||
guard let parsedExpirationDate else {
|
||||
return "Expira em: --"
|
||||
}
|
||||
if isPixExpired {
|
||||
return "Expirado"
|
||||
}
|
||||
|
||||
let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime)))
|
||||
let day = 24 * 60 * 60
|
||||
let hour = 60 * 60
|
||||
|
||||
if remaining >= day {
|
||||
let days = remaining / day
|
||||
return "Expira em: \(days) dia(s)"
|
||||
}
|
||||
if remaining >= hour {
|
||||
let hours = remaining / hour
|
||||
return "Expira em: \(hours) hora(s)"
|
||||
}
|
||||
if remaining >= 60 {
|
||||
let minutes = remaining / 60
|
||||
return "Expira em: \(minutes) min"
|
||||
}
|
||||
return "Vai expirar em \(remaining) segundos"
|
||||
}
|
||||
|
||||
private func showPixExpiredSnackbar() {
|
||||
guard hasShownPixExpiredSnackbar == false else { return }
|
||||
hasShownPixExpiredSnackbar = true
|
||||
SnackbarCenter.shared.show(
|
||||
title: "PIX expirou. Gere um novo pedido para continuar.",
|
||||
style: .warning,
|
||||
icon: "clock.badge.xmark.fill",
|
||||
duration: 4.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PaymentCardView: View {
|
||||
let context: CardPaymentContext
|
||||
var onPaymentConfirmed: (() -> Void)? = nil
|
||||
var onOpenTracking: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var cardHolderName = ""
|
||||
@@ -723,6 +825,7 @@ struct PaymentCardView: View {
|
||||
|
||||
PrimaryButton(title: "Salvar e Pagar") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
|
||||
@@ -731,6 +834,7 @@ struct PaymentCardView: View {
|
||||
|
||||
Button("Apenas Pagar") {
|
||||
if latestOrder?.isPaymentConfirmed == true {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0)
|
||||
@@ -752,6 +856,7 @@ struct PaymentCardView: View {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
if updated.isPaymentConfirmed {
|
||||
onPaymentConfirmed?()
|
||||
openTrackingOnce()
|
||||
}
|
||||
}
|
||||
@@ -766,7 +871,6 @@ struct PaymentCardView: View {
|
||||
guard hasOpenedTracking == false else { return }
|
||||
hasOpenedTracking = true
|
||||
onOpenTracking?()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
|
||||
|
||||
@@ -20,18 +20,29 @@ extension HomeView {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async {
|
||||
func loadHomeCategories(withFallbackStores stores: [StoreSummary], forceRefresh: Bool = false) async {
|
||||
let cacheKey = "public-categories"
|
||||
if forceRefresh == false,
|
||||
let cached: [CategoryModel] = AppContentCache.shared.value(for: cacheKey, as: [CategoryModel].self) {
|
||||
categories = cached
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().listPublicCategories()
|
||||
let response = try await ApiService().listPublicCategories(forceRefresh: forceRefresh)
|
||||
if response.error == false, let remote = response.result, remote.isEmpty == false {
|
||||
categories = mapPublicCategories(remote)
|
||||
let mapped = mapPublicCategories(remote)
|
||||
categories = mapped
|
||||
AppContentCache.shared.set(mapped, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fallback handled below.
|
||||
}
|
||||
|
||||
categories = buildCategories(from: stores)
|
||||
let fallback = buildCategories(from: stores)
|
||||
categories = fallback
|
||||
AppContentCache.shared.set(fallback, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
|
||||
func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
|
||||
|
||||
@@ -83,10 +83,6 @@ struct HomeView: View {
|
||||
.onChange(of: addressCacheScope) { _, _ in
|
||||
guard hasRequestedLocation else { return }
|
||||
Task {
|
||||
AppContentCache.shared.invalidate(prefix: "stores:")
|
||||
AppContentCache.shared.invalidate(prefix: "store-info:")
|
||||
AppContentCache.shared.invalidate(prefix: "store-catalog:")
|
||||
AppImageCache.shared.invalidateAll()
|
||||
await bootstrapStoresFlow(
|
||||
forceLocationRefresh: true,
|
||||
category: selectedCategory == "all" ? nil : selectedCategory,
|
||||
@@ -435,7 +431,7 @@ struct HomeView: View {
|
||||
isLoadingStores = false
|
||||
stores = cachedStores
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(withFallbackStores: cachedStores)
|
||||
await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
@@ -457,9 +453,9 @@ struct HomeView: View {
|
||||
}
|
||||
let results = response.result ?? []
|
||||
stores = results
|
||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180)
|
||||
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
|
||||
if refreshCategories || (category == nil && categories.count <= 1) {
|
||||
await loadHomeCategories(withFallbackStores: results)
|
||||
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
|
||||
if categories.contains(where: { $0.id == selectedCategory }) == false {
|
||||
selectedCategory = "all"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct TrackingStep: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let time: String?
|
||||
let isCompleted: Bool
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
struct OrderTrackingView: View {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
@@ -8,38 +17,30 @@ struct OrderTrackingView: View {
|
||||
@State var errorMessage: String? = nil
|
||||
@State var order: PublicOrderResult? = nil
|
||||
@State var tracker = OrderRealtimeTracker()
|
||||
@State var showCancellationReason = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
headerCard
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding(.top, 20)
|
||||
}
|
||||
|
||||
if let errorMessage, errorMessage.isEmpty == false {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
if let order {
|
||||
timelineCard(order)
|
||||
}
|
||||
|
||||
footerPlaceholderCard
|
||||
topHeader
|
||||
orderTitleSection
|
||||
statusBanner
|
||||
timelineSection
|
||||
placeholderCard
|
||||
contactButton
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
.padding(.bottom, UIDevice.bottomNotch + 45)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||
Button("Fechar", role: .cancel) {}
|
||||
} message: {
|
||||
Text(cancellationReasonText)
|
||||
}
|
||||
.task {
|
||||
await loadInitialOrder()
|
||||
tracker.onOrderUpdated = { updated in
|
||||
@@ -54,67 +55,103 @@ struct OrderTrackingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var displayOrderTitle: String {
|
||||
if let short = order?.shortId, short.isEmpty == false { return "#\(short)" }
|
||||
if let initialShortId, initialShortId.isEmpty == false { return "#\(initialShortId)" }
|
||||
return "#\(orderId.prefix(6))"
|
||||
private var topHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.35))
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
Text("Acompanhamento em tempo real")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
private var orderTitleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(statusTitle)
|
||||
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Pedido ID #\(order?.shortId ?? initialShortId ?? orderId)")
|
||||
Text("Pedido #\(displayOrderTitle)")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
if let order, order.isInDeliveryRoute, let otp = order.displayOtpCode {
|
||||
Text("Seu código do pedido: \(otp) - Informe esse número ao motoboy")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(statusPill)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func timelineCard(_ order: PublicOrderResult) -> some View {
|
||||
let events: [PublicOrderTimelineEvent] = {
|
||||
if order.timeline.isEmpty == false { return order.timeline }
|
||||
return [
|
||||
PublicOrderTimelineEvent(
|
||||
status: order.status ?? order.paymentStatus ?? "PENDING",
|
||||
message: nil,
|
||||
time: order.updatedAt ?? order.createdAt
|
||||
@ViewBuilder
|
||||
private var statusBanner: some View {
|
||||
if let errorMessage, errorMessage.isEmpty == false {
|
||||
statusBadge(
|
||||
title: errorMessage,
|
||||
fg: Color.red,
|
||||
bg: Color.red.opacity(0.12),
|
||||
icon: "xmark.octagon.fill"
|
||||
)
|
||||
} else if isLoading {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Atualizando status do pedido...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if isCanceled {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
statusBadge(
|
||||
title: "Pedido cancelado",
|
||||
fg: Color.red,
|
||||
bg: Color.red.opacity(0.12),
|
||||
icon: "xmark.circle.fill"
|
||||
)
|
||||
]
|
||||
}()
|
||||
if cancellationReasonText.isEmpty == false {
|
||||
Button("Ver motivo do cancelamento") {
|
||||
showCancellationReason = true
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(Color.red)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else if isWaitingPayment {
|
||||
statusBadge(
|
||||
title: "Aguardando pagamento",
|
||||
fg: Color(hex: "#A16207"),
|
||||
bg: Color(hex: "#FDE68A").opacity(0.35),
|
||||
icon: "clock.fill"
|
||||
)
|
||||
} else {
|
||||
statusBadge(
|
||||
title: successBannerTitle,
|
||||
fg: AppColors.primary,
|
||||
bg: AppColors.brandSoft,
|
||||
icon: "checkmark.circle.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Acompanhamento")
|
||||
private var timelineSection: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Progresso do Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(events.enumerated()), id: \.offset) { index, event in
|
||||
timelineRow(event: event, isLast: index == events.count - 1)
|
||||
if let order {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(timelineSteps(for: order).enumerated()), id: \.element.id) { index, step in
|
||||
timelineRow(step: step, isLast: index == timelineSteps(for: order).count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,112 +161,473 @@ struct OrderTrackingView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func timelineRow(event: PublicOrderTimelineEvent, isLast: Bool) -> some View {
|
||||
private var placeholderCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
Image(systemName: summaryStatusIcon)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
)
|
||||
Text(summaryStatusTitle)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Group {
|
||||
if hasTrackingImage {
|
||||
Image(trackingImageName)
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else if hasPlaceholderProductImage {
|
||||
Image("placeholder-product")
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black.opacity(0.08)
|
||||
Image(systemName: "shippingbox.fill")
|
||||
.font(.system(size: 52, weight: .bold))
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private var contactButton: some View {
|
||||
Button("CONTATO") {}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
Text(title)
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(fg)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(bg)
|
||||
.clipShape(Capsule())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func timelineRow(step: TrackingStep, isLast: Bool) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(spacing: 0) {
|
||||
Circle()
|
||||
.fill(AppColors.tertiary)
|
||||
.fill(stepDotColor(step))
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
Group {
|
||||
if step.isCompleted {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
} else if step.isActive {
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: 8, height: 8)
|
||||
} else {
|
||||
Circle()
|
||||
.stroke(Color(hex: "#C5CBD4"), lineWidth: 2)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if isLast == false {
|
||||
Rectangle()
|
||||
.fill(AppColors.tertiary.opacity(0.45))
|
||||
.frame(width: 2, height: 30)
|
||||
.fill(stepLineColor(step))
|
||||
.frame(width: 2, height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(humanReadableStatus(event.status ?? event.message ?? "Atualizado"))
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text(formatTime(event.time) ?? "")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
Text(step.title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(stepTitleColor(step))
|
||||
|
||||
if step.subtitle.isEmpty == false {
|
||||
Text(step.subtitle)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(stepSubtitleColor(step))
|
||||
}
|
||||
|
||||
if let time = step.time, time.isEmpty == false {
|
||||
Text(time)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var footerPlaceholderCard: some View {
|
||||
VStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
.frame(height: 180)
|
||||
.overlay(
|
||||
Image(systemName: "shippingbox.fill")
|
||||
.font(.system(size: 56, weight: .bold))
|
||||
.foregroundStyle(AppColors.tertiary)
|
||||
)
|
||||
|
||||
Text("Acompanhe seu pedido em tempo real")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
private func stepDotColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled {
|
||||
return Color(hex: "#C5CBD4")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#F59E0B")
|
||||
}
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
return Color(hex: "#E5E7EB")
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
humanReadableStatus(order?.status ?? "Aguardando")
|
||||
private func stepLineColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#E5E7EB") }
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary.opacity(0.85)
|
||||
}
|
||||
return Color(hex: "#E5E7EB")
|
||||
}
|
||||
|
||||
private var statusPill: String {
|
||||
let value = order?.paymentStatus ?? order?.status ?? "PENDING"
|
||||
return humanReadableStatus(value)
|
||||
private func stepTitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.textPrimary
|
||||
}
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
private func humanReadableStatus(_ raw: String) -> String {
|
||||
let normalized = raw.uppercased()
|
||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" {
|
||||
return "Aguardando pagamento"
|
||||
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
|
||||
if isCanceled { return Color(hex: "#9CA3AF") }
|
||||
if isWaitingPayment && step.id == "paid" {
|
||||
return Color(hex: "#A16207")
|
||||
}
|
||||
if normalized.contains("CONFIRMED") {
|
||||
return "Pagamento confirmado"
|
||||
if step.isCompleted || step.isActive {
|
||||
return AppColors.primary
|
||||
}
|
||||
if normalized.contains("PREPAR") {
|
||||
return "Em preparo"
|
||||
return Color(hex: "#9CA3AF")
|
||||
}
|
||||
|
||||
private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] {
|
||||
let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP")
|
||||
let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [
|
||||
("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]),
|
||||
("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]),
|
||||
("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]),
|
||||
("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]),
|
||||
("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"])
|
||||
]
|
||||
|
||||
let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps
|
||||
let currentIndex = currentStepIndex(isPickup: isPickup)
|
||||
|
||||
return stepsBase.enumerated().map { index, step in
|
||||
let event = timelineEvent(for: order, statuses: step.statuses)
|
||||
let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex)
|
||||
let isActive = event?.active ?? (isCanceled == false && index == currentIndex)
|
||||
return TrackingStep(
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label),
|
||||
time: formatTime(event?.time),
|
||||
isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)),
|
||||
isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false
|
||||
)
|
||||
}
|
||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("EM_ROTA") || normalized.contains("ROTA") {
|
||||
return "Em rota de entrega"
|
||||
}
|
||||
|
||||
private func currentStepIndex(isPickup: Bool) -> Int {
|
||||
let normalizedStatus = normalized(order?.status)
|
||||
|
||||
if isCanceled {
|
||||
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") { return isPickup ? 2 : 3 }
|
||||
if normalizedStatus.contains("READY") { return 2 }
|
||||
if normalizedStatus.contains("PREPAR") || normalizedStatus.contains("ACCEPT") || normalizedStatus.contains("PENDING") { return 1 }
|
||||
return 0
|
||||
}
|
||||
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") {
|
||||
|
||||
if isWaitingPayment {
|
||||
return 0
|
||||
}
|
||||
|
||||
if let timelineIndex = timelineProgressStepIndex(isPickup: isPickup) {
|
||||
return timelineIndex
|
||||
}
|
||||
|
||||
if normalizedStatus.contains("COMPLETED") || normalizedStatus.contains("DELIVERED") {
|
||||
return isPickup ? 3 : 4
|
||||
}
|
||||
if isPickup {
|
||||
if normalizedStatus.contains("READY") { return 2 }
|
||||
if normalizedStatus.contains("PREPAR") { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
if normalizedStatus.contains("DELIVER") || normalizedStatus.contains("ROTA") {
|
||||
return 3
|
||||
}
|
||||
if normalizedStatus.contains("READY") {
|
||||
return 2
|
||||
}
|
||||
if normalizedStatus.contains("PREPAR") {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func timelineProgressStepIndex(isPickup: Bool) -> Int? {
|
||||
guard let order else { return nil }
|
||||
|
||||
let stepStatuses: [[String]] = isPickup
|
||||
? [
|
||||
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
|
||||
["PREPARING"],
|
||||
["READY"],
|
||||
["COMPLETED", "DELIVERED"]
|
||||
]
|
||||
: [
|
||||
["PENDING", "ACCEPTED", "PAYMENT_PENDING"],
|
||||
["PREPARING"],
|
||||
["READY"],
|
||||
["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"],
|
||||
["COMPLETED", "DELIVERED"]
|
||||
]
|
||||
|
||||
var strongestIndex: Int? = nil
|
||||
var fallbackIndex: Int? = nil
|
||||
|
||||
for (index, statuses) in stepStatuses.enumerated() {
|
||||
let statusSet = Set(statuses.map(normalized))
|
||||
let events = order.timeline.filter { event in
|
||||
statusSet.contains(normalized(event.status))
|
||||
}
|
||||
guard events.isEmpty == false else { continue }
|
||||
|
||||
fallbackIndex = index
|
||||
|
||||
if events.contains(where: { $0.active == true || $0.completed == true }) {
|
||||
strongestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
return strongestIndex ?? fallbackIndex
|
||||
}
|
||||
|
||||
private func isCompletedTerminalStep(index: Int, isPickup: Bool) -> Bool {
|
||||
let terminalIndex = isPickup ? 3 : 4
|
||||
return isCanceled == false && currentStepIndex(isPickup: isPickup) >= terminalIndex && index == terminalIndex
|
||||
}
|
||||
|
||||
private func timelineEvent(for order: PublicOrderResult, statuses: [String]) -> PublicOrderTimelineEvent? {
|
||||
let statusSet = Set(statuses.map(normalized))
|
||||
return order.timeline.first(where: { statusSet.contains(normalized($0.status)) })
|
||||
}
|
||||
|
||||
private func timelineSubtitle(stepId: String, fallback: String, eventLabel: String?) -> String {
|
||||
if stepId == "delivering", customerOtpCode != nil {
|
||||
return customerOtpSubtitle
|
||||
}
|
||||
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
return label
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private var customerOtpSubtitle: String {
|
||||
if let otp = customerOtpCode {
|
||||
return "Código para o entregador: \(otp)"
|
||||
}
|
||||
return "Aguardando saída para entrega"
|
||||
}
|
||||
|
||||
private var customerOtpCode: String? {
|
||||
let raw = (order?.customerOtp ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.isEmpty { return nil }
|
||||
let digits = raw.filter(\.isNumber)
|
||||
if digits.count == 4 {
|
||||
return digits
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var displayOrderTitle: String {
|
||||
if let short = order?.shortId, short.isEmpty == false { return short }
|
||||
if let orderIdValue = order?.id, orderIdValue.isEmpty == false { return orderIdValue }
|
||||
if let initialShortId, initialShortId.isEmpty == false { return initialShortId }
|
||||
return String(orderId.prefix(6))
|
||||
}
|
||||
|
||||
private var isCanceled: Bool {
|
||||
normalized(order?.status).contains("CANCEL")
|
||||
}
|
||||
|
||||
private var isWaitingPayment: Bool {
|
||||
let paymentStatus = normalized(order?.paymentStatus)
|
||||
if isOnlinePaymentMethod == false {
|
||||
return false
|
||||
}
|
||||
if paymentStatus == "PENDING" {
|
||||
return true
|
||||
}
|
||||
return order?.isPaymentConfirmed == false
|
||||
}
|
||||
|
||||
private var isOnlinePaymentMethod: Bool {
|
||||
let code = normalized(order?.paymentMethodCode)
|
||||
if code == "PIX" || code == "CREDIT_CARD" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var successBannerTitle: String {
|
||||
if isCompletedOrder {
|
||||
if isPickupOrder {
|
||||
return "Pedido retirado"
|
||||
}
|
||||
return "Pedido entregue"
|
||||
}
|
||||
if normalized.contains("CANCEL") {
|
||||
return "Pedido cancelado"
|
||||
if isOnlinePaymentMethod {
|
||||
return "Pagamento confirmado"
|
||||
}
|
||||
return raw.capitalized
|
||||
return "Pedido confirmado"
|
||||
}
|
||||
|
||||
private func formatTime(_ isoValue: String?) -> String? {
|
||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
||||
private var summaryStatusTitle: String {
|
||||
if isCanceled {
|
||||
return "Seu pedido foi cancelado"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "Aguardando confirmação de pagamento"
|
||||
}
|
||||
if isCompletedOrder {
|
||||
return isPickupOrder ? "Seu pedido foi retirado" : "Seu pedido foi entregue"
|
||||
}
|
||||
return "Seu pedido está em andamento"
|
||||
}
|
||||
|
||||
private var summaryStatusIcon: String {
|
||||
if isCanceled {
|
||||
return "xmark"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "clock.fill"
|
||||
}
|
||||
return "checkmark"
|
||||
}
|
||||
|
||||
private var isPickupOrder: Bool {
|
||||
normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||
}
|
||||
|
||||
private var isCompletedOrder: Bool {
|
||||
let status = normalized(order?.status)
|
||||
if status.contains("COMPLETED") || status.contains("DELIVERED") {
|
||||
return true
|
||||
}
|
||||
|
||||
let stepIndex = currentStepIndex(isPickup: isPickupOrder)
|
||||
let terminalIndex = isPickupOrder ? 3 : 4
|
||||
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
|
||||
}
|
||||
|
||||
private var cancellationReasonText: String {
|
||||
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? "Sem detalhe informado." : value
|
||||
}
|
||||
|
||||
private var hasTrackingImage: Bool {
|
||||
imageResourceExists(trackingImageName)
|
||||
}
|
||||
|
||||
private var hasPlaceholderProductImage: Bool {
|
||||
imageResourceExists("placeholder-product")
|
||||
}
|
||||
|
||||
private func imageResourceExists(_ name: String) -> Bool {
|
||||
let exts = ["png", "jpg", "jpeg", "webp"]
|
||||
for ext in exts {
|
||||
if Bundle.main.url(forResource: name, withExtension: ext) != nil {
|
||||
return true
|
||||
}
|
||||
if Bundle.module.url(forResource: name, withExtension: ext) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var trackingImageName: String {
|
||||
let isPickup = normalized(order?.deliveryType ?? order?.deliveryTypeLabel).contains("PICKUP")
|
||||
let stepIndex = currentStepIndex(isPickup: isPickup)
|
||||
|
||||
if isCanceled {
|
||||
return "tracking-canceled"
|
||||
}
|
||||
if isWaitingPayment {
|
||||
return "tracking-pending"
|
||||
}
|
||||
switch stepIndex {
|
||||
case 0:
|
||||
return "tracking-pending"
|
||||
case 1:
|
||||
return "tracking-preparing"
|
||||
case 2:
|
||||
return isPickup ? "tracking-ready" : "tracking-preparing"
|
||||
case 3:
|
||||
return "tracking-delivering"
|
||||
default:
|
||||
return "tracking-completed"
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.uppercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func formatTime(_ rawValue: String?) -> String? {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty { return nil }
|
||||
|
||||
if value.contains("T"), let isoTime = formatISOTime(value) {
|
||||
return isoTime
|
||||
}
|
||||
if value.range(of: #"^\d{2}:\d{2}"#, options: .regularExpression) != nil {
|
||||
return String(value.prefix(5))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func formatISOTime(_ value: String) -> String? {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
var date: Date? = iso.date(from: isoValue)
|
||||
var date = iso.date(from: value)
|
||||
if date == nil {
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
date = iso.date(from: isoValue)
|
||||
date = iso.date(from: value)
|
||||
}
|
||||
|
||||
if date == nil {
|
||||
let fallback = DateFormatter()
|
||||
fallback.locale = Locale(identifier: "pt_BR")
|
||||
fallback.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
date = fallback.date(from: isoValue)
|
||||
}
|
||||
|
||||
guard let date else { return nil }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
|
||||
@@ -4,6 +4,7 @@ struct OrdersView: View {
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String? = nil
|
||||
@State var orders: [AppOrderSummary] = []
|
||||
@State var hasLoadedOnce = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -31,8 +32,14 @@ struct OrdersView: View {
|
||||
.padding(.top, 24)
|
||||
} else {
|
||||
ForEach(orders) { order in
|
||||
let trackingId = trackingOrderId(for: order)
|
||||
NavigationLink {
|
||||
OrderTrackingView(orderId: order.id, initialShortId: order.shortId)
|
||||
OrderEntryDestinationView(
|
||||
orderId: trackingId,
|
||||
initialShortId: order.shortId,
|
||||
fallbackPaymentMethod: order.paymentMethod,
|
||||
fallbackTotal: order.total
|
||||
)
|
||||
} label: {
|
||||
orderRow(order)
|
||||
}
|
||||
@@ -47,7 +54,10 @@ struct OrdersView: View {
|
||||
.navigationTitle("Meus Pedidos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await loadOrders()
|
||||
await loadOrdersIfNeeded()
|
||||
}
|
||||
.refreshable {
|
||||
await loadOrders(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +76,7 @@ struct OrdersView: View {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text(humanReadableStatus(order.status ?? order.paymentStatus ?? "Pendente"))
|
||||
Text(humanReadableStatus(for: order))
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
@@ -89,11 +99,41 @@ struct OrdersView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func humanReadableStatus(_ raw: String) -> String {
|
||||
private func humanReadableStatus(for order: AppOrderSummary) -> String {
|
||||
let explicitLabel = (order.statusLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if explicitLabel.isEmpty == false {
|
||||
return explicitLabel
|
||||
}
|
||||
|
||||
let detailed = (order.statusDetailed ?? "").uppercased()
|
||||
switch detailed {
|
||||
case "PENDING_PAYMENT":
|
||||
return "Aguardando pagamento"
|
||||
case "PENDING_PREPARATION":
|
||||
return "Pendente de preparo"
|
||||
case "PREPARING":
|
||||
return "Em preparo"
|
||||
case "PENDING_DELIVERY":
|
||||
return "Pendente de entrega"
|
||||
case "READY_FOR_PICKUP":
|
||||
return "Pronto para retirada"
|
||||
case "IN_DELIVERY":
|
||||
return "Em rota"
|
||||
case "COMPLETED":
|
||||
return "Entregue"
|
||||
case "CANCELED":
|
||||
return "Cancelado"
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let raw = order.status ?? order.paymentStatus ?? "Pendente"
|
||||
let normalized = raw.uppercased()
|
||||
if normalized.contains("PAYMENT_PENDING") || normalized == "PENDING" { return "Aguardando pagamento" }
|
||||
if normalized.contains("CONFIRMED") { return "Confirmado" }
|
||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("ROTA") { return "Em rota" }
|
||||
if normalized.contains("PAYMENT_PENDING") { return "Aguardando pagamento" }
|
||||
if normalized == "PENDING" || normalized == "ACCEPTED" { return "Pendente de preparo" }
|
||||
if normalized.contains("PREPARING") { return "Em preparo" }
|
||||
if normalized.contains("READY") { return "Pendente de entrega" }
|
||||
if normalized.contains("OUT_FOR_DELIVERY") || normalized.contains("DELIVERING") || normalized.contains("ROTA") { return "Em rota" }
|
||||
if normalized.contains("COMPLETED") || normalized.contains("DELIVERED") { return "Entregue" }
|
||||
if normalized.contains("CANCEL") { return "Cancelado" }
|
||||
return raw.capitalized
|
||||
@@ -120,56 +160,192 @@ struct OrdersView: View {
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func trackingOrderId(for order: AppOrderSummary) -> String {
|
||||
let orderCandidate = (order.orderId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if orderCandidate.isEmpty == false {
|
||||
return orderCandidate
|
||||
}
|
||||
let candidate = (order.realId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if candidate.isEmpty == false {
|
||||
return candidate
|
||||
}
|
||||
return order.id
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOrders() async {
|
||||
private func loadOrdersIfNeeded() async {
|
||||
guard hasLoadedOnce == false else { return }
|
||||
await loadOrders(force: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadOrders(force: Bool) async {
|
||||
if isLoading { return }
|
||||
if force == false, hasLoadedOnce { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
let previousOrders = orders
|
||||
|
||||
var trackedMapped: [AppOrderSummary] = []
|
||||
let cachedTracked = SessionStateStore.loadTrackedOrders()
|
||||
if cachedTracked.isEmpty == false {
|
||||
let mapped = cachedTracked.map {
|
||||
trackedMapped = cachedTracked.map {
|
||||
AppOrderSummary.fromTracked($0)
|
||||
}
|
||||
orders = mergeOrders(apiOrders: orders, trackedOrders: mapped)
|
||||
// Use tracked-only list as bootstrap data only on first load.
|
||||
if hasLoadedOnce == false, previousOrders.isEmpty {
|
||||
orders = mergeOrders(apiOrders: [], trackedOrders: trackedMapped)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().listOrders()
|
||||
let response = try await ApiService().listOrders(forceRefresh: force)
|
||||
if response.error {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
errorMessage = response.message ?? "Não foi possível carregar os pedidos."
|
||||
} else {
|
||||
let remote = response.result ?? []
|
||||
orders = mergeOrders(apiOrders: remote, trackedOrders: orders)
|
||||
orders = mergeOrders(apiOrders: remote, trackedOrders: trackedMapped)
|
||||
}
|
||||
} catch {
|
||||
if isCancelledRequest(error) {
|
||||
if previousOrders.isEmpty == false {
|
||||
orders = previousOrders
|
||||
}
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
if orders.isEmpty {
|
||||
errorMessage = "Não foi possível carregar os pedidos."
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
hasLoadedOnce = true
|
||||
}
|
||||
|
||||
private func mergeOrders(apiOrders: [AppOrderSummary], trackedOrders: [AppOrderSummary]) -> [AppOrderSummary] {
|
||||
var map: [String: AppOrderSummary] = [:]
|
||||
for item in trackedOrders { map[item.id] = item }
|
||||
for item in apiOrders { map[item.id] = item }
|
||||
var sourceRank: [String: Int] = [:]
|
||||
|
||||
for (index, item) in trackedOrders.enumerated() {
|
||||
let key = identityKey(for: item)
|
||||
map[key] = item
|
||||
if sourceRank[key] == nil {
|
||||
sourceRank[key] = 10_000 + index
|
||||
}
|
||||
}
|
||||
|
||||
// API order is authoritative for fallback ordering (usually newest first).
|
||||
for (index, item) in apiOrders.enumerated() {
|
||||
let key = identityKey(for: item)
|
||||
map[key] = item
|
||||
sourceRank[key] = index
|
||||
}
|
||||
|
||||
return map.values.sorted { lhs, rhs in
|
||||
let left = lhs.updatedAt ?? lhs.createdAt ?? ""
|
||||
let right = rhs.updatedAt ?? rhs.createdAt ?? ""
|
||||
return left > right
|
||||
let leftDate = orderDateSortValue(lhs)
|
||||
let rightDate = orderDateSortValue(rhs)
|
||||
if leftDate != rightDate {
|
||||
return leftDate > rightDate
|
||||
}
|
||||
|
||||
let leftRank = sourceRank[identityKey(for: lhs)] ?? Int.max
|
||||
let rightRank = sourceRank[identityKey(for: rhs)] ?? Int.max
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
|
||||
let leftNumericId = Int(lhs.id)
|
||||
let rightNumericId = Int(rhs.id)
|
||||
if let leftNumericId, let rightNumericId, leftNumericId != rightNumericId {
|
||||
return leftNumericId > rightNumericId
|
||||
}
|
||||
return lhs.id.localizedCompare(rhs.id) == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func identityKey(for order: AppOrderSummary) -> String {
|
||||
let raw = trackingOrderId(for: order)
|
||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
private func orderDateSortValue(_ order: AppOrderSummary) -> Date {
|
||||
parseDateForSort(order.updatedAt)
|
||||
?? parseDateForSort(order.createdAt)
|
||||
?? .distantPast
|
||||
}
|
||||
|
||||
private func parseDateForSort(_ rawValue: String?) -> Date? {
|
||||
guard let rawValue else { return nil }
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.isEmpty { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = iso.date(from: value) { return date }
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
if let date = iso.date(from: value) { return date }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "pt_BR")
|
||||
|
||||
let formats = [
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX",
|
||||
"yyyy-MM-dd'T'HH:mm:ssXXXXX",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||
"yyyy-MM-dd HH:mm:ss Z",
|
||||
"dd/MM/yyyy HH:mm:ss",
|
||||
"dd/MM/yyyy HH:mm",
|
||||
"dd/MM/yyyy"
|
||||
]
|
||||
for format in formats {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: value) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isCancelledRequest(_ error: Error) -> Bool {
|
||||
if error is CancellationError {
|
||||
return true
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError,
|
||||
case .transportError(let message) = networkError {
|
||||
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized.contains("cancel")
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError,
|
||||
case .cancelled = networkError {
|
||||
return true
|
||||
}
|
||||
|
||||
return error.localizedDescription.lowercased().contains("cancel")
|
||||
}
|
||||
}
|
||||
|
||||
extension AppOrderSummary {
|
||||
static func fromTracked(_ tracked: PublicOrderResult) -> AppOrderSummary {
|
||||
AppOrderSummary(
|
||||
id: tracked.id,
|
||||
orderId: tracked.realId ?? tracked.id,
|
||||
realId: tracked.realId,
|
||||
shortId: tracked.shortId,
|
||||
total: tracked.total,
|
||||
status: tracked.status,
|
||||
statusDetailed: nil,
|
||||
statusLabel: nil,
|
||||
nextAction: nil,
|
||||
paymentStatus: tracked.paymentStatus,
|
||||
paymentMethod: tracked.paymentMethod,
|
||||
deliveryType: tracked.deliveryType,
|
||||
@@ -181,9 +357,14 @@ extension AppOrderSummary {
|
||||
|
||||
init(
|
||||
id: String,
|
||||
orderId: String?,
|
||||
realId: String?,
|
||||
shortId: String?,
|
||||
total: Double?,
|
||||
status: String?,
|
||||
statusDetailed: String?,
|
||||
statusLabel: String?,
|
||||
nextAction: String?,
|
||||
paymentStatus: String?,
|
||||
paymentMethod: String?,
|
||||
deliveryType: String?,
|
||||
@@ -192,9 +373,14 @@ extension AppOrderSummary {
|
||||
updatedAt: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.orderId = orderId
|
||||
self.realId = realId
|
||||
self.shortId = shortId
|
||||
self.total = total
|
||||
self.status = status
|
||||
self.statusDetailed = statusDetailed
|
||||
self.statusLabel = statusLabel
|
||||
self.nextAction = nextAction
|
||||
self.paymentStatus = paymentStatus
|
||||
self.paymentMethod = paymentMethod
|
||||
self.deliveryType = deliveryType
|
||||
@@ -203,3 +389,150 @@ extension AppOrderSummary {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderEntryDestinationView: View {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
let fallbackPaymentMethod: String?
|
||||
let fallbackTotal: Double?
|
||||
|
||||
@State var isResolvingRoute = true
|
||||
@State var didResolve = false
|
||||
@State var pixContext: PixPaymentContext? = nil
|
||||
@State var cardContext: CardPaymentContext? = nil
|
||||
@State var orderTrackingContext: OrderTrackingContext? = nil
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isResolvingRoute {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Carregando pedido...")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
} else if let pixContext {
|
||||
PaymentPixView(
|
||||
context: pixContext,
|
||||
onPaymentConfirmed: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
},
|
||||
onOpenTracking: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
}
|
||||
)
|
||||
} else if let cardContext {
|
||||
PaymentCardView(
|
||||
context: cardContext,
|
||||
onPaymentConfirmed: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||
},
|
||||
onOpenTracking: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $orderTrackingContext) { context in
|
||||
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
|
||||
}
|
||||
.task {
|
||||
guard didResolve == false else { return }
|
||||
didResolve = true
|
||||
await resolveRoute()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolveRoute() async {
|
||||
defer { isResolvingRoute = false }
|
||||
|
||||
let order = await fetchOrderForRouting()
|
||||
guard let order else { return }
|
||||
guard order.isPaymentConfirmed == false else { return }
|
||||
guard isOnlinePaymentMethod(order) else { return }
|
||||
|
||||
let normalizedMethod = normalizePaymentMethod(order)
|
||||
let normalizedStatus = normalize(order.paymentStatus)
|
||||
if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false {
|
||||
return
|
||||
}
|
||||
|
||||
if normalizedMethod.contains("PIX") {
|
||||
let pixFromPayment = order.payment?.pix
|
||||
let pixFromPayload = order.paymentPayload
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste
|
||||
: pixFromPayload?.copyPaste
|
||||
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage
|
||||
: pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
|
||||
pixContext = PixPaymentContext(
|
||||
id: order.id,
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (copyPaste ?? "")
|
||||
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") {
|
||||
cardContext = CardPaymentContext(
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
total: order.total ?? fallbackTotal ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func fetchOrderForRouting() async -> PublicOrderResult? {
|
||||
do {
|
||||
let response = try await ApiService().publicOrder(orderId: orderId)
|
||||
if response.error == false, let result = response.result {
|
||||
SessionStateStore.saveTrackedOrder(result)
|
||||
return result
|
||||
}
|
||||
} catch {
|
||||
// Fallback to local cache when remote call fails.
|
||||
}
|
||||
|
||||
return SessionStateStore.loadTrackedOrder(orderId: orderId)
|
||||
}
|
||||
|
||||
func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool {
|
||||
let method = normalizePaymentMethod(order)
|
||||
return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD")
|
||||
}
|
||||
|
||||
func normalizePaymentMethod(_ order: PublicOrderResult) -> String {
|
||||
let first = normalize(order.paymentMethodCode)
|
||||
if first.isEmpty == false {
|
||||
return first
|
||||
}
|
||||
let second = normalize(order.paymentMethod)
|
||||
if second.isEmpty == false {
|
||||
return second
|
||||
}
|
||||
return normalize(fallbackPaymentMethod)
|
||||
}
|
||||
|
||||
func normalize(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ extension StoreDetailView {
|
||||
categories = catalogResponse.result ?? []
|
||||
selectedCategoryId = categories.first?.id
|
||||
if let info = infoResponse.result {
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300)
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300)
|
||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
|
||||
Reference in New Issue
Block a user