import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif struct CheckoutView: View { @Binding var appState: AppState @State var storeInfo: StoreInfoResult? = nil @State var errorMessage: String? = nil @State var deliveryType: CheckoutDeliveryType = .delivery @State var paymentMethod: CheckoutPaymentMethod = .pix @State var useInAppPayment = true @State var discountValue: Double = 0 @State var baseDeliveryFee: Double? = nil @State var selectedCustomerAddress: CustomerAddress? = nil @State var addressValidationMessage: String? = nil @State var addressValidationBlocked = false @State var isValidatingAddress = false @State var showAddressNotServedAlert = false @State var isRestoringAddress = false @State var lastAcceptedAddressState: AddressState? = nil @State var isSubmittingOrder = false @State var pixPaymentContext: PixPaymentContext? = nil @State var cardPaymentContext: CardPaymentContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil @State var savedCards: [SavedCard] = [] @State var showCardSelectionSheet = false var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } var availableDeliveryTypes: [CheckoutDeliveryType] { let deliveryEnabled = paymentConfig?.paymentOnDelivery ?? true let pickupEnabled = paymentConfig?.paymentOnPickup ?? true var values: [CheckoutDeliveryType] = [] if deliveryEnabled { values.append(.delivery) } if pickupEnabled { values.append(.pickup) } return values.isEmpty ? [.delivery, .pickup] : values } var availableInAppPaymentMethods: [CheckoutPaymentMethod] { [.pix, .creditCard] } var availableStoreMachineMethods: [CheckoutPaymentMethod] { var methods: [CheckoutPaymentMethod] = [] if paymentConfig?.acceptCash == true { methods.append(.money) } if paymentConfig?.hasAnyCreditCard == true { methods.append(.creditCard) } if paymentConfig?.hasAnyDebitCard == true { methods.append(.debitCard) } if paymentConfig?.hasAnyVoucher == true { methods.append(.voucher) } return methods } var isDeliveryMode: Bool { deliveryType == .delivery } private var deliveryToggle: Binding { Binding( get: { isDeliveryMode }, set: { isOn in let next: CheckoutDeliveryType = isOn ? .delivery : .pickup if availableDeliveryTypes.contains(next) { deliveryType = next } } ) } private var subtotalValue: Double { appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } } private var deliveryFeeValue: Double { isDeliveryMode ? (baseDeliveryFee ?? 0) : 0 } var totalValue: Double { max(0, subtotalValue + deliveryFeeValue - discountValue) } private var sectionTitleColor: Color { AppColors.textMuted } var canConfirmPayment: Bool { if isDeliveryMode { if addressValidationBlocked { return false } if baseDeliveryFee == nil { return false } if isValidatingAddress { return false } } if useInAppPayment == false && availableStoreMachineMethods.isEmpty { return false } return true } var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { deliveryTypeSection addressSection orderSummarySection paymentSection if let errorMessage, errorMessage.isEmpty == false { Text(errorMessage) .font(.caption) .foregroundStyle(Color.red) } if let addressValidationMessage, addressValidationMessage.isEmpty == false { Text(addressValidationMessage) .font(.caption) .foregroundStyle(addressValidationBlocked ? Color.red : AppColors.primary) } } .padding(.horizontal, 20) .padding(.top, 16) .padding(.bottom, 10) } .background(AppColors.backgroundLight) .navigationTitle("Finalizar Pedido") .appInlineNavigationTitle() .appBottomSafeAreaInset { bottomBar } .task { await loadStoreInfoIfNeeded() await refreshSelectedCustomerAddress() normalizeSelectedOptions() await validateDeliveryAddressIfNeeded() } .onChange(of: checkoutAddressWatchKey) { _, _ in if isRestoringAddress { return } Task { await refreshSelectedCustomerAddress() await validateDeliveryAddressIfNeeded() } } .onChange(of: deliveryType) { _, _ in Task { await validateDeliveryAddressIfNeeded() } } .alert("Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?", isPresented: $showAddressNotServedAlert) { Button("Não", role: .cancel) { restoreLastAcceptedAddress() } Button("Sim", role: .destructive) { appState.cart.clear() addressValidationBlocked = false addressValidationMessage = nil } } .navigationDestination(item: $pixPaymentContext) { context in PaymentPixView( context: context, appState: $appState, 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, appState: $appState, onOrderCreated: { orderId, shortId in appState.cart.clear() SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) } ) } .sheet(isPresented: $showCardSelectionSheet) { let cardContext = CardPaymentContext.build( storeId: appState.cart.storeId ?? "", total: totalValue, deliveryType: deliveryType.rawValue, profile: appState.profile, address: selectedCustomerAddress, items: appState.cart.toOrderItemsPayload() ) CardSelectionSheet( savedCards: savedCards, cardContext: cardContext, appState: $appState, onSavedCardConfirmed: { cardId in showCardSelectionSheet = false Task { guard let storeId = appState.cart.storeId else { return } await confirmOrderWithSavedCard(cardId: cardId, storeId: storeId) } }, onOrderCreated: { orderId, shortId in showCardSelectionSheet = false appState.cart.clear() SessionStateStore.clearPendingCartOrder() orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) } ) } .task(id: paymentMethod) { if paymentMethod == .creditCard && useInAppPayment { await loadSavedCards() } } .navigationDestination(item: $orderTrackingContext) { context in OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) } } private var deliveryTypeSection: some View { VStack(alignment: .leading, spacing: 10) { Text("TIPO DE ENTREGA") .font(AppTypography.overline) .foregroundStyle(sectionTitleColor) HStack(spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text(isDeliveryMode ? "Entrega" : "Retirada") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } Spacer() Toggle("", isOn: deliveryToggle) .labelsHidden() .tint(AppColors.primary) .disabled(availableDeliveryTypes.count <= 1) } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } private var addressSection: some View { VStack(alignment: .leading, spacing: 10) { Text(addressSectionTitle) .font(AppTypography.overline) .foregroundStyle(sectionTitleColor) HStack(spacing: 12) { Circle() .fill(AppColors.brandSoft) .frame(width: 44, height: 44) .overlay( Image(systemName: "mappin.and.ellipse") .foregroundStyle(AppColors.primary) ) VStack(alignment: .leading, spacing: 4) { Text(isDeliveryMode ? "Casa" : (appState.cart.storeName ?? "Loja")) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) } Spacer() if isDeliveryMode { Button("Alterar") { appState.activeModal = .addressPicker } .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) .buttonStyle(.plain) } } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } private var orderSummarySection: some View { VStack(alignment: .leading, spacing: 10) { Text("RESUMO DO PEDIDO") .font(AppTypography.overline) .foregroundStyle(sectionTitleColor) VStack(alignment: .leading, spacing: 12) { ForEach(appState.cart.items) { item in HStack(alignment: .center, spacing: 10) { Text("\(item.quantity)x") .font(AppTypography.heading3) .foregroundStyle(AppColors.textMuted) .padding(.horizontal, 10) .padding(.vertical, 6) .background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) VStack(alignment: .leading, spacing: 2) { Text(item.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) if let details = item.details, details.isEmpty == false { Text(details) .font(.caption) .foregroundStyle(AppColors.textMuted) .lineLimit(1) } } Spacer() Text(formatCurrency(Double(item.quantity) * item.unitPrice)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } } Divider() .padding(.vertical, 4) summaryRow("Subtotal", formatCurrency(subtotalValue)) summaryRow("Taxa de entrega", deliveryFeeLabel) summaryRow("Desconto", "-\(formatCurrency(discountValue))", valueColor: Color.green) } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } private var paymentSection: some View { VStack(alignment: .leading, spacing: 10) { Text("MÉTODO DE PAGAMENTO") .font(AppTypography.overline) .foregroundStyle(sectionTitleColor) VStack(alignment: .leading, spacing: 0) { // Toggle row HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(useInAppPayment ? "Pagar Pelo App" : "Pagar Na Maquininha") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text(useInAppPayment ? "Mais rápido e seguro" : "Na entrega/retirada com a loja") .font(.caption) .foregroundStyle(AppColors.textMuted) } Spacer() Toggle("", isOn: Binding( get: { useInAppPayment }, set: { newValue in withAnimation(.easeInOut(duration: 0.2)) { useInAppPayment = newValue if newValue { if availableInAppPaymentMethods.contains(paymentMethod) == false { paymentMethod = .pix } } else { guard availableStoreMachineMethods.isEmpty == false else { useInAppPayment = true return } if availableStoreMachineMethods.contains(paymentMethod) == false, let first = availableStoreMachineMethods.first { paymentMethod = first } } } } )) .labelsHidden() .tint(AppColors.primary) } .padding(14) Divider() .padding(.horizontal, 14) // Methods list if useInAppPayment { ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in paymentRow(method, isInAppGroup: true) if method != availableInAppPaymentMethods.last { Divider().padding(.horizontal, 14) } } } else { if availableStoreMachineMethods.isEmpty { Text("Loja não informou métodos presenciais.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .padding(14) } else { ForEach(availableStoreMachineMethods, id: \.rawValue) { method in paymentRow(method, isInAppGroup: false) if method != availableStoreMachineMethods.last { Divider().padding(.horizontal, 14) } } } } } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } private func paymentRow(_ method: CheckoutPaymentMethod, isInAppGroup: Bool) -> some View { let subtitle = paymentSubtitle(for: method, isInAppGroup: isInAppGroup) return Button { paymentMethod = method } label: { HStack(spacing: 12) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(AppColors.backgroundLight) .frame(width: 54, height: 54) .overlay( Image(systemName: method.iconName) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(method == .pix ? Color.green : AppColors.textPrimary) ) VStack(alignment: .leading, spacing: 3) { Text(method.label) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) if let subtitle { Text(subtitle) .font(.caption) .foregroundStyle(AppColors.textMuted) } } Spacer() Circle() .stroke(paymentMethod == method ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) .frame(width: 24, height: 24) .background( Circle() .fill(paymentMethod == method ? AppColors.tertiary : Color.clear) ) } .padding(14) } .buttonStyle(.plain) } private func paymentSubtitle(for method: CheckoutPaymentMethod, isInAppGroup: Bool) -> String? { if isInAppGroup == false { if method == .pix { return "Pagamento presencial (QR da loja)" } return nil } return method.subtitle } private var bottomBar: some View { VStack(spacing: 12) { HStack { Text("Total a pagar") .font(AppTypography.heading3) .foregroundStyle(AppColors.textMuted) Spacer() Text(formatCurrency(totalValue)) .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) } Button { Task { await handleConfirmPaymentTap() } } label: { HStack(spacing: 10) { Text("Confirmar e Pagar") .font(AppTypography.heading2) Image(systemName: "checkmark") .font(.system(size: 18, weight: .bold)) } .foregroundStyle(AppColors.textInverse) .frame(maxWidth: .infinity, minHeight: 54) .background(AppColors.primary) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain) .disabled(canConfirmPayment == false || isValidatingAddress || isSubmittingOrder) .opacity((canConfirmPayment && isValidatingAddress == false && isSubmittingOrder == false) ? 1 : 0.65) } .padding(.horizontal, 20) .padding(.top, 10) .padding(.bottom, UIDevice.bottomNotch + 40) .background(AppColors.surface.opacity(0.98)) } private func summaryRow(_ title: String, _ value: String, valueColor: Color? = nil) -> some View { HStack { Text(title) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) Spacer() Text(value) .font(AppTypography.heading3) .foregroundStyle(valueColor ?? AppColors.textMuted) } } private var customerAddressLabel: String { if let address = selectedCustomerAddress { let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") if joined.isEmpty == false { return joined } } let value = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? "Defina seu endereço" : value } private var addressSectionTitle: String { if isDeliveryMode { return "ENDEREÇO DE ENTREGA" } let storeName = (appState.cart.storeName ?? "LOJA") .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() return "ENDEREÇO DE \(storeName)" } private var storeAddressLabel: String { guard let address = storeInfo?.address else { return "Endereço da loja indisponível" } let street = (address.street ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") return joined.isEmpty ? "Endereço da loja indisponível" : joined } private var deliveryFeeLabel: String { if isDeliveryMode == false { return formatCurrency(0) } if let baseDeliveryFee { return formatCurrency(baseDeliveryFee) } return "Calculando..." } } struct PixPaymentContext: Identifiable, Hashable { let id: String let orderId: String let shortId: String? let storeId: String let copyPaste: String let qrCodeImageBase64: String? let expirationDate: String? // Para troca de pagamento let total: Double let profileName: String let profileEmail: String let profilePhone: String let addressZip: String? let addressNumber: String? let deliveryType: String let itemsJSON: String } struct OrderTrackingContext: Identifiable, Hashable { var id: String { orderId } let orderId: String let shortId: String? } struct CardPaymentContext: Identifiable, Hashable { let id: String let storeId: String let total: Double let deliveryType: String let profileName: String let profileEmail: String let profilePhone: String let addressStreet: String? let addressNumber: String? let addressNeighborhood: String? let addressCity: String? let addressState: String? let addressZip: String? let addressComplement: String? let itemsJSON: String static func build(storeId: String, total: Double, deliveryType: String, profile: ProfileState, address: CustomerAddress?, items: [CreateOrderItemPayload]) -> CardPaymentContext { let itemsData = (try? JSONEncoder().encode(items)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" return CardPaymentContext( id: UUID().uuidString, storeId: storeId, total: total, deliveryType: deliveryType, profileName: profile.name, profileEmail: profile.email, profilePhone: profile.phone, addressStreet: address?.address, addressNumber: address?.number, addressNeighborhood: address?.neighborhood, addressCity: address?.city, addressState: address?.state, addressZip: address?.zipCode, addressComplement: address?.complement, itemsJSON: itemsData ) } var addressPayload: CreateOrderAddressPayload? { guard let street = addressStreet, street.isEmpty == false, let number = addressNumber, number.isEmpty == false, let neighborhood = addressNeighborhood, neighborhood.isEmpty == false else { return nil } return CreateOrderAddressPayload(street: street, number: number, neighborhood: neighborhood, city: addressCity, state: addressState, zip: addressZip, complement: addressComplement) } var orderItems: [CreateOrderItemPayload] { guard let data = itemsJSON.data(using: .utf8), let decoded = try? JSONDecoder().decode([CreateOrderItemPayload].self, from: data) else { return [] } return decoded } } struct PaymentPixView: View { let context: PixPaymentContext @Binding var appState: AppState 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() @State var showChangePaymentSheet = false @State var savedCards: [SavedCard] = [] @State var currentContext: PixPaymentContext init(context: PixPaymentContext, appState: Binding, onPaymentConfirmed: (() -> Void)? = nil, onOpenTracking: (() -> Void)? = nil) { self.context = context self._appState = appState self.onPaymentConfirmed = onPaymentConfirmed self.onOpenTracking = onOpenTracking _currentContext = State(initialValue: context) } private var qrImageSource: String? { guard let raw = currentContext.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), raw.isEmpty == false else { return nil } if raw.lowercased().hasPrefix("data:image") { return raw } return "data:image/png;base64,\(raw)" } var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .fill(AppColors.surface) .overlay( VStack(spacing: 10) { Text("Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) .multilineTextAlignment(.center) .padding(.horizontal, 12) .padding(.top, 14) AsyncStoreImage(imageURL: qrImageSource) .frame(width: 220, height: 220) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(AppColors.tertiary.opacity(0.35), lineWidth: 2) ) Text("AGUARDANDO PAGAMENTO") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .padding(.horizontal, 20) .padding(.vertical, 8) .background(AppColors.brandSoft) .clipShape(Capsule()) } ) .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(currentContext.copyPaste) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(AppColors.textMuted) .lineLimit(0) .multilineTextAlignment(.center) .padding(.horizontal, 10) } .padding(.vertical, 14) ) if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false { Text(expirationLabel) .font(.caption) .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) } HStack(spacing: 10) { PrimaryButton(title: "Copiar Código PIX") { if isPixExpired { showPixExpiredSnackbar() return } copyToClipboard(currentContext.copyPaste) SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) } .disabled(isPixExpired) .opacity(isPixExpired ? 0.5 : 1.0) Button { Task { await loadSavedCards() } showChangePaymentSheet = true } label: { HStack(spacing: 6) { Text("Trocar") .font(AppTypography.heading3) Image(systemName: "chevron.down") .font(.system(size: 12, weight: .semibold)) } .foregroundStyle(AppColors.textPrimary) .frame(height: 54) .padding(.horizontal, 16) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain) } .padding(.top, 10) } .padding(20) } .background(AppColors.backgroundLight) .navigationTitle("Pagamento via PIX") .appInlineNavigationTitle() .sheet(isPresented: $showChangePaymentSheet) { ChangePaymentSheet( pixContext: currentContext, savedCards: savedCards, appState: $appState, onChanged: { newContext in showChangePaymentSheet = false if let newContext { currentContext = newContext tracker.stop() tracker.start(orderId: newContext.orderId, jwt: DefaultTokenStore().jwt) } }, onConfirmed: { showChangePaymentSheet = false onPaymentConfirmed?() openTrackingOnce() } ) } .task { tracker.onOrderUpdated = { updated in latestOrder = updated if updated.isPaymentConfirmed { onPaymentConfirmed?() openTrackingOnce() } } tracker.start(orderId: currentContext.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() } } private func openTrackingOnce() { guard hasOpenedTracking == false else { return } hasOpenedTracking = true onOpenTracking?() } private func copyToClipboard(_ value: String) { appWriteClipboardText(value) } @MainActor private func loadSavedCards() async { guard savedCards.isEmpty else { return } if let cards = try? await ApiService().listCards().result { savedCards = cards } } private var parsedExpirationDate: Date? { let raw = (currentContext.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 ) } } // MARK: - CardSelectionSheet struct CardSelectionSheet: View { let savedCards: [SavedCard] let cardContext: CardPaymentContext @Binding var appState: AppState let onSavedCardConfirmed: (String) -> Void let onOrderCreated: (String, String?) -> Void @Environment(\.dismiss) var dismiss @State var selectedCardId: String? @State var isSubmitting = false @State var showNewCardSheet = false init(savedCards: [SavedCard], cardContext: CardPaymentContext, appState: Binding, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) { self.savedCards = savedCards self.cardContext = cardContext self._appState = appState self.onSavedCardConfirmed = onSavedCardConfirmed self.onOrderCreated = onOrderCreated _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) } var body: some View { VStack(spacing: 0) { // Header HStack { Text("Selecionar Cartão") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppColors.textMuted) .frame(width: 30, height: 30) .background(AppColors.surface) .clipShape(Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 16) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { if savedCards.isEmpty == false { VStack(alignment: .leading, spacing: 0) { ForEach(savedCards) { card in savedCardRow(card) if card.id != savedCards.last?.id { Divider().padding(.horizontal, 14) } } } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) if let selectedCardId { PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar \(formatCurrency(cardContext.total))") { guard isSubmitting == false else { return } onSavedCardConfirmed(selectedCardId) } .disabled(isSubmitting) } Text("ou") .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) .frame(maxWidth: .infinity, alignment: .center) } Button { showNewCardSheet = true } label: { HStack(spacing: 10) { Image(systemName: "plus.circle.fill") .font(.system(size: 18)) .foregroundStyle(AppColors.primary) Text("Adicionar novo cartão") .font(AppTypography.heading3) .foregroundStyle(AppColors.primary) Spacer() } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.bottom, 32) } } .background(AppColors.backgroundLight) .sheet(isPresented: $showNewCardSheet) { PaymentCardView( context: cardContext, appState: $appState, onOrderCreated: { orderId, shortId in showNewCardSheet = false onOrderCreated(orderId, shortId) } ) } } private func savedCardRow(_ card: SavedCard) -> some View { Button { selectedCardId = card.id } label: { HStack(spacing: 12) { Image(systemName: "creditcard.fill") .font(.system(size: 20)) .foregroundStyle(AppColors.textPrimary) .frame(width: 44, height: 44) .background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(card.displayLabel) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text("Vence \(card.expiryLabel)") .font(.caption) .foregroundStyle(AppColors.textMuted) } Spacer() Circle() .stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) .frame(width: 22, height: 22) .background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) } .padding(14) } .buttonStyle(.plain) } private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } } // MARK: - PaymentCardView struct PaymentCardView: View { let context: CardPaymentContext @Binding var appState: AppState let onOrderCreated: (String, String?) -> Void @Environment(\.dismiss) var dismiss @State var holderName: String @State var cardNumber = "" @State var expiry = "" @State var cvv = "" @State var cpf: String @State var saveCard = false @State var nickname = "" @State var isSubmitting = false init(context: CardPaymentContext, appState: Binding, onOrderCreated: @escaping (String, String?) -> Void) { self.context = context self._appState = appState self.onOrderCreated = onOrderCreated _holderName = State(initialValue: context.profileName) _cpf = State(initialValue: appState.wrappedValue.profile.cpf) } var canSubmit: Bool { holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && cardNumber.filter(\.isNumber).count >= 13 && expiry.filter(\.isNumber).count == 6 && cvv.filter(\.isNumber).count >= 3 && cpf.filter(\.isNumber).count == 11 } var body: some View { VStack(spacing: 0) { HStack { Text("Novo Cartão") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppColors.textMuted) .frame(width: 30, height: 30) .background(AppColors.surface) .clipShape(Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 16) ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { // Total HStack { VStack(alignment: .leading, spacing: 2) { Text("Total do Pedido") .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) Text(formatCurrency(context.total)) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) } Spacer() } .padding(14) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) // Dados do cartão VStack(alignment: .leading, spacing: 0) { sectionHeader("DADOS DO CARTÃO") VStack(spacing: 10) { labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber) .keyboardType(.numberPad) .onChange(of: cardNumber) { _, v in let d = String(v.filter(\.isNumber).prefix(16)) let masked = stride(from: 0, to: d.count, by: 4) .map { i -> String in let start = d.index(d.startIndex, offsetBy: i) let end = d.index(start, offsetBy: min(4, d.count - i)) return String(d[start.. { do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} } } // Optionally save card if saveCard { let savePayload = SaveCardPayload( creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanCardNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), creditCardHolderInfo: holderInfo, nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, isDefault: false ) Task { do { _ = try await ApiService().saveCard(payload: savePayload) } catch {} } } let response = try await ApiService().createOrder(storeId: context.storeId, payload: payload) if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) return } guard let result = response.result else { return } let orderId = result.id ?? UUID().uuidString onOrderCreated(orderId, result.shortId) } catch let error as NetworkError { let message: String if case .httpError(_, let serverMessage) = error, let serverMessage, serverMessage.isEmpty == false { message = serverMessage } else { message = error.errorDescription ?? "Não foi possível processar o pagamento." } SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 5.0) } catch { SnackbarCenter.shared.show(title: "Não foi possível processar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } private func sectionHeader(_ text: String) -> some View { Text(text) .font(AppTypography.overline) .foregroundStyle(AppColors.textMuted) .padding(.horizontal, 14) .padding(.top, 12) .padding(.bottom, 4) } private func labeledField(_ label: String, placeholder: String, text: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { Text(label) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) TextField(placeholder, text: text) .appNoAutoCap() .padding(.horizontal, 12) .frame(height: 46) .background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } } private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } } // MARK: - ChangePaymentSheet struct ChangePaymentSheet: View { let pixContext: PixPaymentContext let savedCards: [SavedCard] @Binding var appState: AppState let onChanged: (PixPaymentContext?) -> Void let onConfirmed: () -> Void @Environment(\.dismiss) var dismiss @State var isSubmitting = false @State var showCardSheet = false var body: some View { VStack(spacing: 0) { HStack { Text("Trocar Pagamento") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) Spacer() Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: 12, weight: .bold)) .foregroundStyle(AppColors.textMuted) .frame(width: 30, height: 30) .background(AppColors.surface) .clipShape(Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 16) ScrollView(showsIndicators: false) { VStack(spacing: 12) { optionRow(icon: "qrcode", title: "Novo QR Code PIX", subtitle: "Gera um novo código PIX") { Task { await changeToMethod("PIX") } } optionRow(icon: "creditcard.fill", title: "Cartão de Crédito", subtitle: "Débito imediato no cartão") { showCardSheet = true } optionRow(icon: "banknote.fill", title: "Pagar na Entrega", subtitle: "Pague ao receber o pedido") { Task { await changeToMethod("CASH") } } } .padding(.horizontal, 20) .padding(.bottom, 32) } } .background(AppColors.backgroundLight) .sheet(isPresented: $showCardSheet) { let items = (try? JSONDecoder().decode([CreateOrderItemPayload].self, from: pixContext.itemsJSON.data(using: .utf8) ?? Data())) ?? [] let cardCtx = CardPaymentContext.build(storeId: pixContext.storeId, total: pixContext.total, deliveryType: pixContext.deliveryType, profile: appState.profile, address: nil, items: items) ChangePaymentCardSheet(pixContext: pixContext, cardContext: cardCtx, savedCards: savedCards, appState: $appState) { _, _ in showCardSheet = false onConfirmed() } } } private func optionRow(icon: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 14) { Image(systemName: icon).font(.system(size: 20)).foregroundStyle(AppColors.primary) .frame(width: 44, height: 44).background(AppColors.brandSoft) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(title).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) Text(subtitle).font(.caption).foregroundStyle(AppColors.textMuted) } Spacer() if isSubmitting { ProgressView().scaleEffect(0.8) } else { Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(AppColors.textMuted) } } .padding(14).background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } .buttonStyle(.plain).disabled(isSubmitting) } @MainActor private func changeToMethod(_ method: String) async { isSubmitting = true defer { isSubmitting = false } let payload = ChangePaymentMethodPayload(paymentMethod: method, clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: nil) do { let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) if response.error { SnackbarCenter.shared.show(title: response.message ?? "Não foi possível trocar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) return } guard let result = response.result else { return } if method == "CASH" { SnackbarCenter.shared.show(title: "Pagamento alterado. Pague ao receber.", style: .success, icon: "checkmark.circle.fill", duration: 3.0) onConfirmed(); return } let pix = result.payment?.pix ?? result.paymentPayload if let copyPaste = pix?.copyPaste, copyPaste.isEmpty == false { let newCtx = PixPaymentContext(id: UUID().uuidString, orderId: pixContext.orderId, shortId: pixContext.shortId, storeId: pixContext.storeId, copyPaste: copyPaste, qrCodeImageBase64: pix?.qrCodeImage, expirationDate: pix?.expirationDate, total: pixContext.total, profileName: pixContext.profileName, profileEmail: pixContext.profileEmail, profilePhone: pixContext.profilePhone, addressZip: pixContext.addressZip, addressNumber: pixContext.addressNumber, deliveryType: pixContext.deliveryType, itemsJSON: pixContext.itemsJSON) SnackbarCenter.shared.show(title: "Novo QR Code gerado.", style: .success, icon: "qrcode", duration: 2.5) onChanged(newCtx) } } catch let error as NetworkError { let msg: String if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao trocar pagamento." } SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) } catch { SnackbarCenter.shared.show(title: "Erro ao trocar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } } // MARK: - ChangePaymentCardSheet struct ChangePaymentCardSheet: View { let pixContext: PixPaymentContext let cardContext: CardPaymentContext let savedCards: [SavedCard] @Binding var appState: AppState let onConfirmed: (String, String?) -> Void @Environment(\.dismiss) var dismiss @State var selectedCardId: String? @State var showNewCardForm = false @State var isSubmitting = false init(pixContext: PixPaymentContext, cardContext: CardPaymentContext, savedCards: [SavedCard], appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { self.pixContext = pixContext; self.cardContext = cardContext; self.savedCards = savedCards self._appState = appState; self.onConfirmed = onConfirmed _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) } var body: some View { VStack(spacing: 0) { HStack { Text("Cartão de Crédito").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) Spacer() Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) }.buttonStyle(.plain) } .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) ScrollView(showsIndicators: false) { VStack(spacing: 16) { if savedCards.isEmpty == false { VStack(spacing: 0) { ForEach(savedCards) { card in Button { selectedCardId = card.id } label: { HStack(spacing: 12) { Image(systemName: "creditcard.fill").font(.system(size: 18)).foregroundStyle(AppColors.textPrimary) .frame(width: 44, height: 44).background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) VStack(alignment: .leading, spacing: 3) { Text(card.displayLabel).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) Text("Vence \(card.expiryLabel)").font(.caption).foregroundStyle(AppColors.textMuted) } Spacer() Circle().stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) .frame(width: 22, height: 22).background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) }.padding(14) }.buttonStyle(.plain) if card.id != savedCards.last?.id { Divider().padding(.horizontal, 14) } } } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) if let selectedCardId { PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar com este Cartão") { guard isSubmitting == false else { return } Task { await changeToSavedCard(cardId: selectedCardId) } }.disabled(isSubmitting) } Text("ou").font(AppTypography.caption).foregroundStyle(AppColors.textMuted).frame(maxWidth: .infinity, alignment: .center) } Button { showNewCardForm = true } label: { HStack(spacing: 10) { Image(systemName: "plus.circle.fill").font(.system(size: 18)).foregroundStyle(AppColors.primary) Text("Novo cartão").font(AppTypography.heading3).foregroundStyle(AppColors.primary) Spacer() } .padding(14).background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) }.buttonStyle(.plain) } .padding(.horizontal, 20).padding(.bottom, 32) } } .background(AppColors.backgroundLight) .sheet(isPresented: $showNewCardForm) { ChangePaymentNewCardView(pixContext: pixContext, appState: $appState) { orderId, shortId in showNewCardForm = false; onConfirmed(orderId, shortId) } } } @MainActor private func changeToSavedCard(cardId: String) async { isSubmitting = true; defer { isSubmitting = false } let payload = ChangePaymentMethodPayload(paymentMethod: "CREDIT_CARD", clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: cardId) do { let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5); return } onConfirmed(pixContext.orderId, pixContext.shortId) } catch let error as NetworkError { let msg: String if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = "Pagamento recusado." } SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) } catch { SnackbarCenter.shared.show(title: "Erro ao processar.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } } // MARK: - ChangePaymentNewCardView struct ChangePaymentNewCardView: View { let pixContext: PixPaymentContext @Binding var appState: AppState let onConfirmed: (String, String?) -> Void @Environment(\.dismiss) var dismiss @State var holderName: String @State var cardNumber = "" @State var expiry = "" @State var cvv = "" @State var cpf: String @State var saveCard = false @State var nickname = "" @State var isSubmitting = false init(pixContext: PixPaymentContext, appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { self.pixContext = pixContext; self._appState = appState; self.onConfirmed = onConfirmed _holderName = State(initialValue: appState.wrappedValue.profile.name) _cpf = State(initialValue: appState.wrappedValue.profile.cpf) } var canSubmit: Bool { holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && cardNumber.filter(\.isNumber).count >= 13 && expiry.filter(\.isNumber).count == 6 && cvv.filter(\.isNumber).count >= 3 && cpf.filter(\.isNumber).count == 11 } var body: some View { VStack(spacing: 0) { HStack { Text("Novo Cartão").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) Spacer() Button { dismiss() } label: { Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) }.buttonStyle(.plain) } .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) ScrollView(showsIndicators: false) { VStack(spacing: 16) { VStack(spacing: 10) { cardField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber).keyboardType(.numberPad) .onChange(of: cardNumber) { _, v in let d = String(v.filter(\.isNumber).prefix(16)) let m = stride(from: 0, to: d.count, by: 4).map { i -> String in let s = d.index(d.startIndex, offsetBy: i); let e = d.index(s, offsetBy: min(4, d.count - i)); return String(d[s.. { do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} } } if saveCard { let sp = SaveCardPayload(creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), creditCardHolderInfo: holderInfo, nickname: nickname.trimmingCharacters(in: .whitespaces).isEmpty ? nil : nickname, isDefault: false) Task { do { _ = try await ApiService().saveCard(payload: sp) } catch {} } } do { let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 4.0); return } onConfirmed(pixContext.orderId, pixContext.shortId) } catch let error as NetworkError { let msg: String if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao processar." } SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 5.0) } catch { SnackbarCenter.shared.show(title: "Erro ao processar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } private func cardField(_ label: String, placeholder: String, text: Binding) -> some View { VStack(alignment: .leading, spacing: 6) { Text(label).font(AppTypography.caption).foregroundStyle(AppColors.textMuted) TextField(placeholder, text: text).appNoAutoCap() .padding(.horizontal, 12).frame(height: 46).background(AppColors.backgroundLight) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } } }