This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,886 @@
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?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State var isLoading = true
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
@State var storeContactPhone: String? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
@State var reviewSavedObserver: Any?
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
screenHeader
topHeader
orderTitleSection
statusBanner
timelineSection
placeholderCard
if shouldShowReviewButton {
reviewButton
} else {
contactButton
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 45)
}
.background(AppColors.backgroundLight)
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
Text(cancellationReasonText)
}
.task {
await loadInitialOrder()
tracker.onOrderUpdated = { updated in
order = updated
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
storeContactPhone = inlinePhone
}
isLoading = false
errorMessage = nil
}
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
}
.onAppear {
attachReviewSavedObserverIfNeeded()
}
.onDisappear {
tracker.stop()
detachReviewSavedObserver()
}
.navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft)
}
}
private func attachReviewSavedObserverIfNeeded() {
guard reviewSavedObserver == nil else { return }
reviewSavedObserver = NotificationCenter.default.addObserver(
forName: .orderReviewDidSave,
object: nil,
queue: nil
) { payload in
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
let currentOrderId = (order?.id ?? orderId)
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true
}
}
}
private func detachReviewSavedObserver() {
guard let reviewSavedObserver else { return }
NotificationCenter.default.removeObserver(reviewSavedObserver)
self.reviewSavedObserver = nil
}
private var screenHeader: some View {
ZStack {
Text("Pedido \(displayOrderTitle)")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: {
if let postOrderBack {
postOrderBack()
} else {
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 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 orderTitleSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text(order?.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order?.storeName ?? "Seu pedido") : "Seu pedido")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Text("Pedido #\(displayOrderTitle)")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@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"
)
}
}
private var timelineSection: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Progresso do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
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)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
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") {
openStoreWhatsApp()
}
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private var reviewButton: some View {
Button("AVALIAR PEDIDO") {
guard let reviewTargetDraft else { return }
reviewDraft = reviewTargetDraft
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#7CF02A"))
.clipShape(Capsule())
.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(stepDotColor(step))
.frame(width: 20, height: 20)
.overlay(
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(stepLineColor(step))
.frame(width: 2, height: 36)
}
}
VStack(alignment: .leading, spacing: 2) {
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 func stepDotColor(_ step: TrackingStep) -> Color {
if isCanceled {
return Color(hex: "#C5CBD4")
}
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#F59E0B")
}
if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#E5E7EB")
}
private func stepLineColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#E5E7EB") }
if step.isCompleted || step.isActive {
return AppColors.primary.opacity(0.85)
}
return Color(hex: "#E5E7EB")
}
private func stepTitleColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#9CA3AF") }
if step.isCompleted || step.isActive {
return AppColors.textPrimary
}
return Color(hex: "#9CA3AF")
}
private func stepSubtitleColor(_ step: TrackingStep) -> Color {
if isCanceled { return Color(hex: "#9CA3AF") }
if isWaitingPayment && step.id == "paid" {
return Color(hex: "#A16207")
}
if step.isCompleted || step.isActive {
return AppColors.primary
}
return Color(hex: "#9CA3AF")
}
private func timelineSteps(for order: PublicOrderResult) -> [TrackingStep] {
let isPickup = normalized(order.deliveryType ?? order.deliveryTypeLabel).contains("PICKUP")
let allSteps: [(id: String, title: String, subtitle: String, statuses: [String])] = [
("confirmed", "Pedido confirmado", "Seu pedido foi recebido", ["PENDING", "ACCEPTED", "PAYMENT_PENDING"]),
("preparing", "Pedido em produção", "Seu pedido está sendo preparado", ["PREPARING"]),
("ready", isPickup ? "Pronto para retirada" : "Pronto para entrega", isPickup ? "Retire no balcão quando avisarmos" : "Pedido pronto para sair", ["READY"]),
("delivering", "Em rota de entrega", customerOtpSubtitle, ["DELIVERING", "OUT_FOR_DELIVERY", "EM_ROTA", "ON_ROUTE"]),
("completed", isPickup ? "Retirado" : "Entregue", "Pedido finalizado", ["COMPLETED", "DELIVERED"])
]
let stepsBase = isPickup ? allSteps.filter { $0.id != "delivering" } : allSteps
let currentIndex = currentStepIndex(isPickup: isPickup)
return stepsBase.enumerated().map { index, step in
let event = timelineEvent(for: order, statuses: step.statuses)
let isCompleted = event?.completed ?? (isCanceled == false && index < currentIndex)
let isActive = event?.active ?? (isCanceled == false && index == currentIndex)
return TrackingStep(
id: step.id,
title: step.title,
subtitle: timelineSubtitle(stepId: step.id, fallback: step.subtitle, eventLabel: event?.label),
time: formatTime(event?.time),
isCompleted: isCompleted || (index == currentIndex && isCompletedTerminalStep(index: index, isPickup: isPickup)),
isActive: isActive && isCompletedTerminalStep(index: index, isPickup: isPickup) == false
)
}
}
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 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 shouldUseTimelineEventLabel(label, fallback: fallback) {
return label
}
return fallback
}
private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool {
guard label.isEmpty == false else { return false }
let foldedLabel = label
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
let foldedFallback = fallback
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if foldedLabel == foldedFallback { return false }
let englishHints = [
"order",
"confirmed",
"in progress",
"progress",
"delivery",
"delivered",
"ready",
"sent",
"out for",
"began"
]
if englishHints.contains(where: { foldedLabel.contains($0) }) {
return false
}
return true
}
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 isOnlinePaymentMethod {
return "Pagamento confirmado"
}
return "Pedido confirmado"
}
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 shouldShowReviewButton: Bool {
guard isCompletedOrder else { return false }
guard isCanceled == false else { return false }
guard let reviewTargetDraft else { return false }
if didSaveReviewForCurrentOrder { return false }
if hasPersistedReviewForCurrentOrder { return false }
return order?.review == nil
}
private var hasPersistedReviewForCurrentOrder: Bool {
reviewIdCandidates.contains { candidate in
SessionStateStore.hasOrderReview(orderId: candidate)
}
}
private var reviewIdCandidates: [String] {
let values = [
orderId,
order?.id,
order?.realId,
order?.shortId
]
var unique: [String] = []
var seen = Set<String>()
for raw in values {
let normalized = (raw ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue }
seen.insert(normalized)
unique.append(normalized)
}
return unique
}
private var reviewTargetDraft: ReviewDraft? {
let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = idFromOrder.isEmpty ? orderId : idFromOrder
guard id.isEmpty == false else { return nil }
return ReviewDraft(
orderId: id,
storeId: order?.storeId,
shortId: order?.shortId ?? initialShortId,
storeName: order?.storeName,
storeLogoURL: order?.storeLogoURL,
createdAt: order?.createdAt,
total: order?.total
)
}
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 "tracking-ready"
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 = iso.date(from: value)
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: value)
}
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
@MainActor
private func loadInitialOrder() async {
logger.info("OrderTracking initial fetch orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} else if let result = response.result {
order = result
storeContactPhone = result.storePhone
errorMessage = nil
logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
await refreshStoreContactPhone(for: result)
}
} catch {
errorMessage = "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
}
isLoading = false
}
@MainActor
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
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
return
}
}
} catch {
// Fallback handled below.
}
if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inlinePhone
}
}
private func openStoreWhatsApp() {
guard let phoneRaw = storeContactPhone,
let url = makeWhatsAppURL(from: phoneRaw) else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
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)")
}
}