From 6b397db494184e0e3cd9f1c15da179856eb895e8 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 3 Jun 2026 17:32:30 -0300 Subject: [PATCH] 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) )