From 6b397db494184e0e3cd9f1c15da179856eb895e8 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 3 Jun 2026 17:32:30 -0300 Subject: [PATCH 1/5] feat(cards): implement full saved card management and transparent checkout - ApiCardModels.swift: SavedCard, SaveCardPayload, CreditCardOrderPayload, etc. - ApiOrderModels.swift: add savedCardId, clientCpfCnpj, creditCard, creditCardHolderInfo to CreateOrderPayload; Codable for item/addon payloads - ApiService.swift: listCards, saveCard, updateCard, deleteCard, updateProfileCpf - AppState.ProfileState: add cpf field - CheckoutView: CardSelectionSheet (list saved cards + add new), rewrite PaymentCardView (submits order with card data), CardPaymentContext carries full order data - CheckoutView+Logic: buildCreateOrderPayload accepts card params, new loadSavedCards/confirmOrderWithSavedCard/handleOrderResponse helpers, redirect credit card confirm to CardSelectionSheet - OrdersView: CREDIT_CARD pending orders go to tracking (not card form, since card is now submitted with order) - UserProfileView: CPF field with mask, save via PATCH /api/customer/profile --- .../PediFoods/Services/ApiCardModels.swift | 67 +++ .../PediFoods/Services/ApiOrderModels.swift | 10 +- .../PediFoods/Services/ApiService.swift | 37 ++ .../Sources/PediFoods/State/AppState.swift | 1 + .../Views/Main/CheckoutView+Logic.swift | 192 +++++--- .../PediFoods/Views/Main/CheckoutView.swift | 463 +++++++++++++++--- .../PediFoods/Views/Main/OrdersView.swift | 17 +- .../Views/Main/UserProfileView.swift | 26 + 8 files changed, 636 insertions(+), 177 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift new file mode 100644 index 0000000..b4a5102 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift @@ -0,0 +1,67 @@ +import Foundation + +struct SavedCard: Decodable, Identifiable, Hashable { + let id: String + let nickname: String? + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool + + var displayLabel: String { + if let nickname, nickname.isEmpty == false { return nickname } + let brandLabel = (brand ?? "Cartão").capitalized + return "\(brandLabel) •••• \(last4)" + } + + var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" } +} + +struct SaveCardCreditCardPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} + +struct SaveCardHolderInfoPayload: Encodable { + let name: String + let email: String + let cpfCnpj: String + let postalCode: String + let addressNumber: String + let phone: String +} + +struct SaveCardPayload: Encodable { + let creditCard: SaveCardCreditCardPayload + let creditCardHolderInfo: SaveCardHolderInfoPayload + let nickname: String? + let isDefault: Bool +} + +struct UpdateCardPayload: Encodable { + let nickname: String? + let isDefault: Bool? +} + +struct SavedCardResult: Decodable { + let id: String + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool +} + +struct CreditCardOrderPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift index 275c804..dd95608 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable { let paymentMethod: String let deliveryType: String let address: CreateOrderAddressPayload? + // Cartão salvo + let savedCardId: String? + // Novo cartão (checkout transparente) + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? } struct CreateOrderCustomerPayload: Encodable { @@ -16,7 +22,7 @@ struct CreateOrderCustomerPayload: Encodable { let asaasId: String? } -struct CreateOrderItemPayload: Encodable { +struct CreateOrderItemPayload: Codable { let productId: String let name: String let qty: Int @@ -24,7 +30,7 @@ struct CreateOrderItemPayload: Encodable { let addons: [CreateOrderAddonPayload] } -struct CreateOrderAddonPayload: Encodable { +struct CreateOrderAddonPayload: Codable { let addonId: String let name: String let qty: Int diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index ee295f4..994272f 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -426,6 +426,43 @@ final class ApiService { ) return try await sendEnvelope(req) } + + // MARK: - Cards + + func listCards() async throws -> ApiEnvelope<[SavedCard]> { + let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func deleteCard(cardId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + // MARK: - Profile CPF + + func updateProfileCpf(cpf: String) async throws -> ApiEnvelope { + let payload = ["cpf": cpf] + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let result: ApiEnvelope = try await sendEnvelope(req) + if result.error == false { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + } + return result + } } extension ApiService { static func decodeFlexibleString(from container: KeyedDecodingContainer, keys: [K]) -> String? { diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 9d3b368..961b9bd 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -92,6 +92,7 @@ struct ProfileState { var email: String = "" var phone: String = "" var profilePicture: String = "" + var cpf: String = "" } struct AddressState { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 250dcb3..7de4bf1 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -234,7 +234,13 @@ extension CheckoutView { return } - let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod) + // Crédito pelo app → abre seleção de cartão antes de criar pedido + if useInAppPayment && effectivePaymentMethod == .creditCard { + showCardSelectionSheet = true + return + } + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil) guard case .success(let payload) = payloadBuildResult else { let message: String if case .failure(let reason) = payloadBuildResult { @@ -251,83 +257,31 @@ extension CheckoutView { do { let response = try await ApiService().createOrder(storeId: storeId, payload: payload) - if response.error { - SnackbarCenter.shared.show( - title: response.message ?? "Não foi possível criar o pedido.", - style: .error, - icon: "xmark.octagon.fill", - duration: 3.0 - ) - return - } - - guard let result = response.result else { - SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) - return - } - - let orderSnapshot = result.asPublicOrderResult() - SessionStateStore.saveTrackedOrder(orderSnapshot) - - let orderId = result.id ?? UUID().uuidString let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) if useInAppPayment == false || isInAppMethod == false { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + if response.error == false, let result = response.result { + let orderId = result.id ?? UUID().uuidString + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } return } - - if orderSnapshot.isPaymentConfirmed { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) - return - } - - SessionStateStore.savePendingCartOrderId(orderId) - - if effectivePaymentMethod == .creditCard { - cardPaymentContext = CardPaymentContext( - orderId: orderId, - shortId: result.shortId, - total: totalValue - ) - return - } - - let pixFromPayment = result.payment?.pix - let pixFromPayload = result.paymentPayload - - let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.copyPaste - : pixFromPayload?.copyPaste - let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.qrCodeImage - : pixFromPayload?.qrCodeImage - let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.expirationDate - : pixFromPayload?.expirationDate - - guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) - return - } - - pixPaymentContext = PixPaymentContext( - id: orderId, - orderId: orderId, - shortId: result.shortId, - copyPaste: copyPaste, - qrCodeImageBase64: qrCodeImage, - expirationDate: expirationDate - ) + handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod) } catch { SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } - func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result { + func buildCreateOrderPayload( + paymentMethod: CheckoutPaymentMethod, + savedCardId: String? = nil, + creditCard: CreditCardOrderPayload? = nil, + creditCardHolderInfo: SaveCardHolderInfoPayload? = nil, + clientCpfCnpj: String? = nil + ) -> Result { guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -360,18 +314,96 @@ extension CheckoutView { return .success( CreateOrderPayload( - customer: CreateOrderCustomerPayload( - name: profileName, - phone: profilePhone, - email: profileEmail, - asaasId: nil - ), - items: appState.cart.toOrderItemsPayload(), - total: totalValue, - paymentMethod: paymentMethod.rawValue, - deliveryType: deliveryType.rawValue, - address: addressPayload + customer: CreateOrderCustomerPayload( + name: profileName, + phone: profilePhone, + email: profileEmail, + asaasId: nil + ), + items: appState.cart.toOrderItemsPayload(), + total: totalValue, + paymentMethod: paymentMethod.rawValue, + deliveryType: deliveryType.rawValue, + address: addressPayload, + savedCardId: savedCardId, + clientCpfCnpj: clientCpfCnpj, + creditCard: creditCard, + creditCardHolderInfo: creditCardHolderInfo ) ) } + + @MainActor + func loadSavedCards() async { + do { + let response = try await ApiService().listCards() + if response.error == false, let cards = response.result { + savedCards = cards + } + } catch {} + } + + @MainActor + func confirmOrderWithSavedCard(cardId: String, storeId: String) async { + let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId) + guard case .success(let payload) = payloadResult else { return } + + isSubmittingOrder = true + defer { isSubmittingOrder = false } + + do { + let response = try await ApiService().createOrder(storeId: storeId, payload: payload) + handleOrderResponse(response, effectivePaymentMethod: .creditCard) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + func handleOrderResponse(_ response: ApiEnvelope, effectivePaymentMethod: CheckoutPaymentMethod) { + if response.error { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível criar o pedido.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + guard let result = response.result else { + SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + return + } + + let orderSnapshot = result.asPublicOrderResult() + SessionStateStore.saveTrackedOrder(orderSnapshot) + let orderId = result.id ?? UUID().uuidString + + if orderSnapshot.isPaymentConfirmed { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + SessionStateStore.savePendingCartOrderId(orderId) + + let pixFromPayment = result.payment?.pix + let pixFromPayload = result.paymentPayload + let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste + let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage + let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate + + if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + pixPaymentContext = PixPaymentContext( + id: orderId, orderId: orderId, shortId: result.shortId, + copyPaste: copyPaste, qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate + ) + return + } + + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 3f03913..30e9f07 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -26,6 +26,8 @@ struct CheckoutView: View { @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 } @@ -167,14 +169,48 @@ struct CheckoutView: View { .navigationDestination(item: $cardPaymentContext) { context in PaymentCardView( context: context, - onPaymentConfirmed: { + appState: $appState, + onOrderCreated: { orderId, shortId in appState.cart.clear() SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) } - ) { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.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 @@ -539,10 +575,55 @@ struct OrderTrackingContext: Identifiable, Hashable { } struct CardPaymentContext: Identifiable, Hashable { - var id: String { orderId } - let orderId: String - let shortId: String? + 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 { @@ -747,104 +828,328 @@ struct PaymentPixView: View { } } -struct PaymentCardView: View { - let context: CardPaymentContext - var onPaymentConfirmed: (() -> Void)? = nil - var onOpenTracking: (() -> Void)? = nil +// 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 cardHolderName = "" - @State var cardNumber = "" - @State var expiry = "" - @State var cvv = "" - @State var tracker = OrderRealtimeTracker() - @State var latestOrder: PublicOrderResult? = nil - @State var hasOpenedTracking = false + @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 { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 14) { - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .fill(AppColors.surface) - .overlay( - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Total do Pedido") - .font(AppTypography.caption) - .foregroundStyle(AppColors.textMuted) - Text(formatCurrency(context.total)) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) + 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) - ) - .frame(height: 88) - - Text("Dados do Cartão") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - - VStack(spacing: 10) { - labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber) - labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $cardHolderName) - HStack(spacing: 10) { - labeledField("Validade", placeholder: "MM/AA", text: $expiry) - labeledField("CVV", placeholder: "•••", text: $cvv) + .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)) - PrimaryButton(title: "Salvar e Pagar") { - if latestOrder?.isPaymentConfirmed == true { - onPaymentConfirmed?() - openTrackingOnce() - } else { - SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) + // 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) } - - Button("Apenas Pagar") { - if latestOrder?.isPaymentConfirmed == true { - onPaymentConfirmed?() - openTrackingOnce() - } else { - SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0) - } - } - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textMuted) - .frame(maxWidth: .infinity, minHeight: 48) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - .buttonStyle(.plain) + + // 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("Pagamento") + .navigationTitle("Novo Cartão") .appInlineNavigationTitle() - .task { - tracker.onOrderUpdated = { updated in - latestOrder = updated - if updated.isPaymentConfirmed { - onPaymentConfirmed?() - openTrackingOnce() + } + + @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 {} } } - tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) - } - .onDisappear { - tracker.stop() + + // 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 openTrackingOnce() { - guard hasOpenedTracking == false else { return } - hasOpenedTracking = true - onOpenTracking?() + 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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 5be7f66..ee664a9 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -633,7 +633,6 @@ struct OrderEntryDestinationView: View { @State var isResolvingRoute = true @State var didResolve = false @State var pixContext: PixPaymentContext? = nil - @State var cardContext: CardPaymentContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil @State var orderDetails: PublicOrderResult? = nil @@ -658,16 +657,6 @@ struct OrderEntryDestinationView: View { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) } ) - } else if let cardContext { - PaymentCardView( - context: cardContext, - onPaymentConfirmed: { - orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) - }, - onOpenTracking: { - orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) - } - ) } else if let orderDetails { OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) } else { @@ -725,11 +714,7 @@ struct OrderEntryDestinationView: View { return } if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { - cardContext = CardPaymentContext( - orderId: order.id, - shortId: order.shortId ?? initialShortId, - total: order.total ?? fallbackTotal ?? 0 - ) + orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId) return } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift index b04cb9e..697ac46 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -11,6 +11,7 @@ struct UserProfileView: View { @State var name: String = "" @State var email: String = "" @State var phone: String = "" + @State var cpf: String = "" @State var profilePicture: String = "" @State var isSaving = false @@ -97,6 +98,14 @@ struct UserProfileView: View { phone = masked } } + + textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf) + .keyboardType(.numberPad) + .onChange(of: cpf) { _, newValue in + let digits = newValue.filter(\.isNumber) + let masked = formatCPF(digits) + if masked != newValue { cpf = masked } + } .appNoAutoCap() } .padding(16) @@ -148,6 +157,15 @@ struct UserProfileView: View { email = appState.profile.email phone = formatPhoneForDisplay(appState.profile.phone) profilePicture = appState.profile.profilePicture + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" } @MainActor @@ -186,6 +204,14 @@ struct UserProfileView: View { appState.profile.email = customer?.email ?? cleanEmail appState.profile.phone = customer?.phoneNumber ?? normalizedPhone appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto + + let cleanCpf = cpf.filter(\.isNumber) + if cleanCpf.count == 11 { + appState.profile.cpf = cleanCpf + Task { + do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} + } + } SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) ) From a54895532094246d65354555af81e863a9bfa3db Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 3 Jun 2026 17:44:10 -0300 Subject: [PATCH 2/5] fix(cards): replace NavigationStack header with custom X button, new card form opens as sheet --- .../PediFoods/Views/Main/CheckoutView.swift | 85 ++++++++++++++----- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 30e9f07..8103fd4 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -189,6 +189,7 @@ struct CheckoutView: View { CardSelectionSheet( savedCards: savedCards, cardContext: cardContext, + appState: $appState, onSavedCardConfirmed: { cardId in showCardSelectionSheet = false Task { @@ -196,10 +197,6 @@ struct CheckoutView: View { await confirmOrderWithSavedCard(cardId: cardId, storeId: storeId) } }, - onNewCard: { - showCardSelectionSheet = false - cardPaymentContext = cardContext - }, onOrderCreated: { orderId, shortId in showCardSelectionSheet = false appState.cart.clear() @@ -833,25 +830,48 @@ struct PaymentPixView: View { struct CardSelectionSheet: View { let savedCards: [SavedCard] let cardContext: CardPaymentContext + @Binding var appState: AppState let onSavedCardConfirmed: (String) -> Void - let onNewCard: () -> 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, onSavedCardConfirmed: @escaping (String) -> Void, onNewCard: @escaping () -> Void, onOrderCreated: @escaping (String, String?) -> Void) { + 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.onNewCard = onNewCard self.onOrderCreated = onOrderCreated _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) } var body: some View { - NavigationStack { + 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 { @@ -881,7 +901,7 @@ struct CardSelectionSheet: View { } Button { - onNewCard() + showNewCardSheet = true } label: { HStack(spacing: 10) { Image(systemName: "plus.circle.fill") @@ -898,17 +918,20 @@ struct CardSelectionSheet: View { } .buttonStyle(.plain) } - .padding(20) + .padding(.horizontal, 20) + .padding(.bottom, 32) } - .background(AppColors.backgroundLight) - .navigationTitle("Selecionar Cartão") - .appInlineNavigationTitle() - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancelar") { dismiss() } - .foregroundStyle(AppColors.textMuted) + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showNewCardSheet) { + PaymentCardView( + context: cardContext, + appState: $appState, + onOrderCreated: { orderId, shortId in + showNewCardSheet = false + onOrderCreated(orderId, shortId) } - } + ) } } @@ -982,6 +1005,28 @@ struct PaymentCardView: View { } 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 @@ -1058,8 +1103,8 @@ struct PaymentCardView: View { .padding(20) } .background(AppColors.backgroundLight) - .navigationTitle("Novo Cartão") - .appInlineNavigationTitle() + } // VStack + .background(AppColors.backgroundLight) } @MainActor From abd58ac36de60ef2961f2940b96d607691254456 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 3 Jun 2026 17:58:44 -0300 Subject: [PATCH 3/5] feat(cards): add input masks and char limits to card form fields - Card number: groups of 4 digits (0000 0000 0000 0000), max 16 digits - Expiry: MM/AAAA mask, max 6 digits - CVV: digits only, max 4 - CPF: 000.000.000-00 mask, max 11 digits - canSubmit validates stripped digit counts --- .../PediFoods/Views/Main/CheckoutView.swift | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 8103fd4..d43053e 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -997,10 +997,10 @@ struct PaymentCardView: View { } var canSubmit: Bool { - holderName.isEmpty == false && + holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && cardNumber.filter(\.isNumber).count >= 13 && - expiry.count >= 4 && - cvv.count >= 3 && + expiry.filter(\.isNumber).count == 6 && + cvv.filter(\.isNumber).count >= 3 && cpf.filter(\.isNumber).count == 11 } @@ -1051,15 +1051,49 @@ struct PaymentCardView: View { 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.. Date: Wed, 3 Jun 2026 18:06:43 -0300 Subject: [PATCH 4/5] fix(cards): propagate API validation error message to user on card submit failure --- .../Sources/PediFoods/Views/Main/CheckoutView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index d43053e..d644a89 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -1217,6 +1217,14 @@ struct PaymentCardView: View { 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) } From ee8745553efbb5091a2867426544c66aa94da724 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 3 Jun 2026 18:49:07 -0300 Subject: [PATCH 5/5] feat(payment): implement change payment method (PATCH /payment-method) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChangePaymentMethodPayload + ChangePaymentMethodResult models - ApiService.changePaymentMethod() - PixPaymentContext gains storeId + order data for payment switch - PaymentPixView: Trocar button opens ChangePaymentSheet with 3 options - ChangePaymentSheet: Novo PIX (new QR), Cartão (saved or new), Pagar na Entrega - ChangePaymentCardSheet + ChangePaymentNewCardView with same card masks/validation - OrdersView/OrderEntryDestinationView: threaded appState binding --- .../PediFoods/Services/ApiCardModels.swift | 16 + .../PediFoods/Services/ApiService.swift | 6 + .../Views/Main/CheckoutView+Logic.swift | 19 +- .../PediFoods/Views/Main/CheckoutView.swift | 447 +++++++++++++++++- .../PediFoods/Views/Main/OrdersView.swift | 18 +- .../PediFoods/Views/Main/ProfileView.swift | 2 +- 6 files changed, 487 insertions(+), 21 deletions(-) diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift index b4a5102..c4c7b94 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift @@ -65,3 +65,19 @@ struct CreditCardOrderPayload: Encodable { let expiryYear: String let ccv: String } + +struct ChangePaymentMethodPayload: Encodable { + let paymentMethod: String + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? + let savedCardId: String? +} + +struct ChangePaymentMethodResult: Decodable { + let paymentMethod: String? + let paymentLocation: String? + let paymentId: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 994272f..0d90e42 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -451,6 +451,12 @@ final class ApiService { return try await sendEnvelope(req) } + func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + // MARK: - Profile CPF func updateProfileCpf(cpf: String) async throws -> ApiEnvelope { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 7de4bf1..55629a6 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -397,9 +397,24 @@ extension CheckoutView { ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + let storeId = appState.cart.storeId ?? "" pixPaymentContext = PixPaymentContext( - id: orderId, orderId: orderId, shortId: result.shortId, - copyPaste: copyPaste, qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate + id: orderId, + orderId: orderId, + shortId: result.shortId, + storeId: storeId, + copyPaste: copyPaste, + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate, + total: totalValue, + profileName: appState.profile.name, + profileEmail: appState.profile.email, + profilePhone: appState.profile.phone, + addressZip: selectedCustomerAddress?.zipCode, + addressNumber: selectedCustomerAddress?.number, + deliveryType: deliveryType.rawValue, + itemsJSON: itemsData ) return } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index d644a89..9ca15a4 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -156,6 +156,7 @@ struct CheckoutView: View { .navigationDestination(item: $pixPaymentContext) { context in PaymentPixView( context: context, + appState: $appState, onPaymentConfirmed: { appState.cart.clear() SessionStateStore.clearPendingCartOrder() @@ -560,9 +561,19 @@ 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 { @@ -625,6 +636,7 @@ struct CardPaymentContext: Identifiable, Hashable { struct PaymentPixView: View { let context: PixPaymentContext + @Binding var appState: AppState var onPaymentConfirmed: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss @@ -633,9 +645,20 @@ struct PaymentPixView: View { @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 = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), + 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)" @@ -683,33 +706,53 @@ struct PaymentPixView: View { .frame(height: 20) .overlay( VStack(spacing: 8) { - Text(context.copyPaste) + 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 = context.expirationDate, expirationDate.isEmpty == false { + + if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false { Text(expirationLabel) .font(.caption) .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) } - - PrimaryButton(title: "Copiar Código PIX") { - if isPixExpired { - showPixExpiredSnackbar() - return + + 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) } - 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) + + 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) } - .disabled(isPixExpired) - .opacity(isPixExpired ? 0.5 : 1.0) .padding(.top, 10) } .padding(20) @@ -717,6 +760,26 @@ struct PaymentPixView: View { .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 @@ -725,7 +788,7 @@ struct PaymentPixView: View { openTrackingOnce() } } - tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) + tracker.start(orderId: currentContext.orderId, jwt: DefaultTokenStore().jwt) } .task { while Task.isCancelled == false { @@ -752,8 +815,16 @@ struct PaymentPixView: View { 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 = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let raw = (currentContext.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard raw.isEmpty == false else { return nil } let iso = ISO8601DateFormatter() @@ -1257,3 +1328,347 @@ struct PaymentCardView: View { 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)) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index ee664a9..bf1c9ec 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -1,6 +1,7 @@ import SwiftUI struct OrdersView: View { + @Binding var appState: AppState @Environment(\.dismiss) var dismiss @State var isLoading = false @State var errorMessage: String? = nil @@ -58,7 +59,8 @@ struct OrdersView: View { initialShortId: context.shortId, fallbackPaymentMethod: context.paymentMethod, fallbackTotal: context.total, - routeIntent: context.intent + routeIntent: context.intent, + appState: $appState ) } } @@ -629,6 +631,7 @@ struct OrderEntryDestinationView: View { let fallbackPaymentMethod: String? let fallbackTotal: Double? let routeIntent: OrderRouteIntent + @Binding var appState: AppState @State var isResolvingRoute = true @State var didResolve = false @@ -650,6 +653,7 @@ struct OrderEntryDestinationView: View { } else if let pixContext { PaymentPixView( context: pixContext, + appState: $appState, onPaymentConfirmed: { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) }, @@ -701,15 +705,25 @@ struct OrderEntryDestinationView: View { let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate + let storeId = order.storeId ?? "" pixContext = PixPaymentContext( id: order.id, orderId: order.id, shortId: order.shortId ?? initialShortId, + storeId: storeId, copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (copyPaste ?? "") : "Código PIX indisponível no momento. Aguarde e tente novamente.", qrCodeImageBase64: qrCodeImage, - expirationDate: expirationDate + expirationDate: expirationDate, + total: order.total ?? 0, + profileName: "", + profileEmail: "", + profilePhone: "", + addressZip: nil, + addressNumber: nil, + deliveryType: order.deliveryType ?? "DELIVERY", + itemsJSON: "[]" ) return } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 495ddc8..4aa2b04 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -28,7 +28,7 @@ struct ProfileView: View { VStack(spacing: 14) { NavigationLink { - OrdersView() + OrdersView(appState: $appState) } label: { ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") }