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:
67
pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift
Normal file
67
pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user