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
This commit is contained in:
Daniel Arantes Loverde
2026-06-03 17:32:30 -03:00
parent 2d27240cee
commit 6b397db494
8 changed files with 636 additions and 177 deletions

View File

@@ -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
}

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,43 @@ 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)
}
// 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,96 @@ 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 {
pixPaymentContext = PixPaymentContext(
id: orderId, orderId: orderId, shortId: result.shortId,
copyPaste: copyPaste, qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate
)
return
}
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
}
} }

View File

@@ -26,6 +26,8 @@ struct CheckoutView: View {
@State var pixPaymentContext: PixPaymentContext? = nil @State var pixPaymentContext: PixPaymentContext? = nil
@State var cardPaymentContext: CardPaymentContext? = nil @State var cardPaymentContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil
@State var savedCards: [SavedCard] = []
@State var showCardSelectionSheet = false
var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods }
@@ -167,14 +169,48 @@ struct CheckoutView: View {
.navigationDestination(item: $cardPaymentContext) { context in .navigationDestination(item: $cardPaymentContext) { context in
PaymentCardView( PaymentCardView(
context: context, context: context,
onPaymentConfirmed: { appState: $appState,
onOrderCreated: { orderId, shortId in
appState.cart.clear() appState.cart.clear()
SessionStateStore.clearPendingCartOrder() SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId)
} }
) { )
appState.cart.clear() }
SessionStateStore.clearPendingCartOrder() .sheet(isPresented: $showCardSelectionSheet) {
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) 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 .navigationDestination(item: $orderTrackingContext) { context in
@@ -539,10 +575,55 @@ struct OrderTrackingContext: Identifiable, Hashable {
} }
struct CardPaymentContext: Identifiable, Hashable { struct CardPaymentContext: Identifiable, Hashable {
var id: String { orderId } let id: String
let orderId: String let storeId: String
let shortId: String?
let total: Double 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 { struct PaymentPixView: View {
@@ -747,104 +828,328 @@ struct PaymentPixView: View {
} }
} }
struct PaymentCardView: View { // MARK: - CardSelectionSheet
let context: CardPaymentContext
var onPaymentConfirmed: (() -> Void)? = nil struct CardSelectionSheet: View {
var onOpenTracking: (() -> Void)? = nil let savedCards: [SavedCard]
let cardContext: CardPaymentContext
let onSavedCardConfirmed: (String) -> Void
let onNewCard: () -> Void
let onOrderCreated: (String, String?) -> Void
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var cardHolderName = "" @State var selectedCardId: String?
@State var cardNumber = "" @State var isSubmitting = false
@State var expiry = ""
@State var cvv = "" init(savedCards: [SavedCard], cardContext: CardPaymentContext, onSavedCardConfirmed: @escaping (String) -> Void, onNewCard: @escaping () -> Void, onOrderCreated: @escaping (String, String?) -> Void) {
@State var tracker = OrderRealtimeTracker() self.savedCards = savedCards
@State var latestOrder: PublicOrderResult? = nil self.cardContext = cardContext
@State var hasOpenedTracking = false 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 { var body: some View {
ScrollView(showsIndicators: false) { NavigationStack {
VStack(alignment: .leading, spacing: 14) { ScrollView(showsIndicators: false) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) VStack(alignment: .leading, spacing: 16) {
.fill(AppColors.surface) if savedCards.isEmpty == false {
.overlay( VStack(alignment: .leading, spacing: 0) {
HStack { ForEach(savedCards) { card in
VStack(alignment: .leading, spacing: 4) { savedCardRow(card)
Text("Total do Pedido") if card.id != savedCards.last?.id {
.font(AppTypography.caption) Divider().padding(.horizontal, 14)
.foregroundStyle(AppColors.textMuted) }
Text(formatCurrency(context.total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
} }
}
.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() Spacer()
} }
.padding(14) .padding(14)
) .background(AppColors.surface)
.frame(height: 88) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
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)
} }
.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<AppState>, 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) .padding(14)
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
PrimaryButton(title: "Salvar e Pagar") { // Dados do cartão
if latestOrder?.isPaymentConfirmed == true { VStack(alignment: .leading, spacing: 0) {
onPaymentConfirmed?() sectionHeader("DADOS DO CARTÃO")
openTrackingOnce() VStack(spacing: 10) {
} else { labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber)
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0) .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) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .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) .padding(20)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pagamento") .navigationTitle("Novo Cartão")
.appInlineNavigationTitle() .appInlineNavigationTitle()
.task { }
tracker.onOrderUpdated = { updated in
latestOrder = updated @MainActor
if updated.isPaymentConfirmed { private func submitOrder() async {
onPaymentConfirmed?() isSubmitting = true
openTrackingOnce() 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<Void, Never> {
do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {}
} }
} }
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
} // Optionally save card
.onDisappear { if saveCard {
tracker.stop() 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<Void, Never> {
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() { private func sectionHeader(_ text: String) -> some View {
guard hasOpenedTracking == false else { return } Text(text)
hasOpenedTracking = true .font(AppTypography.overline)
onOpenTracking?() .foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 4)
} }
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View { private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {

View File

@@ -633,7 +633,6 @@ struct OrderEntryDestinationView: View {
@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
@@ -658,16 +657,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 {
@@ -725,11 +714,7 @@ struct OrderEntryDestinationView: View {
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

@@ -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)
) )