payment and list

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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