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, 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, onSavedCardConfirmed: { cardId in showCardSelectionSheet = false Task { guard let storeId = appState.cart.storeId else { return } await confirmOrderWithSavedCard(cardId: cardId, storeId: storeId) } }, onNewCard: { showCardSelectionSheet = false cardPaymentContext = cardContext }, 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 copyPaste: String let qrCodeImageBase64: String? let expirationDate: 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 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), 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(context.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 = 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) } .disabled(isPixExpired) .opacity(isPixExpired ? 0.5 : 1.0) .padding(.top, 10) } .padding(20) } .background(AppColors.backgroundLight) .navigationTitle("Pagamento via PIX") .appInlineNavigationTitle() .task { 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() } } private func openTrackingOnce() { guard hasOpenedTracking == false else { return } hasOpenedTracking = true onOpenTracking?() } private func copyToClipboard(_ value: String) { appWriteClipboardText(value) } 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 ) } } // MARK: - CardSelectionSheet struct CardSelectionSheet: View { let savedCards: [SavedCard] let cardContext: CardPaymentContext let onSavedCardConfirmed: (String) -> Void let onNewCard: () -> Void let onOrderCreated: (String, String?) -> Void @Environment(\.dismiss) var dismiss @State var selectedCardId: String? @State var isSubmitting = false init(savedCards: [SavedCard], cardContext: CardPaymentContext, onSavedCardConfirmed: @escaping (String) -> Void, onNewCard: @escaping () -> Void, onOrderCreated: @escaping (String, String?) -> Void) { self.savedCards = savedCards self.cardContext = cardContext self.onSavedCardConfirmed = onSavedCardConfirmed self.onNewCard = onNewCard self.onOrderCreated = onOrderCreated _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) } var body: some View { NavigationStack { 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 { onNewCard() } 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(20) } .background(AppColors.backgroundLight) .navigationTitle("Selecionar Cartão") .appInlineNavigationTitle() .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancelar") { dismiss() } .foregroundStyle(AppColors.textMuted) } } } } 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.isEmpty == false && cardNumber.filter(\.isNumber).count >= 13 && expiry.count >= 4 && cvv.count >= 3 && cpf.filter(\.isNumber).count == 11 } var body: some View { 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) labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $holderName) HStack(spacing: 10) { labeledField("Validade", placeholder: "MM/AAAA", text: $expiry) .keyboardType(.numberPad) labeledField("CVV", placeholder: "•••", text: $cvv) .keyboardType(.numberPad) } labeledField("CPF do Titular", placeholder: "000.000.000-00", text: $cpf) .keyboardType(.numberPad) } .padding(14) } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) // Salvar cartão VStack(spacing: 0) { HStack { VStack(alignment: .leading, spacing: 2) { Text("Salvar cartão") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) Text("Pague mais rápido nas próximas compras") .font(.caption) .foregroundStyle(AppColors.textMuted) } Spacer() Toggle("", isOn: $saveCard) .labelsHidden() .tint(AppColors.primary) } .padding(14) if saveCard { Divider().padding(.horizontal, 14) labeledField("Apelido do cartão (opcional)", placeholder: "Ex: Meu Visa", text: $nickname) .padding(14) } } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .animation(.easeInOut(duration: 0.2), value: saveCard) PrimaryButton(title: isSubmitting ? "Processando..." : "Confirmar e Pagar") { guard canSubmit, isSubmitting == false else { return } Task { await submitOrder() } } .disabled(canSubmit == false || isSubmitting) } .padding(20) } .background(AppColors.backgroundLight) .navigationTitle("Novo Cartão") .appInlineNavigationTitle() } @MainActor private func submitOrder() async { isSubmitting = true defer { isSubmitting = false } let cleanCpf = cpf.filter(\.isNumber) let cleanCardNumber = cardNumber.filter(\.isNumber) let expiryParts = expiry.filter(\.isNumber) let expiryMonth = String(expiryParts.prefix(2)) let expiryYear: String let yearPart = String(expiryParts.dropFirst(2)) expiryYear = yearPart.count == 2 ? "20\(yearPart)" : yearPart let rawPhone = context.profilePhone.filter(\.isNumber) let phoneWithDdi = rawPhone.hasPrefix("55") ? rawPhone : "55\(rawPhone)" let holderInfo = SaveCardHolderInfoPayload( name: context.profileName, email: context.profileEmail, cpfCnpj: cleanCpf, postalCode: (context.addressZip ?? "").filter(\.isNumber), addressNumber: context.addressNumber ?? "", phone: phoneWithDdi ) let creditCardPayload = CreditCardOrderPayload( holderName: holderName, number: cleanCardNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv ) let payload = CreateOrderPayload( customer: CreateOrderCustomerPayload(name: context.profileName, phone: context.profilePhone, email: context.profileEmail, asaasId: nil), items: context.orderItems, total: context.total, paymentMethod: CheckoutPaymentMethod.creditCard.rawValue, deliveryType: context.deliveryType, address: context.addressPayload, savedCardId: nil, clientCpfCnpj: cleanCpf, creditCard: creditCardPayload, creditCardHolderInfo: holderInfo ) do { // Save CPF locally for future use if cleanCpf.count == 11 { appState.profile.cpf = cleanCpf Task { 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 { 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: ",") } }