This commit is contained in:
Daniel Arantes Loverde
2026-06-05 16:32:08 -03:00
parent e62bd60e9a
commit 18818f909a
27 changed files with 1440 additions and 137 deletions

View File

@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
let order: PublicOrderResult
let orderId: String
let initialShortId: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var storeContactPhone: String? = nil
@State private var resolvedStoreLogoURL: String? = nil
@State private var showCallAlert = false
@State private var navigateToStore = false
@State private var showClearCartAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
screenHeader
statusCard
storeCard
itemsCard
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
.padding(.bottom, UIDevice.bottomNotch)
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(isPresented: $navigateToStore) {
if let storeId = order.storeId, storeId.isEmpty == false {
StoreDetailView(
storeId: storeId,
storeName: order.storeName ?? "Loja",
storeCoverURL: nil,
storeLogoURL: order.storeLogoURL,
storeCategory: nil,
storeRating: nil,
storeDistance: nil,
storeDeliveryFee: nil,
appState: $appState
)
}
}
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
Button("Ligar para \(order.storeName ?? "a loja")") {
if let phone = storeContactPhone {
openTel(phone)
}
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
}
.task {
await loadStoreContactPhone()
}
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
Button("Limpar e adicionar", role: .destructive) {
applyReorder(clearFirst: true)
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
}
.appBottomSafeAreaInset {
VStack {
reorderButton
@@ -35,6 +81,27 @@ struct OrderDetailsView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var statusCard: some View {
HStack(spacing: 14) {
Circle()
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
Text(statusDateText)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
if let reason = cancellationReasonText {
Text(reason)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 2)
}
if let addr = deliveryAddressSummary {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 11))
.foregroundStyle(AppColors.textMuted)
Text(addr)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
.padding(.top, 2)
}
}
Spacer(minLength: 0)
@@ -64,30 +151,39 @@ struct OrderDetailsView: View {
}
private var storeCard: some View {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button {
if order.storeId?.isEmpty == false {
navigateToStore = true
}
} label: {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
Spacer(minLength: 0)
if order.storeId?.isEmpty == false {
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
}
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private var itemsCard: some View {
@@ -216,27 +312,28 @@ struct OrderDetailsView: View {
private var reorderButton: some View {
Button("Pedir Novamente") {
SnackbarCenter.shared.show(
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
reorder()
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E"))
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule())
.buttonStyle(.plain)
.disabled(order.items.isEmpty)
}
private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
Button {
handleHelpTap()
} label: {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
private var subtotal: Double {
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
return "Pedido #\(displayOrderTitle)"
}
private var deliveryAddressSummary: String? {
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return full.trimmingCharacters(in: .whitespacesAndNewlines)
}
let line1 = deliveryAddressLine
let line2 = deliveryAddressLine2
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
return combined.isEmpty ? nil : combined
}
private var cancellationReasonText: String? {
guard statusTitle.contains("cancelado"),
let reason = order.cancellationReason,
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else { return nil }
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
}
private var statusTitle: String {
let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" }
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
}
private var statusDateText: String {
if let event = order.timeline.first,
let date = event.date, date.isEmpty == false {
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
let combined = time.map { "\(date) às \($0)" } ?? date
return "\(statusDatePrefix) \(combined)"
}
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)"
}
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func reorder() {
guard order.items.isEmpty == false else { return }
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
let orderStoreId = order.storeId ?? ""
let cartHasDifferentStore = cartStoreId.isEmpty == false
&& orderStoreId.isEmpty == false
&& cartStoreId != orderStoreId
&& appState.cart.items.isEmpty == false
if cartHasDifferentStore {
showClearCartAlert = true
} else {
applyReorder(clearFirst: false)
}
}
private func applyReorder(clearFirst: Bool) {
if clearFirst {
appState.cart.clear()
}
let storeId = order.storeId ?? ""
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
appState.cart.storeId = storeId
appState.cart.storeName = order.storeName
}
var addedCount = 0
for item in order.items {
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard name.isEmpty == false else { continue }
let qty = max(1, item.qty ?? 1)
let price = item.price ?? 0
let cartItem = CartItemState(
id: UUID().uuidString,
productId: item.productId ?? item.id,
storeId: storeId,
name: name,
imageURL: nil,
details: nil,
addons: [],
quantity: qty,
unitPrice: price
)
appState.cart.add(item: cartItem)
addedCount += qty
}
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
}
private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: isoValue)
let optionSets: [ISO8601DateFormatter.Options] = [
[.withInternetDateTime, .withFractionalSeconds],
[.withInternetDateTime],
[.withFullDate, .withTime, .withColonSeparatorInTime],
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
[.withFullDate]
]
var date: Date? = nil
for options in optionSets {
iso.formatOptions = options
if let d = iso.date(from: isoValue) {
date = d
break
}
}
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "en_US_POSIX")
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
fallback.dateFormat = fmt
if let d = fallback.date(from: isoValue) { date = d; break }
}
}
guard let date else { return nil }
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
return formatter.string(from: date)
}
@MainActor
private func loadStoreContactPhone() async {
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inline
}
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
}
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
resolvedStoreLogoURL = logo
}
}
} catch {}
}
private func handleHelpTap() {
guard let phoneRaw = storeContactPhone,
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
if let waURL = makeWhatsAppURL(from: phoneRaw) {
openURL(waURL)
} else {
showCallAlert = true
}
}
private func openTel(_ phoneRaw: String) {
let digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return }
if let url = URL(string: "tel://\(digits)") {
openURL(url)
}
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)