Merge branch 'feature/card-management' into feature/card-brand-logo

# Conflicts:
#	pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
This commit is contained in:
Daniel Arantes Loverde
2026-06-04 15:38:50 -03:00
9 changed files with 1253 additions and 219 deletions

View File

@@ -0,0 +1,83 @@
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
}
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?
}

View File

@@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable {
let paymentMethod: String let paymentMethod: String
let deliveryType: String let deliveryType: String
let address: CreateOrderAddressPayload? 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 { struct CreateOrderCustomerPayload: Encodable {
@@ -16,7 +22,7 @@ struct CreateOrderCustomerPayload: Encodable {
let asaasId: String? let asaasId: String?
} }
struct CreateOrderItemPayload: Encodable { struct CreateOrderItemPayload: Codable {
let productId: String let productId: String
let name: String let name: String
let qty: Int let qty: Int
@@ -24,7 +30,7 @@ struct CreateOrderItemPayload: Encodable {
let addons: [CreateOrderAddonPayload] let addons: [CreateOrderAddonPayload]
} }
struct CreateOrderAddonPayload: Encodable { struct CreateOrderAddonPayload: Codable {
let addonId: String let addonId: String
let name: String let name: String
let qty: Int let qty: Int

View File

@@ -426,6 +426,49 @@ final class ApiService {
) )
return try await sendEnvelope(req) 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<SavedCardResult> {
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<SavedCardResult> {
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<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
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<EmptyResult> {
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<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return result
}
} }
extension ApiService { extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? { static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {

View File

@@ -92,6 +92,7 @@ struct ProfileState {
var email: String = "" var email: String = ""
var phone: String = "" var phone: String = ""
var profilePicture: String = "" var profilePicture: String = ""
var cpf: String = ""
} }
struct AddressState { struct AddressState {

View File

@@ -234,7 +234,13 @@ extension CheckoutView {
return 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 { guard case .success(let payload) = payloadBuildResult else {
let message: String let message: String
if case .failure(let reason) = payloadBuildResult { if case .failure(let reason) = payloadBuildResult {
@@ -251,83 +257,31 @@ extension CheckoutView {
do { do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload) 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) let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
if useInAppPayment == false || isInAppMethod == false { if useInAppPayment == false || isInAppMethod == false {
appState.cart.clear() if response.error == false, let result = response.result {
SessionStateStore.clearPendingCartOrder() let orderId = result.id ?? UUID().uuidString
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) 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 return
} }
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
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
)
} catch { } catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) 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<CreateOrderPayload, CheckoutPayloadValidationError> { func buildCreateOrderPayload(
paymentMethod: CheckoutPaymentMethod,
savedCardId: String? = nil,
creditCard: CreditCardOrderPayload? = nil,
creditCardHolderInfo: SaveCardHolderInfoPayload? = nil,
clientCpfCnpj: String? = nil
) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -360,18 +314,111 @@ extension CheckoutView {
return .success( return .success(
CreateOrderPayload( CreateOrderPayload(
customer: CreateOrderCustomerPayload( customer: CreateOrderCustomerPayload(
name: profileName, name: profileName,
phone: profilePhone, phone: profilePhone,
email: profileEmail, email: profileEmail,
asaasId: nil asaasId: nil
), ),
items: appState.cart.toOrderItemsPayload(), items: appState.cart.toOrderItemsPayload(),
total: totalValue, total: totalValue,
paymentMethod: paymentMethod.rawValue, paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue, deliveryType: deliveryType.rawValue,
address: addressPayload 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<CreateOrderResult>, 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 {
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,
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
}
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct OrdersView: View { struct OrdersView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@@ -58,7 +59,8 @@ struct OrdersView: View {
initialShortId: context.shortId, initialShortId: context.shortId,
fallbackPaymentMethod: context.paymentMethod, fallbackPaymentMethod: context.paymentMethod,
fallbackTotal: context.total, fallbackTotal: context.total,
routeIntent: context.intent routeIntent: context.intent,
appState: $appState
) )
} }
} }
@@ -629,11 +631,11 @@ struct OrderEntryDestinationView: View {
let fallbackPaymentMethod: String? let fallbackPaymentMethod: String?
let fallbackTotal: Double? let fallbackTotal: Double?
let routeIntent: OrderRouteIntent let routeIntent: OrderRouteIntent
@Binding var appState: AppState
@State var isResolvingRoute = true @State var isResolvingRoute = true
@State var didResolve = false @State var didResolve = false
@State var pixContext: PixPaymentContext? = nil @State var pixContext: PixPaymentContext? = nil
@State var cardContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil
@State var orderDetails: PublicOrderResult? = nil @State var orderDetails: PublicOrderResult? = nil
@@ -651,6 +653,7 @@ struct OrderEntryDestinationView: View {
} else if let pixContext { } else if let pixContext {
PaymentPixView( PaymentPixView(
context: pixContext, context: pixContext,
appState: $appState,
onPaymentConfirmed: { onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}, },
@@ -658,16 +661,6 @@ struct OrderEntryDestinationView: View {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) 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 { } else if let orderDetails {
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId)
} else { } else {
@@ -712,24 +705,30 @@ struct OrderEntryDestinationView: View {
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate ? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate : pixFromPayload?.expirationDate
let storeId = order.storeId ?? ""
pixContext = PixPaymentContext( pixContext = PixPaymentContext(
id: order.id, id: order.id,
orderId: order.id, orderId: order.id,
shortId: order.shortId ?? initialShortId, shortId: order.shortId ?? initialShortId,
storeId: storeId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "") ? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.", : "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage, qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate expirationDate: expirationDate,
total: order.total ?? 0,
profileName: "",
profileEmail: "",
profilePhone: "",
addressZip: nil,
addressNumber: nil,
deliveryType: order.deliveryType ?? "DELIVERY",
itemsJSON: "[]"
) )
return return
} }
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
cardContext = CardPaymentContext( orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId)
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
return return
} }
} }

View File

@@ -28,7 +28,7 @@ struct ProfileView: View {
VStack(spacing: 14) { VStack(spacing: 14) {
NavigationLink { NavigationLink {
OrdersView() OrdersView(appState: $appState)
} label: { } label: {
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
} }

View File

@@ -11,6 +11,7 @@ struct UserProfileView: View {
@State var name: String = "" @State var name: String = ""
@State var email: String = "" @State var email: String = ""
@State var phone: String = "" @State var phone: String = ""
@State var cpf: String = ""
@State var profilePicture: String = "" @State var profilePicture: String = ""
@State var isSaving = false @State var isSaving = false
@@ -97,6 +98,14 @@ struct UserProfileView: View {
phone = masked 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() .appNoAutoCap()
} }
.padding(16) .padding(16)
@@ -148,6 +157,15 @@ struct UserProfileView: View {
email = appState.profile.email email = appState.profile.email
phone = formatPhoneForDisplay(appState.profile.phone) phone = formatPhoneForDisplay(appState.profile.phone)
profilePicture = appState.profile.profilePicture 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 @MainActor
@@ -186,6 +204,14 @@ struct UserProfileView: View {
appState.profile.email = customer?.email ?? cleanEmail appState.profile.email = customer?.email ?? cleanEmail
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
let cleanCpf = cpf.filter(\.isNumber)
if cleanCpf.count == 11 {
appState.profile.cpf = cleanCpf
Task<Void, Never> {
do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {}
}
}
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
) )